craft-native 0.0.187 → 0.0.189

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -135,22 +135,105 @@ private enum CraftNativeTreeLayoutBridge {
135
135
  var childCount: UInt32
136
136
  }
137
137
 
138
- private struct Frame {
138
+ private struct StyleInputV2 {
139
+ var direction: UInt32 = 0
140
+ var width: Float = .nan
141
+ var height: Float = .nan
142
+ var intrinsicWidth: Float = .nan
143
+ var intrinsicHeight: Float = .nan
144
+ var baseline: Float = .nan
145
+ var aspectRatio: Float = .nan
146
+ var flexBasis: Float = .nan
147
+ var flexGrow: Float = 0
148
+ var flexShrink: Float = 0
149
+ var minWidth: Float = .nan
150
+ var maxWidth: Float = .nan
151
+ var minHeight: Float = .nan
152
+ var maxHeight: Float = .nan
153
+ var gap: Float = 0
154
+ var justifyContent: UInt32 = 0
155
+ var alignItems: UInt32 = 3
156
+ var alignSelf: UInt32 = 0
157
+ var flexWrap: UInt32 = 0
158
+ var alignContent: UInt32 = 6
159
+ var marginTop: Float = 0
160
+ var marginRight: Float = 0
161
+ var marginBottom: Float = 0
162
+ var marginLeft: Float = 0
163
+ var paddingTop: Float = 0
164
+ var paddingRight: Float = 0
165
+ var paddingBottom: Float = 0
166
+ var paddingLeft: Float = 0
167
+ var grid: UInt32 = 0
168
+ var gridColumns: UInt32 = 1
169
+ var rowGap: Float = 0
170
+ var columnGap: Float = 0
171
+ var reverse: UInt32 = 0
172
+ }
173
+
174
+ private struct NodeInputV2 {
175
+ var style: StyleInputV2
176
+ var firstChild: UInt32
177
+ var childCount: UInt32
178
+ }
179
+
180
+ fileprivate struct Frame {
139
181
  var x: Float
140
182
  var y: Float
141
183
  var width: Float
142
184
  var height: Float
143
185
  }
144
186
 
187
+ fileprivate struct TreeNode {
188
+ let view: UIView?
189
+ let style: CraftNativeLayoutStyle
190
+ let intrinsic: CGSize
191
+ let baseline: CGFloat
192
+ let direction: NSLayoutConstraint.Axis
193
+ let gap: CGFloat
194
+ let rowGap: CGFloat
195
+ let columnGap: CGFloat
196
+ let wrap: Bool
197
+ let justifyContent: String
198
+ let alignItems: String
199
+ let alignContent: String
200
+ let padding: UIEdgeInsets
201
+ let grid: Bool
202
+ let gridColumns: Int
203
+ let reverse: Bool
204
+ let wrapReverse: Bool
205
+ let children: [TreeNode]
206
+ }
207
+
208
+ fileprivate struct TreeResult {
209
+ let nodes: [TreeNode]
210
+ let parents: [Int]
211
+ let frames: [Frame]
212
+ }
213
+
145
214
  private typealias Function = @convention(c) (
146
215
  Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
147
216
  ) -> Int32
148
217
 
218
+ private typealias FunctionV2 = @convention(c) (
219
+ Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
220
+ ) -> Int32
221
+
149
222
  private static let function: Function? = {
150
223
  guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
151
224
  return unsafeBitCast(symbol, to: Function.self)
152
225
  }()
153
226
 
227
+ private static let functionV2: FunctionV2? = {
228
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v2") else { return nil }
229
+ return unsafeBitCast(symbol, to: FunctionV2.self)
230
+ }()
231
+
232
+ private static let functionV3: FunctionV2? = {
233
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v3") else { return nil }
234
+ return unsafeBitCast(symbol, to: FunctionV2.self)
235
+ }()
236
+
154
237
  private static func finite(_ value: CGFloat?) -> Float {
155
238
  guard let value, value.isFinite else { return .nan }
156
239
  return Float(value)
@@ -260,6 +343,199 @@ private enum CraftNativeTreeLayoutBridge {
260
343
  (child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
261
344
  }
262
345
  }
346
+
347
+ fileprivate static func treeFrames(root: TreeNode, width: CGFloat, height: CGFloat) -> TreeResult? {
348
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
349
+ if containsWrapReverse(root) {
350
+ return treeFramesV2(root: root, width: width, height: height, function: functionV3)
351
+ }
352
+ if containsReverse(root) {
353
+ return treeFramesV2(root: root, width: width, height: height, function: functionV2)
354
+ }
355
+ var nodes: [TreeNode] = []
356
+ var parents: [Int] = []
357
+ func append(_ node: TreeNode, parent: Int) {
358
+ let index = nodes.count
359
+ nodes.append(node)
360
+ parents.append(parent)
361
+ node.children.forEach { append($0, parent: index) }
362
+ }
363
+ append(root, parent: -1)
364
+
365
+ func put(_ input: inout StyleInput, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
366
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
367
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
368
+ let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
369
+ input.direction = node.direction == .horizontal ? 0 : 1
370
+ input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
371
+ input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
372
+ input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
373
+ input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
374
+ input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
375
+ input.aspectRatio = finite(node.style.aspectRatio)
376
+ input.flexBasis = length(node.style.flexBasis, base: mainSize)
377
+ input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
378
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
379
+ input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
380
+ input.minWidth = length(node.style.minWidth, base: contentWidth)
381
+ input.maxWidth = length(node.style.maxWidth, base: contentWidth)
382
+ input.minHeight = length(node.style.minHeight, base: contentHeight)
383
+ input.maxHeight = length(node.style.maxHeight, base: contentHeight)
384
+ input.gap = Float(max(0, node.gap))
385
+ input.justifyContent = justify(node.justifyContent)
386
+ input.alignItems = alignment(node.alignItems)
387
+ input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
388
+ input.flexWrap = node.wrap ? 1 : 0
389
+ input.alignContent = craftNativeAlignContentCode(node.alignContent)
390
+ input.marginTop = Float(max(0, node.style.marginTop))
391
+ input.marginRight = Float(max(0, node.style.marginRight))
392
+ input.marginBottom = Float(max(0, node.style.marginBottom))
393
+ input.marginLeft = Float(max(0, node.style.marginLeft))
394
+ input.paddingTop = Float(max(0, node.padding.top))
395
+ input.paddingRight = Float(max(0, node.padding.right))
396
+ input.paddingBottom = Float(max(0, node.padding.bottom))
397
+ input.paddingLeft = Float(max(0, node.padding.left))
398
+ input.grid = node.grid ? 1 : 0
399
+ input.gridColumns = UInt32(max(1, node.gridColumns))
400
+ input.rowGap = Float(max(0, node.rowGap))
401
+ input.columnGap = Float(max(0, node.columnGap))
402
+ }
403
+
404
+ var styles = [StyleInput](repeating: StyleInput(), count: nodes.count)
405
+ var metadata = [Float](repeating: 0, count: nodes.count * 2)
406
+ var cursor = 0
407
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
408
+ let index = cursor
409
+ cursor += 1
410
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
411
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
412
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
413
+ let firstChild = cursor
414
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
415
+ metadata[index * 2] = Float(firstChild)
416
+ metadata[index * 2 + 1] = Float(cursor - firstChild)
417
+ }
418
+ write(root, parentWidth: width, parentHeight: height, rootNode: true)
419
+ let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
420
+ NodeInput(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
421
+ }
422
+
423
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
424
+ let frameCount = output.count
425
+ let status = inputs.withUnsafeBufferPointer { inputBuffer in
426
+ metadata.withUnsafeBufferPointer { metadataBuffer in
427
+ output.withUnsafeMutableBufferPointer { outputBuffer in
428
+ function(
429
+ Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
430
+ UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
431
+ )
432
+ }
433
+ }
434
+ }
435
+ guard status == 0, output.allSatisfy({ frame in
436
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
437
+ frame.width >= 0 && frame.height >= 0
438
+ }) else { return nil }
439
+ return TreeResult(nodes: nodes, parents: parents, frames: output)
440
+ }
441
+
442
+ private static func containsReverse(_ node: TreeNode) -> Bool {
443
+ node.reverse || node.children.contains(where: containsReverse)
444
+ }
445
+
446
+ private static func containsWrapReverse(_ node: TreeNode) -> Bool {
447
+ node.wrapReverse || node.children.contains(where: containsWrapReverse)
448
+ }
449
+
450
+ private static func treeFramesV2(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
451
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
452
+ var nodes: [TreeNode] = []
453
+ var parents: [Int] = []
454
+ func append(_ node: TreeNode, parent: Int) {
455
+ let index = nodes.count
456
+ nodes.append(node)
457
+ parents.append(parent)
458
+ node.children.forEach { append($0, parent: index) }
459
+ }
460
+ append(root, parent: -1)
461
+
462
+ func put(_ input: inout StyleInputV2, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
463
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
464
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
465
+ let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
466
+ input.direction = node.direction == .horizontal ? 0 : 1
467
+ input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
468
+ input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
469
+ input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
470
+ input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
471
+ input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
472
+ input.aspectRatio = finite(node.style.aspectRatio)
473
+ input.flexBasis = length(node.style.flexBasis, base: mainSize)
474
+ input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
475
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && node.direction == .horizontal) ? 1 : 0
476
+ input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
477
+ input.minWidth = length(node.style.minWidth, base: contentWidth)
478
+ input.maxWidth = length(node.style.maxWidth, base: contentWidth)
479
+ input.minHeight = length(node.style.minHeight, base: contentHeight)
480
+ input.maxHeight = length(node.style.maxHeight, base: contentHeight)
481
+ input.gap = Float(max(0, node.gap))
482
+ input.justifyContent = justify(node.justifyContent)
483
+ input.alignItems = alignment(node.alignItems)
484
+ input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
485
+ input.flexWrap = node.wrap ? 1 : 0
486
+ input.alignContent = craftNativeAlignContentCode(node.alignContent)
487
+ input.marginTop = Float(max(0, node.style.marginTop))
488
+ input.marginRight = Float(max(0, node.style.marginRight))
489
+ input.marginBottom = Float(max(0, node.style.marginBottom))
490
+ input.marginLeft = Float(max(0, node.style.marginLeft))
491
+ input.paddingTop = Float(max(0, node.padding.top))
492
+ input.paddingRight = Float(max(0, node.padding.right))
493
+ input.paddingBottom = Float(max(0, node.padding.bottom))
494
+ input.paddingLeft = Float(max(0, node.padding.left))
495
+ input.grid = node.grid ? 1 : 0
496
+ input.gridColumns = UInt32(max(1, node.gridColumns))
497
+ input.rowGap = Float(max(0, node.rowGap))
498
+ input.columnGap = Float(max(0, node.columnGap))
499
+ input.reverse = (node.reverse ? 1 : 0) | (node.wrapReverse ? 2 : 0)
500
+ }
501
+
502
+ var styles = [StyleInputV2](repeating: StyleInputV2(), count: nodes.count)
503
+ var metadata = [Float](repeating: 0, count: nodes.count * 2)
504
+ var cursor = 0
505
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool) {
506
+ let index = cursor
507
+ cursor += 1
508
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode)
509
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
510
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
511
+ let firstChild = cursor
512
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false) }
513
+ metadata[index * 2] = Float(firstChild)
514
+ metadata[index * 2 + 1] = Float(cursor - firstChild)
515
+ }
516
+ write(root, parentWidth: width, parentHeight: height, rootNode: true)
517
+ let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
518
+ NodeInputV2(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
519
+ }
520
+
521
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
522
+ let frameCount = output.count
523
+ let status = inputs.withUnsafeBufferPointer { inputBuffer in
524
+ metadata.withUnsafeBufferPointer { metadataBuffer in
525
+ output.withUnsafeMutableBufferPointer { outputBuffer in
526
+ function(
527
+ Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
528
+ UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
529
+ )
530
+ }
531
+ }
532
+ }
533
+ guard status == 0, output.allSatisfy({ frame in
534
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
535
+ frame.width >= 0 && frame.height >= 0
536
+ }) else { return nil }
537
+ return TreeResult(nodes: nodes, parents: parents, frames: output)
538
+ }
263
539
  }
264
540
 
265
541
  private enum CraftNativeFlexLineBridge {
@@ -1548,6 +1824,137 @@ private class CraftNativeFlowView: UIStackView {
1548
1824
  flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
1549
1825
  }
1550
1826
 
1827
+ /// Builds the eligible portion of this container for the host-neutral
1828
+ /// tree ABI. The local algorithm remains authoritative for
1829
+ /// grid-auto-rows, absolute children, and any subtree whose measurement
1830
+ /// cannot be represented by the shared contract.
1831
+ fileprivate func sharedTreeNode(parentWidth: CGFloat, parentHeight: CGFloat) -> CraftNativeTreeLayoutBridge.TreeNode? {
1832
+ guard gridAutoRows == nil else { return nil }
1833
+ let children = visibleChildren
1834
+ let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
1835
+ guard flow.count == children.count else { return nil }
1836
+ let style = craftLayoutStyle
1837
+ let width = style.width?.resolve(parentWidth)
1838
+ let height = style.height?.resolve(parentHeight)
1839
+ let intrinsic = CraftNativeFlexLayout.measure(
1840
+ self, width: width, height: height,
1841
+ maxWidth: max(0, parentWidth), maxHeight: max(0, parentHeight)
1842
+ )
1843
+ let measuredWidth = width ?? intrinsic.width
1844
+ let measuredHeight = height ?? intrinsic.height
1845
+ let contentWidth = max(0, measuredWidth - effectivePadding.left - effectivePadding.right)
1846
+ let contentHeight = max(0, measuredHeight - effectivePadding.top - effectivePadding.bottom)
1847
+ let nodes = flow.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
1848
+ let childStyle = child.craftLayoutStyle
1849
+ let childWidth = childStyle.width?.resolve(contentWidth)
1850
+ let childHeight = childStyle.height?.resolve(contentHeight)
1851
+ let childIntrinsic = CraftNativeFlexLayout.measure(
1852
+ child, width: childWidth, height: childHeight,
1853
+ maxWidth: contentWidth, maxHeight: contentHeight
1854
+ )
1855
+ let nested = child as? CraftNativeFlowView
1856
+ let nestedNode = nested?.sharedTreeNode(
1857
+ parentWidth: contentWidth, parentHeight: contentHeight
1858
+ )
1859
+ if nested != nil && nestedNode == nil { return nil }
1860
+ return CraftNativeTreeLayoutBridge.TreeNode(
1861
+ view: child,
1862
+ style: childStyle,
1863
+ intrinsic: childIntrinsic,
1864
+ baseline: Self.firstBaseline(child, cross: childIntrinsic.height),
1865
+ direction: nested?.axis ?? .horizontal,
1866
+ gap: nested.map { $0.axis == .horizontal ? ($0.columnGap ?? $0.gap) : ($0.rowGap ?? $0.gap) } ?? 0,
1867
+ rowGap: nested?.rowGap ?? 0,
1868
+ columnGap: nested?.columnGap ?? 0,
1869
+ wrap: nested?.wrap ?? false,
1870
+ justifyContent: nested?.justifyContent ?? "flex-start",
1871
+ alignItems: nested?.alignItems ?? "stretch",
1872
+ alignContent: nested?.alignContent ?? "flex-start",
1873
+ padding: nested?.effectivePadding ?? .zero,
1874
+ grid: nested?.grid ?? false,
1875
+ gridColumns: nested?.gridColumns ?? 1,
1876
+ reverse: nested?.reverse ?? false,
1877
+ wrapReverse: nested?.wrapReverse ?? false,
1878
+ children: nestedNode?.children ?? []
1879
+ )
1880
+ }
1881
+ guard nodes.allSatisfy({ $0 != nil }) else { return nil }
1882
+ return CraftNativeTreeLayoutBridge.TreeNode(
1883
+ view: self,
1884
+ style: style,
1885
+ intrinsic: intrinsic,
1886
+ baseline: 0,
1887
+ direction: axis,
1888
+ gap: axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap),
1889
+ rowGap: rowGap ?? 0,
1890
+ columnGap: columnGap ?? 0,
1891
+ wrap: wrap,
1892
+ justifyContent: justifyContent,
1893
+ alignItems: alignItems,
1894
+ alignContent: alignContent,
1895
+ padding: effectivePadding,
1896
+ grid: grid,
1897
+ gridColumns: gridColumns,
1898
+ reverse: reverse,
1899
+ wrapReverse: wrapReverse,
1900
+ children: nodes.compactMap { $0 }
1901
+ )
1902
+ }
1903
+
1904
+ private func sharedTreeFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> CraftNativeTreeLayoutBridge.TreeResult? {
1905
+ guard let width, let height, gridAutoRows == nil,
1906
+ width.isFinite, height.isFinite, width >= 0, height >= 0 else { return nil }
1907
+ let nodes = children.map { child -> CraftNativeTreeLayoutBridge.TreeNode? in
1908
+ let style = child.craftLayoutStyle
1909
+ let intrinsic = CraftNativeFlexLayout.measure(child, width: style.width?.resolve(width), height: style.height?.resolve(height), maxWidth: width, maxHeight: height)
1910
+ let nested = child as? CraftNativeFlowView
1911
+ let nestedNode = nested?.sharedTreeNode(parentWidth: width, parentHeight: height)
1912
+ if nested != nil && nestedNode == nil { return nil }
1913
+ return CraftNativeTreeLayoutBridge.TreeNode(
1914
+ view: child,
1915
+ style: style,
1916
+ intrinsic: intrinsic,
1917
+ baseline: Self.firstBaseline(child, cross: intrinsic.height),
1918
+ direction: nested?.axis ?? .horizontal,
1919
+ gap: nested.map { $0.axis == .horizontal ? ($0.columnGap ?? $0.gap) : ($0.rowGap ?? $0.gap) } ?? 0,
1920
+ rowGap: nested?.rowGap ?? 0,
1921
+ columnGap: nested?.columnGap ?? 0,
1922
+ wrap: nested?.wrap ?? false,
1923
+ justifyContent: nested?.justifyContent ?? "flex-start",
1924
+ alignItems: nested?.alignItems ?? "stretch",
1925
+ alignContent: nested?.alignContent ?? "flex-start",
1926
+ padding: nested?.effectivePadding ?? .zero,
1927
+ grid: nested?.grid ?? false,
1928
+ gridColumns: nested?.gridColumns ?? 1,
1929
+ reverse: nested?.reverse ?? false,
1930
+ wrapReverse: nested?.wrapReverse ?? false,
1931
+ children: nestedNode?.children ?? []
1932
+ )
1933
+ }
1934
+ guard nodes.allSatisfy({ $0 != nil }) else { return nil }
1935
+ let root = CraftNativeTreeLayoutBridge.TreeNode(
1936
+ view: nil,
1937
+ style: .empty,
1938
+ intrinsic: .zero,
1939
+ baseline: 0,
1940
+ direction: axis,
1941
+ gap: axis == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap),
1942
+ rowGap: rowGap ?? 0,
1943
+ columnGap: columnGap ?? 0,
1944
+ wrap: wrap,
1945
+ justifyContent: justifyContent,
1946
+ alignItems: alignItems,
1947
+ alignContent: alignContent,
1948
+ padding: effectivePadding,
1949
+ grid: grid,
1950
+ gridColumns: gridColumns,
1951
+ reverse: reverse,
1952
+ wrapReverse: wrapReverse,
1953
+ children: nodes.compactMap { $0 }
1954
+ )
1955
+ return CraftNativeTreeLayoutBridge.treeFrames(root: root, width: width, height: height)
1956
+ }
1957
+
1551
1958
  /// The flexbox algorithm, single pass. `width`/`height` are this view's
1552
1959
  /// outer size when its parent fixes it, nil when it sizes to its content
1553
1960
  /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
@@ -1609,7 +2016,19 @@ private class CraftNativeFlowView: UIStackView {
1609
2016
  frames: &frames
1610
2017
  )
1611
2018
  }
1612
- if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
2019
+ if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height), (sharedTree.nodes.count > flow.count + 1 || grid) {
2020
+ frames = []
2021
+ for index in 1..<sharedTree.nodes.count {
2022
+ guard let view = sharedTree.nodes[index].view else { continue }
2023
+ let offset = sharedTree.frames[index]
2024
+ let parent = sharedTree.parents[index]
2025
+ let parentFrame = sharedTree.frames[parent]
2026
+ frames.append((view, CGRect(
2027
+ x: CGFloat(offset.x - parentFrame.x), y: CGFloat(offset.y - parentFrame.y),
2028
+ width: CGFloat(offset.width), height: CGFloat(offset.height)
2029
+ )))
2030
+ }
2031
+ } else if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
1613
2032
  frames = shared
1614
2033
  }
1615
2034
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
@@ -3321,6 +3740,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3321
3740
  // a screen that hid its own.
3322
3741
  navigationController?.setNavigationBarHidden(false, animated: animated)
3323
3742
  }
3743
+ applyNavigationBarAppearance()
3324
3744
  // Each screen says whether it wants the tab bar, so one that hid it
3325
3745
  // never leaves it hidden for the next.
3326
3746
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
@@ -3406,6 +3826,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3406
3826
 
3407
3827
  private func refreshTraitDefaults() {
3408
3828
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
3829
+ applyNavigationBarAppearance()
3409
3830
  if let document = mutationDocument.node("root") {
3410
3831
  renderCommitted(document)
3411
3832
  } else {
@@ -3637,7 +4058,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3637
4058
  /// - `craft.secureStorage.getSync(key)`: the Keychain item the web page's
3638
4059
  /// `craft.secureStorage.set` wrote.
3639
4060
  /// - `craft.navigation.setOptions({ title, largeTitle, hidden, backTitle,
3640
- /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled, backgroundColor, rightButtons })`;
4061
+ /// tabBarHidden, swipeBack, contentSwipeBack, tabSwipeEnabled,
4062
+ /// backgroundColor, navigationBarSolid, rightButtons })`;
3641
4063
  /// a right button's tap is the event `navButton`.
3642
4064
  ///
3643
4065
  /// A runtime that assigns `craft.storage` (or the others) afterwards keeps
@@ -3905,6 +4327,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3905
4327
  /// own horizontal pager or chart gesture.
3906
4328
  private(set) var tabSwipeEnabled = true
3907
4329
  private var prefersLargeTitle: Bool?
4330
+ /// Whether this screen opts into an opaque navigation bar filled with its
4331
+ /// own background colour. The default keeps UIKit's material appearance.
4332
+ private var navigationBarSolid = false
3908
4333
  /// The screen's own colour (`backgroundColor`), under the status bar and
3909
4334
  /// the home indicator too, where its content never reaches: a black
3910
4335
  /// full-screen timer is black edge to edge. nil is the app's.
@@ -3964,6 +4389,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3964
4389
  screenBackgroundColor = color(options["backgroundColor"])
3965
4390
  view.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
3966
4391
  }
4392
+ if let solid = options["navigationBarSolid"] as? Bool {
4393
+ navigationBarSolid = solid
4394
+ }
3967
4395
  if let hidden = options["hidden"] as? Bool {
3968
4396
  navigationBarHidden = hidden
3969
4397
  if navigationController?.topViewController === self {
@@ -3974,6 +4402,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3974
4402
  let buttons = (options["rightButtons"] as? [[String: Any]]) ?? []
3975
4403
  navigationItem.rightBarButtonItems = buttons.compactMap(navigationButton).reversed()
3976
4404
  }
4405
+ if options.keys.contains("backgroundColor") || options.keys.contains("navigationBarSolid") {
4406
+ applyNavigationBarAppearance()
4407
+ }
3977
4408
  // Asked for after the bar was hidden for a bare root screen.
3978
4409
  if navigationBarHidden == nil, wantsNavigationBar, navigationController?.topViewController === self,
3979
4410
  navigationController?.isNavigationBarHidden == true {
@@ -3981,6 +4412,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3981
4412
  }
3982
4413
  }
3983
4414
 
4415
+ /// Applies the screen's optional opaque bar without changing the default
4416
+ /// UIKit material when the screen did not opt in.
4417
+ private func applyNavigationBarAppearance() {
4418
+ guard navigationBarSolid else {
4419
+ navigationItem.standardAppearance = nil
4420
+ navigationItem.scrollEdgeAppearance = nil
4421
+ navigationItem.compactAppearance = nil
4422
+ navigationItem.compactScrollEdgeAppearance = nil
4423
+ return
4424
+ }
4425
+ let appearance = UINavigationBarAppearance()
4426
+ appearance.configureWithOpaqueBackground()
4427
+ appearance.backgroundColor = screenBackgroundColor ?? config.resolvedBackgroundColor
4428
+ appearance.shadowColor = .clear
4429
+ navigationItem.standardAppearance = appearance
4430
+ navigationItem.scrollEdgeAppearance = appearance
4431
+ navigationItem.compactAppearance = appearance
4432
+ navigationItem.compactScrollEdgeAppearance = appearance
4433
+ }
4434
+
3984
4435
  private func applyStatusBar(_ props: [String: Any]) {
3985
4436
  let style: UIStatusBarStyle?
3986
4437
  switch (props["barStyle"] as? String)?.lowercased() {