craft-native 0.0.158 → 0.0.161

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.
@@ -182,11 +182,32 @@ private enum CraftNativeGridBridge {
182
182
  private typealias Function = @convention(c) (
183
183
  Float, Float, UInt32, Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
184
184
  ) -> Int32
185
+ private typealias AutoRowsFunction = @convention(c) (
186
+ Float, Float, UInt32, Float, Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
187
+ ) -> Int32
188
+ private typealias AlignedFunction = @convention(c) (
189
+ Float, Float, UInt32, Float, Float, UInt32, UnsafeRawPointer, Int, UnsafeMutableRawPointer
190
+ ) -> Int32
191
+ private typealias AutoRowsAlignedFunction = @convention(c) (
192
+ Float, Float, UInt32, Float, Float, UInt32, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer
193
+ ) -> Int32
185
194
 
186
195
  private static let function: Function? = {
187
196
  guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid") else { return nil }
188
197
  return unsafeBitCast(symbol, to: Function.self)
189
198
  }()
199
+ private static let autoRowsFunction: AutoRowsFunction? = {
200
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows") else { return nil }
201
+ return unsafeBitCast(symbol, to: AutoRowsFunction.self)
202
+ }()
203
+ private static let alignedFunction: AlignedFunction? = {
204
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_aligned") else { return nil }
205
+ return unsafeBitCast(symbol, to: AlignedFunction.self)
206
+ }()
207
+ private static let autoRowsAlignedFunction: AutoRowsAlignedFunction? = {
208
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows_aligned") else { return nil }
209
+ return unsafeBitCast(symbol, to: AutoRowsAlignedFunction.self)
210
+ }()
190
211
 
191
212
  static func frames(
192
213
  availableWidth: CGFloat,
@@ -194,9 +215,13 @@ private enum CraftNativeGridBridge {
194
215
  columns: Int,
195
216
  columnGap: CGFloat,
196
217
  rowGap: CGFloat,
197
- items: [(intrinsicWidth: CGFloat, intrinsicHeight: CGFloat, explicitWidth: CGFloat?, explicitHeight: CGFloat?, minWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, maxHeight: CGFloat?, marginLeft: CGFloat, marginRight: CGFloat, marginTop: CGFloat, marginBottom: CGFloat, stretch: Bool, alignment: UInt32)]
218
+ items: [(intrinsicWidth: CGFloat, intrinsicHeight: CGFloat, explicitWidth: CGFloat?, explicitHeight: CGFloat?, minWidth: CGFloat?, maxWidth: CGFloat?, minHeight: CGFloat?, maxHeight: CGFloat?, marginLeft: CGFloat, marginRight: CGFloat, marginTop: CGFloat, marginBottom: CGFloat, stretch: Bool, alignment: UInt32)],
219
+ autoRowHeight: CGFloat? = nil,
220
+ alignContent: UInt32 = 0
198
221
  ) -> [(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)]? {
199
- guard let function, !items.isEmpty else { return nil }
222
+ guard !items.isEmpty else { return nil }
223
+ if autoRowHeight != nil, autoRowsFunction == nil { return nil }
224
+ if autoRowHeight == nil, function == nil { return nil }
200
225
  var input = items.map { item in
201
226
  Item(
202
227
  intrinsicWidth: Float(item.intrinsicWidth), intrinsicHeight: Float(item.intrinsicHeight),
@@ -211,7 +236,17 @@ private enum CraftNativeGridBridge {
211
236
  var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: input.count)
212
237
  let status = input.withUnsafeBufferPointer { inputBuffer in
213
238
  output.withUnsafeMutableBufferPointer { outputBuffer in
214
- function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
239
+ if let autoRowHeight, let autoRowsFunction {
240
+ if alignContent != 0, let autoRowsAlignedFunction {
241
+ return autoRowsAlignedFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), alignContent, Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
242
+ }
243
+ return autoRowsFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
244
+ }
245
+ if alignContent != 0, let alignedFunction {
246
+ return alignedFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), alignContent, UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
247
+ }
248
+ guard let function else { return Int32(-1) }
249
+ return function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
215
250
  }
216
251
  }
217
252
  guard status == 0, output.allSatisfy({ frame in
@@ -221,6 +256,17 @@ private enum CraftNativeGridBridge {
221
256
  }
222
257
  }
223
258
 
259
+ private func craftNativeAlignContentCode(_ value: String) -> UInt32 {
260
+ switch value {
261
+ case "center": return 1
262
+ case "flex-end": return 2
263
+ case "space-between": return 3
264
+ case "space-around": return 4
265
+ case "stretch": return 5
266
+ default: return 0
267
+ }
268
+ }
269
+
224
270
  private final class CraftNativeVideoView: UIView {
225
271
  private let playerController = AVPlayerViewController()
226
272
  private var player: AVPlayer?
@@ -242,12 +288,20 @@ private final class CraftNativeVideoView: UIView {
242
288
  host.addChild(playerController)
243
289
  addSubview(playerController.view)
244
290
  playerController.didMove(toParent: host)
291
+ // Expose the host view as the native Video node. The player controller
292
+ // owns an accessibility subtree of its own, which otherwise masks the
293
+ // host's testID and makes the component look like a WebView-backed view
294
+ // to UI tests and assistive technology.
295
+ playerController.view.isAccessibilityElement = false
296
+ playerController.view.accessibilityElementsHidden = true
245
297
  }
246
298
 
247
299
  required init?(coder: NSCoder) {
248
300
  super.init(coder: coder)
249
301
  clipsToBounds = true
250
302
  addSubview(playerController.view)
303
+ playerController.view.isAccessibilityElement = false
304
+ playerController.view.accessibilityElementsHidden = true
251
305
  }
252
306
 
253
307
  override func layoutSubviews() {
@@ -381,7 +435,7 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
381
435
  /// A length as a style gives it: points, or a percentage of the parent's
382
436
  /// content box. A percentage against a size that is not known yet (a column
383
437
  /// whose height comes from its content) resolves to nothing, as on the web.
384
- private enum CraftNativeLength {
438
+ private enum CraftNativeLength: Equatable {
385
439
  case points(CGFloat)
386
440
  case percent(CGFloat)
387
441
 
@@ -414,6 +468,7 @@ private enum CraftNativeLength {
414
468
  private struct CraftNativeLayoutStyle {
415
469
  let width: CraftNativeLength?
416
470
  let height: CraftNativeLength?
471
+ let aspectRatio: CGFloat?
417
472
  let minWidth: CraftNativeLength?
418
473
  let maxWidth: CraftNativeLength?
419
474
  let minHeight: CraftNativeLength?
@@ -435,7 +490,7 @@ private struct CraftNativeLayoutStyle {
435
490
  let marginLeft: CGFloat
436
491
  let display: String
437
492
  let gridColumns: Int
438
- let gridAutoRows: CGFloat?
493
+ let gridAutoRows: CraftNativeLength?
439
494
 
440
495
  static let empty = CraftNativeLayoutStyle([:])
441
496
 
@@ -443,6 +498,8 @@ private struct CraftNativeLayoutStyle {
443
498
  func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
444
499
  width = CraftNativeLength(raw["width"])
445
500
  height = CraftNativeLength(raw["height"])
501
+ let ratio = number(raw["aspectRatio"])
502
+ aspectRatio = ratio.flatMap { $0.isFinite && $0 > 0 ? $0 : nil }
446
503
  minWidth = CraftNativeLength(raw["minWidth"])
447
504
  maxWidth = CraftNativeLength(raw["maxWidth"])
448
505
  minHeight = CraftNativeLength(raw["minHeight"])
@@ -470,7 +527,7 @@ private struct CraftNativeLayoutStyle {
470
527
  marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
471
528
  display = raw["display"] as? String ?? "flex"
472
529
  gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
473
- gridAutoRows = number(raw["gridAutoRows"])
530
+ gridAutoRows = CraftNativeLength(raw["gridAutoRows"])
474
531
  }
475
532
  }
476
533
 
@@ -531,11 +588,15 @@ enum CraftNativeFlexLayout {
531
588
  // Text takes the width it needs up to the room there is, and
532
589
  // wraps or truncates inside it rather than widening its parent.
533
590
  let limit = width ?? maxWidth
534
- let natural = label.sizeThatFits(CGSize(width: limit, height: unbounded))
535
- let resolvedWidth = width ?? min(ceil(natural.width), limit)
536
- let resolvedHeight = height ?? ceil(resolvedWidth < limit
591
+ // Ask for max-content before asking for the wrapped height. A
592
+ // constrained sizeThatFits call can return min-content (the
593
+ // longest word), which collapses shrink-to-fit bubbles and pills.
594
+ let maxContent = label.sizeThatFits(CGSize(width: unbounded, height: unbounded))
595
+ let resolvedWidth = width ?? min(ceil(maxContent.width), limit)
596
+ // Narrower than max-content (it met the limit): the wrapped height.
597
+ let resolvedHeight = height ?? ceil(resolvedWidth < ceil(maxContent.width)
537
598
  ? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
538
- : natural.height)
599
+ : maxContent.height)
539
600
  return CGSize(width: resolvedWidth, height: resolvedHeight)
540
601
  case let image as UIImageView where image.image == nil:
541
602
  return CGSize(width: width ?? 0, height: height ?? 0)
@@ -583,7 +644,7 @@ enum CraftNativeFlexLayout {
583
644
  /// grid. It is a UIStackView in name only, so existing callers and tests keep
584
645
  /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
585
646
  /// constraints are never created, and every frame comes from `layout`.
586
- private final class CraftNativeFlowView: UIStackView {
647
+ private class CraftNativeFlowView: UIStackView {
587
648
  var pressActiveOpacity: CGFloat?
588
649
  var pressBaseOpacity: CGFloat = 1
589
650
  var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
@@ -595,7 +656,7 @@ private final class CraftNativeFlowView: UIStackView {
595
656
  var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
596
657
  var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
597
658
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
598
- var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
659
+ var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
599
660
  var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
600
661
  var padding = UIEdgeInsets.zero {
601
662
  didSet {
@@ -628,6 +689,36 @@ private final class CraftNativeFlowView: UIStackView {
628
689
 
629
690
  required init(coder: NSCoder) { super.init(coder: coder) }
630
691
 
692
+ /// A view made one accessibility element (a row with `accessibilityRole`)
693
+ /// hides its children from VoiceOver, so with no label of its own it reads
694
+ /// what they say, in order, as React Native's accessible View does. A
695
+ /// label given in props always wins.
696
+ override var accessibilityLabel: String? {
697
+ get {
698
+ if let own = super.accessibilityLabel, !own.isEmpty { return own }
699
+ guard isAccessibilityElement else { return nil }
700
+ // An avatar labelled with the name beside it is said once.
701
+ var spoken: [String] = []
702
+ for part in CraftNativeFlowView.spokenText(in: self) where !spoken.contains(part) { spoken.append(part) }
703
+ return spoken.isEmpty ? nil : spoken.joined(separator: ", ")
704
+ }
705
+ set { super.accessibilityLabel = newValue }
706
+ }
707
+
708
+ private static func spokenText(in view: UIView) -> [String] {
709
+ var parts: [String] = []
710
+ for child in view.subviews where !child.isHidden && child.alpha > 0.01 {
711
+ if child.isAccessibilityElement {
712
+ // A Text's label is its text (applyAccessibility leaves accessibilityLabel nil).
713
+ let said = child.accessibilityLabel ?? (child as? UILabel)?.text
714
+ if let label = said?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty { parts.append(label) }
715
+ } else {
716
+ parts.append(contentsOf: spokenText(in: child))
717
+ }
718
+ }
719
+ return parts
720
+ }
721
+
631
722
  private var alignmentValue: UIStackView.Alignment {
632
723
  switch alignItems {
633
724
  case "flex-start": return .leading
@@ -814,12 +905,13 @@ private final class CraftNativeFlowView: UIStackView {
814
905
  let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
815
906
  for child in children where child.craftLayoutStyle.position == "absolute" {
816
907
  let style = child.craftLayoutStyle
817
- let fixedWidth = style.left != nil && style.right != nil
908
+ var fixedWidth = style.left != nil && style.right != nil
818
909
  ? max(0, content.width - (style.left ?? 0) - (style.right ?? 0))
819
910
  : style.width?.resolve(content.width)
820
- let fixedHeight = style.top != nil && style.bottom != nil
911
+ var fixedHeight = style.top != nil && style.bottom != nil
821
912
  ? max(0, content.height - (style.top ?? 0) - (style.bottom ?? 0))
822
913
  : style.height?.resolve(content.height)
914
+ (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
823
915
  let measured = CraftNativeFlexLayout.measure(child, width: fixedWidth, height: fixedHeight, maxWidth: content.width, maxHeight: content.height)
824
916
  let x = style.left ?? (style.right.map { content.width - $0 - measured.width } ?? 0)
825
917
  let y = style.top ?? (style.bottom.map { content.height - $0 - measured.height } ?? 0)
@@ -829,6 +921,21 @@ private final class CraftNativeFlowView: UIStackView {
829
921
  return (size, frames)
830
922
  }
831
923
 
924
+ /// Resolves the missing side of an aspect-ratio box after its parent has
925
+ /// supplied any definite width or height. Keeping this at the host edge
926
+ /// lets grid and absolute children follow the same rule as flex items
927
+ /// without adding platform-specific measurement behavior to native views.
928
+ private func aspectDimensions(
929
+ _ style: CraftNativeLayoutStyle,
930
+ width: CGFloat?,
931
+ height: CGFloat?
932
+ ) -> (width: CGFloat?, height: CGFloat?) {
933
+ guard let ratio = style.aspectRatio else { return (width, height) }
934
+ if let width, height == nil { return (width, width / ratio) }
935
+ if let height, width == nil { return (height * ratio, height) }
936
+ return (width, height)
937
+ }
938
+
832
939
  private func layoutFlex(
833
940
  _ children: [UIView],
834
941
  horizontal: Bool,
@@ -853,15 +960,69 @@ private final class CraftNativeFlowView: UIStackView {
853
960
  return horizontal ? (size.width, size.height) : (size.height, size.width)
854
961
  }
855
962
 
856
- var items: [Item] = children.map { view in
963
+ var items: [Item] = []
964
+
965
+ func fallbackWidths(for line: Range<Int>, available: CGFloat, gap: CGFloat) -> [CGFloat] {
966
+ let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + CGFloat(max(0, line.count - 1)) * gap
967
+ let free = available - hypothetical
968
+ let growTotal = line.reduce(0) { $0 + items[$1].grow }
969
+ let shrinkTotal = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
970
+ let growing = free > 0 && growTotal > 0
971
+ let shrinking = free < 0 && shrinkTotal > 0
972
+ var widths = line.map { items[$0].basis }
973
+ guard growing || shrinking else { return widths }
974
+
975
+ var frozen = Set<Int>()
976
+ for _ in 0...line.count {
977
+ var fixed = CGFloat(max(0, line.count - 1)) * gap
978
+ var activeBasis: CGFloat = 0
979
+ var factor: CGFloat = 0
980
+ for (offset, index) in line.enumerated() {
981
+ let item = items[index]
982
+ fixed += item.mainMargins
983
+ if frozen.contains(offset) {
984
+ fixed += widths[offset]
985
+ } else {
986
+ activeBasis += item.basis
987
+ factor += growing ? item.grow : item.shrink * item.basis
988
+ }
989
+ }
990
+ guard factor > 0 else { break }
991
+ let remaining = available - fixed - activeBasis
992
+ var froze = false
993
+ for (offset, index) in line.enumerated() where !frozen.contains(offset) {
994
+ let item = items[index]
995
+ let share = growing
996
+ ? remaining * item.grow / factor
997
+ : -remaining * item.shrink * item.basis / factor
998
+ let raw = growing ? item.basis + share : item.basis - share
999
+ let clamped = clamp(raw, item.minMain, item.maxMain)
1000
+ widths[offset] = clamped
1001
+ if abs(clamped - raw) > 0.0001 {
1002
+ frozen.insert(offset)
1003
+ froze = true
1004
+ }
1005
+ }
1006
+ if !froze { break }
1007
+ }
1008
+ return widths
1009
+ }
1010
+
1011
+ items = children.map { view in
857
1012
  let style = view.craftLayoutStyle
858
1013
  let mainLeading = horizontal ? style.marginLeft : style.marginTop
859
1014
  let mainTrailing = horizontal ? style.marginRight : style.marginBottom
860
1015
  let crossLeading = horizontal ? style.marginTop : style.marginLeft
861
1016
  let crossTrailing = horizontal ? style.marginBottom : style.marginRight
862
1017
  let align = style.alignSelf ?? alignItems
863
- let explicitMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
864
- let explicitCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
1018
+ let authoredMain = (horizontal ? style.width : style.height)?.resolve(definiteMain)
1019
+ let authoredCross = (horizontal ? style.height : style.width)?.resolve(definiteCross)
1020
+ let explicitMain = authoredMain ?? style.aspectRatio.flatMap { ratio in
1021
+ authoredCross.map { horizontal ? $0 * ratio : $0 / ratio }
1022
+ }
1023
+ let explicitCross = authoredCross ?? style.aspectRatio.flatMap { ratio in
1024
+ authoredMain.map { horizontal ? $0 / ratio : $0 * ratio }
1025
+ }
865
1026
  let minMain = (horizontal ? style.minWidth : style.minHeight)?.resolve(definiteMain)
866
1027
  let maxMain = (horizontal ? style.maxWidth : style.maxHeight)?.resolve(definiteMain)
867
1028
  let minCross = (horizontal ? style.minHeight : style.minWidth)?.resolve(definiteCross)
@@ -971,22 +1132,8 @@ private final class CraftNativeFlowView: UIStackView {
971
1132
  if let sharedWidths {
972
1133
  for (offset, index) in line.enumerated() { items[index].main = sharedWidths[offset].width }
973
1134
  } else {
974
- let free = lineMain - hypothetical
975
- if free > 0 {
976
- let total = line.reduce(0) { $0 + items[$1].grow }
977
- for index in line {
978
- let share = total > 0 ? free * items[index].grow / total : 0
979
- items[index].main = clamp(items[index].basis + share, items[index].minMain, items[index].maxMain)
980
- }
981
- } else if free < 0 {
982
- let total = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
983
- for index in line {
984
- let share = total > 0 ? -free * items[index].shrink * items[index].basis / total : 0
985
- items[index].main = clamp(items[index].basis - share, items[index].minMain, items[index].maxMain)
986
- }
987
- } else {
988
- for index in line { items[index].main = items[index].basis }
989
- }
1135
+ let fallback = fallbackWidths(for: line, available: lineMain, gap: mainGap)
1136
+ for (offset, index) in line.enumerated() { items[index].main = fallback[offset] }
990
1137
  }
991
1138
 
992
1139
  var lineCross: CGFloat = 0
@@ -1152,21 +1299,26 @@ private final class CraftNativeFlowView: UIStackView {
1152
1299
  let style = child.craftLayoutStyle
1153
1300
  let available = max(0, cellWidth - style.marginLeft - style.marginRight)
1154
1301
  let align = style.alignSelf ?? alignItems
1155
- let fixedWidth = style.width?.resolve(cellWidth) ?? (align == "stretch" ? available : nil)
1302
+ var fixedWidth = style.width?.resolve(cellWidth)
1303
+ var fixedHeight = style.height?.resolve(nil)
1304
+ if fixedWidth == nil && fixedHeight == nil && align == "stretch" { fixedWidth = available }
1305
+ (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
1156
1306
  let size = CraftNativeFlexLayout.measure(
1157
1307
  child,
1158
1308
  width: fixedWidth.map { min($0, available) },
1159
- height: style.height?.resolve(nil),
1309
+ height: fixedHeight,
1160
1310
  maxWidth: available,
1161
1311
  maxHeight: CraftNativeFlexLayout.unbounded
1162
1312
  )
1163
1313
  sizes.append(size)
1164
1314
  let row = index / columns
1165
- while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
1315
+ while rowHeights.count <= row { rowHeights.append(gridAutoRows?.resolve(innerHeight) ?? 0) }
1166
1316
  rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
1167
1317
  }
1168
1318
  let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
1169
1319
  let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
1320
+ // Older runtimes return nil for the optional symbol; the local layout
1321
+ // below remains the compatibility path in that case.
1170
1322
  let sharedFrames = CraftNativeGridBridge.frames(
1171
1323
  availableWidth: innerWidth ?? width,
1172
1324
  availableHeight: innerHeight ?? height,
@@ -1178,15 +1330,23 @@ private final class CraftNativeFlowView: UIStackView {
1178
1330
  let align = style.alignSelf ?? alignItems
1179
1331
  let size = sizes[index]
1180
1332
  let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1333
+ var explicitWidth = style.width?.resolve(cellWidth)
1334
+ var explicitHeight = style.height?.resolve(nil)
1335
+ if explicitWidth == nil && explicitHeight == nil && align == "stretch" {
1336
+ explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1337
+ }
1338
+ (explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
1181
1339
  return (
1182
1340
  size.width, size.height,
1183
- style.width?.resolve(cellWidth), style.height?.resolve(nil),
1341
+ explicitWidth, explicitHeight,
1184
1342
  style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
1185
1343
  style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
1186
1344
  style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
1187
1345
  align == "stretch", alignment
1188
1346
  )
1189
- }
1347
+ },
1348
+ autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 },
1349
+ alignContent: craftNativeAlignContentCode(alignContent)
1190
1350
  )
1191
1351
  if place {
1192
1352
  if let sharedFrames {
@@ -1196,30 +1356,136 @@ private final class CraftNativeFlowView: UIStackView {
1196
1356
  }
1197
1357
  return CGSize(width: width, height: innerHeight ?? height)
1198
1358
  }
1199
- var rowTop = padding.top
1359
+ let gridHeight = innerHeight ?? height
1360
+ let gridGaps = CGFloat(max(0, rowHeights.count - 1)) * verticalGap
1361
+ let gridFree = max(0, gridHeight - rowHeights.reduce(0, +) - gridGaps)
1362
+ var rowLeading: CGFloat = 0
1363
+ var rowBetween = verticalGap
1364
+ var rowExtra: CGFloat = 0
1365
+ switch alignContent {
1366
+ case "center": rowLeading = gridFree / 2
1367
+ case "flex-end": rowLeading = gridFree
1368
+ case "space-between":
1369
+ if rowHeights.count > 1 { rowBetween += gridFree / CGFloat(rowHeights.count - 1) }
1370
+ case "space-around":
1371
+ rowLeading = gridFree / CGFloat(rowHeights.count * 2)
1372
+ rowBetween += gridFree / CGFloat(rowHeights.count)
1373
+ case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
1374
+ default: break
1375
+ }
1376
+ var rowTop = padding.top + rowLeading
1200
1377
  for (index, child) in children.enumerated() {
1201
1378
  let style = child.craftLayoutStyle
1202
1379
  let row = index / columns
1203
1380
  let column = index % columns
1204
- let rowHeight = rowHeights[row]
1381
+ let rowHeight = rowHeights[row] + rowExtra
1205
1382
  let size = sizes[index]
1206
1383
  let align = style.alignSelf ?? alignItems
1207
1384
  let availableWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1208
1385
  let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
1209
1386
  let childWidth = min(availableWidth, size.width)
1210
- let childHeight = align == "stretch" && style.height == nil ? availableHeight : min(availableHeight, size.height)
1387
+ let childHeight = align == "stretch" && style.height == nil && style.aspectRatio == nil ? availableHeight : min(availableHeight, size.height)
1211
1388
  let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
1212
1389
  + (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
1213
1390
  let y = rowTop + style.marginTop
1214
1391
  + (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
1215
1392
  frames.append((child, CGRect(x: x, y: y, width: childWidth, height: childHeight)))
1216
- if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + verticalGap }
1393
+ if column == columns - 1 || index == children.count - 1 { rowTop += rowHeight + rowBetween }
1217
1394
  }
1218
1395
  }
1219
1396
  return CGSize(width: width, height: innerHeight ?? height)
1220
1397
  }
1221
1398
  }
1222
1399
 
1400
+ /// A flex container that responds to the controller's keyboard overlap.
1401
+ /// `padding` keeps the content visible without moving the field; `position`
1402
+ /// lifts the whole container; `height` reduces its viewport.
1403
+ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
1404
+ private var authoredPadding = UIEdgeInsets.zero
1405
+ private var keyboardInset: CGFloat = 0
1406
+ private var keyboardVerticalOffset: CGFloat = 0
1407
+ private var keyboardBehavior = "padding"
1408
+ private var appliedHeightInset: CGFloat = 0
1409
+ private var adjustingHeight = false
1410
+
1411
+ func configureKeyboard(props: [String: Any]) {
1412
+ let requestedBehavior = (props["behavior"] as? String) ?? "padding"
1413
+ keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
1414
+ keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
1415
+ if keyboardBehavior != "height" { appliedHeightInset = 0 }
1416
+ authoredPadding = padding
1417
+ applyKeyboardAvoidance()
1418
+ }
1419
+
1420
+ func setKeyboardInset(_ inset: CGFloat) {
1421
+ guard abs(keyboardInset - inset) > 0.5 else { return }
1422
+ keyboardInset = max(0, inset)
1423
+ applyKeyboardAvoidance()
1424
+ }
1425
+
1426
+ private var effectiveInset: CGFloat { max(0, keyboardInset - keyboardVerticalOffset) }
1427
+
1428
+ private func applyKeyboardAvoidance() {
1429
+ let inset = effectiveInset
1430
+ switch keyboardBehavior {
1431
+ case "position":
1432
+ transform = CGAffineTransform(translationX: 0, y: -inset)
1433
+ padding = authoredPadding
1434
+ case "height":
1435
+ transform = .identity
1436
+ padding = authoredPadding
1437
+ default:
1438
+ transform = .identity
1439
+ padding = UIEdgeInsets(
1440
+ top: authoredPadding.top,
1441
+ left: authoredPadding.left,
1442
+ bottom: authoredPadding.bottom + inset,
1443
+ right: authoredPadding.right
1444
+ )
1445
+ }
1446
+ invalidateMeasurements()
1447
+ }
1448
+
1449
+ override func layoutSubviews() {
1450
+ if keyboardBehavior == "height", !adjustingHeight {
1451
+ let inset = effectiveInset
1452
+ let target = max(0, bounds.height + appliedHeightInset - inset)
1453
+ if abs(target - bounds.height) > 0.5 {
1454
+ adjustingHeight = true
1455
+ appliedHeightInset = inset
1456
+ bounds.size.height = target
1457
+ super.layoutSubviews()
1458
+ adjustingHeight = false
1459
+ return
1460
+ }
1461
+ }
1462
+ super.layoutSubviews()
1463
+ }
1464
+ }
1465
+
1466
+ /// An image-backed flex container. The image stays behind ordinary native
1467
+ /// children so `ImageBackground` keeps the same layout and interaction
1468
+ /// semantics as a `View` while using the shared image loader.
1469
+ private final class CraftNativeImageBackgroundView: UIImageView {
1470
+ let contentStack = CraftNativeFlowView()
1471
+
1472
+ override init(frame: CGRect) {
1473
+ super.init(frame: frame)
1474
+ isUserInteractionEnabled = true
1475
+ contentMode = .scaleAspectFill
1476
+ clipsToBounds = true
1477
+ contentStack.backgroundColor = .clear
1478
+ addSubview(contentStack)
1479
+ }
1480
+
1481
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
1482
+
1483
+ override func layoutSubviews() {
1484
+ super.layoutSubviews()
1485
+ contentStack.frame = bounds
1486
+ }
1487
+ }
1488
+
1223
1489
  private final class CraftNativeModalView: UIView {
1224
1490
  let contentStack = CraftNativeFlowView()
1225
1491
  private let blocker = UIView()
@@ -1876,6 +2142,13 @@ private final class CraftNativeButton: UIButton {
1876
2142
  }
1877
2143
  }
1878
2144
 
2145
+ /// A native checkbox button. UIKit has no checkbox control, so use the system
2146
+ /// square/checkmark symbols while keeping the control's title and accessibility
2147
+ /// state native and keyboard/touch reachable.
2148
+ private final class CraftNativeCheckboxButton: UIButton {
2149
+ var indeterminate = false
2150
+ }
2151
+
1879
2152
  private final class CraftNativeLabel: UILabel {
1880
2153
  var insets = UIEdgeInsets.zero {
1881
2154
  didSet {
@@ -2052,6 +2325,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2052
2325
  private var imageSources: [ObjectIdentifier: String] = [:]
2053
2326
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
2054
2327
  private var imageErrors: [ObjectIdentifier: String] = [:]
2328
+ private var checkboxIndeterminate: [ObjectIdentifier: Bool] = [:]
2055
2329
  private var tintedImages = Set<ObjectIdentifier>()
2056
2330
  private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
2057
2331
  private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
@@ -2967,12 +3241,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2967
3241
  }
2968
3242
  let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
2969
3243
  let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
2970
- let changed = abs(inset - keyboardSafeAreaInset) > 0.5
3244
+ let globalInset = containsKeyboardAvoidingView() ? 0 : inset
3245
+ let safeAreaChanged = abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5
2971
3246
  keyboardSafeAreaInset = inset
2972
3247
  keyboardScreenFrame = keyboardFrame
3248
+ updateKeyboardAvoidingViews()
2973
3249
  UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
2974
- if changed {
2975
- self.additionalSafeAreaInsets.bottom = inset
3250
+ if safeAreaChanged {
3251
+ self.additionalSafeAreaInsets.bottom = globalInset
2976
3252
  self.view.layoutIfNeeded()
2977
3253
  }
2978
3254
  self.insetScrollViewsForKeyboard()
@@ -2980,6 +3256,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2980
3256
  }
2981
3257
  }
2982
3258
 
3259
+ private func updateKeyboardAvoidingViews() {
3260
+ func visit(_ node: RenderedNode?) {
3261
+ guard let node else { return }
3262
+ if let keyboard = node.view as? CraftNativeKeyboardAvoidingView {
3263
+ keyboard.setKeyboardInset(keyboardSafeAreaInset)
3264
+ }
3265
+ node.children.forEach(visit)
3266
+ }
3267
+ visit(renderedRoot)
3268
+ }
3269
+
3270
+ private func containsKeyboardAvoidingView() -> Bool {
3271
+ func visit(_ node: RenderedNode?) -> Bool {
3272
+ guard let node else { return false }
3273
+ if node.view is CraftNativeKeyboardAvoidingView { return true }
3274
+ return node.children.contains(where: visit)
3275
+ }
3276
+ return visit(renderedRoot)
3277
+ }
3278
+
3279
+ private func syncGlobalKeyboardSafeArea() {
3280
+ let globalInset = containsKeyboardAvoidingView() ? 0 : keyboardSafeAreaInset
3281
+ guard abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5 else { return }
3282
+ additionalSafeAreaInsets.bottom = globalInset
3283
+ view.setNeedsLayout()
3284
+ }
3285
+
2983
3286
  /// A scroll view the keyboard still covers part of (one that does not end
2984
3287
  /// at the screen's bottom edge, or a keyboard taller than the safe area
2985
3288
  /// took account of) insets its content by that much, so everything in it
@@ -3318,6 +3621,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3318
3621
  rootStack.setLayoutStyle(rootStyle, for: next.view)
3319
3622
  rootStack.invalidateMeasurements()
3320
3623
  renderedRoot = next
3624
+ updateKeyboardAvoidingViews()
3625
+ syncGlobalKeyboardSafeArea()
3321
3626
  trackContentScrollView()
3322
3627
  restoreFocus(focused)
3323
3628
  }
@@ -3325,6 +3630,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3325
3630
  private func clearRenderedTree() {
3326
3631
  if let root = renderedRoot { detach(root, from: rootStack) }
3327
3632
  renderedRoot = nil
3633
+ syncGlobalKeyboardSafeArea()
3328
3634
  }
3329
3635
 
3330
3636
  private func firstResponder(in view: UIView) -> UIView? {
@@ -3341,7 +3647,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3341
3647
  }
3342
3648
 
3343
3649
  private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
3344
- let type = node["type"] as? String ?? "View"
3650
+ let type = canonicalType(node["type"] as? String ?? "View")
3345
3651
  let props = node["props"] as? [String: Any] ?? [:]
3346
3652
  let style = node["style"] as? [String: Any] ?? [:]
3347
3653
  let events = node["events"] as? [String: String] ?? [:]
@@ -3359,7 +3665,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3359
3665
  current.protocolId = node["id"] as? String
3360
3666
  current.style = style
3361
3667
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
3362
- if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
3668
+ if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
3363
3669
  result.setContentHuggingPriority(.required, for: .vertical)
3364
3670
  }
3365
3671
  applyViewStyle(style, to: result, node: current)
@@ -3391,6 +3697,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3391
3697
  (button as? CraftNativeButton)?.padding = paddingInsets(style)
3392
3698
  button.isEnabled = props["disabled"] as? Bool != true
3393
3699
  updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
3700
+ case "Checkbox":
3701
+ let checkbox = result as! CraftNativeCheckboxButton
3702
+ let label = props["label"] as? String ?? children.compactMap { $0 as? String }.joined()
3703
+ let transformedLabel = transformedText(label, style: style)
3704
+ let checked = (props["checked"] as? Bool) ?? (props["value"] as? Bool) ?? false
3705
+ let indeterminate = props["indeterminate"] as? Bool == true
3706
+ checkbox.isSelected = checked
3707
+ checkbox.indeterminate = indeterminate
3708
+ checkboxIndeterminate[ObjectIdentifier(checkbox)] = indeterminate
3709
+ checkbox.setTitle(transformedLabel, for: .normal)
3710
+ checkbox.setAttributedTitle(NSAttributedString(
3711
+ string: transformedLabel,
3712
+ attributes: buttonTitleAttributes(style, color: color(props["color"]) ?? color(style["color"]) ?? .label, font: textFont(style, default: .systemFont(ofSize: 16)), forceUnderline: false)
3713
+ ), for: .normal)
3714
+ let uncheckedImage = UIImage(systemName: indeterminate ? "minus.square" : "square")
3715
+ checkbox.setImage(uncheckedImage, for: .normal)
3716
+ checkbox.setImage(
3717
+ UIImage(systemName: indeterminate ? "minus.square" : "checkmark.square.fill"),
3718
+ for: .selected
3719
+ )
3720
+ checkbox.tintColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
3721
+ checkbox.isEnabled = props["disabled"] as? Bool != true
3722
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: checkbox)
3394
3723
  case "TextInput":
3395
3724
  if let textView = result as? UITextView {
3396
3725
  inputIdentities[ObjectIdentifier(textView)] = current.identity
@@ -3490,7 +3819,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3490
3819
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
3491
3820
  picker.minimumDate = Self.pickerDate(props["minimumDate"])
3492
3821
  picker.maximumDate = Self.pickerDate(props["maximumDate"])
3493
- if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
3822
+ if mode == .countDownTimer {
3823
+ if let duration = Self.countdownDuration(props["value"]), abs(picker.countDownDuration - duration) >= 1 {
3824
+ picker.countDownDuration = duration
3825
+ }
3826
+ } else if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
3494
3827
  picker.setDate(date, animated: false)
3495
3828
  }
3496
3829
  picker.tintColor = color(props["color"]) ?? color(style["color"])
@@ -3611,6 +3944,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3611
3944
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
3612
3945
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
3613
3946
  updateImage(image, source: props["source"])
3947
+ case "ImageBackground":
3948
+ let background = result as! CraftNativeImageBackgroundView
3949
+ let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
3950
+ background.contentMode = imageContentMode(requestedResizeMode)
3951
+ background.clipsToBounds = background.contentMode == .scaleAspectFill
3952
+ updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
3953
+ updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
3954
+ updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
3955
+ updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
3956
+ updateImage(background, source: props["source"])
3957
+ configureStack(background.contentStack, style: style)
3958
+ reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
3959
+ case "KeyboardAvoidingView":
3960
+ let keyboard = result as! CraftNativeKeyboardAvoidingView
3961
+ configureStack(keyboard, style: style)
3962
+ keyboard.configureKeyboard(props: props)
3963
+ reconcileChildren(children, in: keyboard, parent: current, path: path, style: style)
3614
3964
  case "Video":
3615
3965
  let video = result as! CraftNativeVideoView
3616
3966
  let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
@@ -3627,7 +3977,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3627
3977
  controls: (props["controls"] as? Bool) == true,
3628
3978
  resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
3629
3979
  )
3630
- case "ScrollView":
3980
+ case "ScrollView", "SectionList":
3631
3981
  let scroll = result as! CraftNativeScrollView
3632
3982
  let flexDirection = style["flexDirection"] as? String
3633
3983
  let direction = (props["horizontal"] as? Bool) == true
@@ -3677,7 +4027,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3677
4027
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
3678
4028
  }
3679
4029
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
3680
- if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
4030
+ if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
3681
4031
  let disabled = props["disabled"] as? Bool == true
3682
4032
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
3683
4033
  updatePressFeedback(
@@ -3701,6 +4051,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3701
4051
  let button = CraftNativeButton(type: .system)
3702
4052
  button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
3703
4053
  return button
4054
+ case "Checkbox":
4055
+ let checkbox = CraftNativeCheckboxButton(type: .system)
4056
+ checkbox.semanticContentAttribute = .forceLeftToRight
4057
+ checkbox.contentHorizontalAlignment = .leading
4058
+ checkbox.addTarget(self, action: #selector(checkboxPressed(_:)), for: .touchUpInside)
4059
+ return checkbox
3704
4060
  case "TextInput":
3705
4061
  if props["multiline"] as? Bool == true {
3706
4062
  let textView = CraftNativeTextView()
@@ -3738,13 +4094,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3738
4094
  return UIActivityIndicatorView(style: .medium)
3739
4095
  case "Image":
3740
4096
  return UIImageView()
4097
+ case "ImageBackground":
4098
+ return CraftNativeImageBackgroundView(frame: .zero)
4099
+ case "KeyboardAvoidingView":
4100
+ return CraftNativeKeyboardAvoidingView()
3741
4101
  case "Video":
3742
4102
  return CraftNativeVideoView(host: self)
3743
4103
  case "Icon":
3744
4104
  let icon = CraftNativeIconView()
3745
4105
  icon.contentMode = .scaleAspectFit
3746
4106
  return icon
3747
- case "ScrollView":
4107
+ case "ScrollView", "SectionList":
3748
4108
  return CraftNativeScrollView()
3749
4109
  case "FlatList":
3750
4110
  return CraftNativeFlatList()
@@ -3759,12 +4119,48 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3759
4119
  }
3760
4120
  }
3761
4121
 
4122
+ private func canonicalType(_ raw: String) -> String {
4123
+ switch raw.lowercased() {
4124
+ case "view": return "View"
4125
+ case "safeareaview": return "SafeAreaView"
4126
+ case "keyboardavoidingview": return "KeyboardAvoidingView"
4127
+ case "text": return "Text"
4128
+ case "button": return "Button"
4129
+ case "link": return "Link"
4130
+ case "checkbox": return "Checkbox"
4131
+ case "textinput", "input": return "TextInput"
4132
+ case "picker", "select": return "Picker"
4133
+ case "modal": return "Modal"
4134
+ case "switch": return "Switch"
4135
+ case "slider": return "Slider"
4136
+ case "activityindicator": return "ActivityIndicator"
4137
+ case "image": return "Image"
4138
+ case "imagebackground": return "ImageBackground"
4139
+ case "video": return "Video"
4140
+ case "icon": return "Icon"
4141
+ case "scrollview": return "ScrollView"
4142
+ case "sectionlist": return "SectionList"
4143
+ case "flatlist": return "FlatList"
4144
+ case "svg": return "Svg"
4145
+ case "touchableopacity": return "TouchableOpacity"
4146
+ case "touchablehighlight": return "TouchableHighlight"
4147
+ case "pressable": return "Pressable"
4148
+ default: return raw
4149
+ }
4150
+ }
4151
+
3762
4152
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
3763
4153
  // The current @stacksjs/stx native compiler emits key in props; accept the IR
3764
4154
  // field too so keyed children keep working when the compiler adopts it.
3765
- [node["key"], props["key"], props["testID"], node["id"]]
4155
+ let direct = [node["key"], props["key"], props["testID"]]
3766
4156
  .compactMap { $0 as? String }
3767
4157
  .first { !$0.isEmpty }
4158
+ if let direct { return direct }
4159
+ for child in node["children"] as? [[String: Any]] ?? [] {
4160
+ let childProps = child["props"] as? [String: Any] ?? [:]
4161
+ if let nested = explicitKey(child, props: childProps) { return nested }
4162
+ }
4163
+ return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
3768
4164
  }
3769
4165
 
3770
4166
  private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
@@ -3983,6 +4379,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3983
4379
  imageLoadHandlers.removeValue(forKey: id)
3984
4380
  imageLoadEndHandlers.removeValue(forKey: id)
3985
4381
  imageErrorHandlers.removeValue(forKey: id)
4382
+ checkboxIndeterminate.removeValue(forKey: id)
3986
4383
  if let video = node.view as? CraftNativeVideoView { video.stop() }
3987
4384
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
3988
4385
  }
@@ -4248,7 +4645,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4248
4645
  stack.columnGap = number(style["columnGap"]) ?? gap
4249
4646
  stack.grid = style["display"] as? String == "grid"
4250
4647
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
4251
- stack.gridAutoRows = number(style["gridAutoRows"])
4648
+ stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
4252
4649
  stack.padding = paddingInsets(style)
4253
4650
  stack.invalidateMeasurements()
4254
4651
  }
@@ -4572,7 +4969,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4572
4969
 
4573
4970
  private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
4574
4971
  view.accessibilityLabel = (props["accessibilityLabel"] as? String)
4575
- ?? (type == "Image" ? props["alt"] as? String : nil)
4972
+ ?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
4576
4973
  view.accessibilityHint = props["accessibilityHint"] as? String
4577
4974
  if let value = props["accessibilityValue"] {
4578
4975
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
@@ -4580,10 +4977,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4580
4977
  view.accessibilityValue = nil
4581
4978
  }
4582
4979
  let role = props["accessibilityRole"] as? String
4583
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
4980
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
4584
4981
  var traits: UIAccessibilityTraits = []
4585
4982
  switch role ?? type.lowercased() {
4586
- case "button": traits.insert(.button)
4983
+ case "button", "checkbox": traits.insert(.button)
4587
4984
  case "image": traits.insert(.image)
4588
4985
  case "header": traits.insert(.header)
4589
4986
  case "link": traits.insert(.link)
@@ -4603,7 +5000,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4603
5000
  if state["checked"] as? Bool == true { traits.insert(.selected) }
4604
5001
  }
4605
5002
  if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
4606
- if type == "Image", let message = imageErrors[ObjectIdentifier(view)] {
5003
+ if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
5004
+ if checkbox.isSelected { traits.insert(.selected) }
5005
+ view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
5006
+ }
5007
+ if (type == "Image" || type == "ImageBackground"), let message = imageErrors[ObjectIdentifier(view)] {
4607
5008
  let current = view.accessibilityValue
4608
5009
  if current?.contains(message) != true {
4609
5010
  view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
@@ -4638,6 +5039,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4638
5039
  }
4639
5040
  }
4640
5041
 
5042
+ @objc private func checkboxPressed(_ sender: CraftNativeCheckboxButton) {
5043
+ sender.indeterminate = false
5044
+ checkboxIndeterminate[ObjectIdentifier(sender)] = false
5045
+ sender.isSelected.toggle()
5046
+ if let handler = handlers[ObjectIdentifier(sender)] {
5047
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isSelected]])
5048
+ }
5049
+ applyAccessibility([:], type: "Checkbox", to: sender)
5050
+ }
5051
+
4641
5052
  /// Return on a `returnKeyType="next"` field the screen does not handle
4642
5053
  /// itself (no `@submit`) goes to the next field down the screen, keeping
4643
5054
  /// the keyboard up. Every other Return ends editing and fires `@submit`.
@@ -4737,7 +5148,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4737
5148
  /// milliseconds for code that wants those.
4738
5149
  @objc private func datePicked(_ sender: UIDatePicker) {
4739
5150
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
4740
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender.date, mode: sender.datePickerMode)])
5151
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender)])
5152
+ }
5153
+
5154
+ static func datePickerEvent(_ picker: UIDatePicker) -> [String: Any] {
5155
+ if picker.datePickerMode == .countDownTimer { return countdownEvent(picker.countDownDuration) }
5156
+ return datePickerEvent(picker.date, mode: picker.datePickerMode)
5157
+ }
5158
+
5159
+ static func countdownEvent(_ duration: TimeInterval) -> [String: Any] {
5160
+ let totalSeconds = max(0, Int(duration.rounded()))
5161
+ let hours = totalSeconds / 3600
5162
+ let minutes = (totalSeconds % 3600) / 60
5163
+ let seconds = totalSeconds % 60
5164
+ return [
5165
+ "value": String(format: "%02d:%02d:%02d", hours, minutes, seconds),
5166
+ "iso": "PT\(totalSeconds)S",
5167
+ "timestamp": Double(totalSeconds * 1000),
5168
+ ]
4741
5169
  }
4742
5170
 
4743
5171
  static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
@@ -4764,6 +5192,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4764
5192
  }
4765
5193
  }
4766
5194
 
5195
+ /// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
5196
+ static func countdownDuration(_ value: Any?) -> TimeInterval? {
5197
+ let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
5198
+ if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
5199
+ guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
5200
+ if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
5201
+ let parts = text.split(separator: ":").compactMap { Double($0) }
5202
+ guard parts.count == 2 || parts.count == 3 else { return nil }
5203
+ let hours = parts[0]
5204
+ let minutes = parts[1]
5205
+ let seconds = parts.count == 3 ? parts[2] : 0
5206
+ guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
5207
+ return min(maximum, hours * 3600 + minutes * 60 + seconds)
5208
+ }
5209
+
4767
5210
  /// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
4768
5211
  /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
4769
5212
  /// milliseconds since 1970. Nil for anything else.