craft-native 0.0.156 → 0.0.160

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,18 @@ 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
185
188
 
186
189
  private static let function: Function? = {
187
190
  guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid") else { return nil }
188
191
  return unsafeBitCast(symbol, to: Function.self)
189
192
  }()
193
+ private static let autoRowsFunction: AutoRowsFunction? = {
194
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_grid_auto_rows") else { return nil }
195
+ return unsafeBitCast(symbol, to: AutoRowsFunction.self)
196
+ }()
190
197
 
191
198
  static func frames(
192
199
  availableWidth: CGFloat,
@@ -194,9 +201,12 @@ private enum CraftNativeGridBridge {
194
201
  columns: Int,
195
202
  columnGap: CGFloat,
196
203
  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)]
204
+ 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)],
205
+ autoRowHeight: CGFloat? = nil
198
206
  ) -> [(x: CGFloat, y: CGFloat, width: CGFloat, height: CGFloat)]? {
199
- guard let function, !items.isEmpty else { return nil }
207
+ guard !items.isEmpty else { return nil }
208
+ if autoRowHeight != nil, autoRowsFunction == nil { return nil }
209
+ if autoRowHeight == nil, function == nil { return nil }
200
210
  var input = items.map { item in
201
211
  Item(
202
212
  intrinsicWidth: Float(item.intrinsicWidth), intrinsicHeight: Float(item.intrinsicHeight),
@@ -211,7 +221,11 @@ private enum CraftNativeGridBridge {
211
221
  var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: input.count)
212
222
  let status = input.withUnsafeBufferPointer { inputBuffer in
213
223
  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!))
224
+ if let autoRowHeight, let autoRowsFunction {
225
+ return autoRowsFunction(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), Float(autoRowHeight), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
226
+ }
227
+ guard let function else { return Int32(-1) }
228
+ return function(Float(availableWidth), Float(availableHeight), UInt32(max(1, columns)), Float(columnGap), Float(rowGap), UnsafeRawPointer(inputBuffer.baseAddress!), input.count, UnsafeMutableRawPointer(outputBuffer.baseAddress!))
215
229
  }
216
230
  }
217
231
  guard status == 0, output.allSatisfy({ frame in
@@ -242,12 +256,20 @@ private final class CraftNativeVideoView: UIView {
242
256
  host.addChild(playerController)
243
257
  addSubview(playerController.view)
244
258
  playerController.didMove(toParent: host)
259
+ // Expose the host view as the native Video node. The player controller
260
+ // owns an accessibility subtree of its own, which otherwise masks the
261
+ // host's testID and makes the component look like a WebView-backed view
262
+ // to UI tests and assistive technology.
263
+ playerController.view.isAccessibilityElement = false
264
+ playerController.view.accessibilityElementsHidden = true
245
265
  }
246
266
 
247
267
  required init?(coder: NSCoder) {
248
268
  super.init(coder: coder)
249
269
  clipsToBounds = true
250
270
  addSubview(playerController.view)
271
+ playerController.view.isAccessibilityElement = false
272
+ playerController.view.accessibilityElementsHidden = true
251
273
  }
252
274
 
253
275
  override func layoutSubviews() {
@@ -381,7 +403,7 @@ struct CraftNativeScreen: UIViewControllerRepresentable {
381
403
  /// A length as a style gives it: points, or a percentage of the parent's
382
404
  /// content box. A percentage against a size that is not known yet (a column
383
405
  /// whose height comes from its content) resolves to nothing, as on the web.
384
- private enum CraftNativeLength {
406
+ private enum CraftNativeLength: Equatable {
385
407
  case points(CGFloat)
386
408
  case percent(CGFloat)
387
409
 
@@ -435,7 +457,7 @@ private struct CraftNativeLayoutStyle {
435
457
  let marginLeft: CGFloat
436
458
  let display: String
437
459
  let gridColumns: Int
438
- let gridAutoRows: CGFloat?
460
+ let gridAutoRows: CraftNativeLength?
439
461
 
440
462
  static let empty = CraftNativeLayoutStyle([:])
441
463
 
@@ -470,7 +492,7 @@ private struct CraftNativeLayoutStyle {
470
492
  marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
471
493
  display = raw["display"] as? String ?? "flex"
472
494
  gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
473
- gridAutoRows = number(raw["gridAutoRows"])
495
+ gridAutoRows = CraftNativeLength(raw["gridAutoRows"])
474
496
  }
475
497
  }
476
498
 
@@ -531,11 +553,15 @@ enum CraftNativeFlexLayout {
531
553
  // Text takes the width it needs up to the room there is, and
532
554
  // wraps or truncates inside it rather than widening its parent.
533
555
  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
556
+ // Ask for max-content before asking for the wrapped height. A
557
+ // constrained sizeThatFits call can return min-content (the
558
+ // longest word), which collapses shrink-to-fit bubbles and pills.
559
+ let maxContent = label.sizeThatFits(CGSize(width: unbounded, height: unbounded))
560
+ let resolvedWidth = width ?? min(ceil(maxContent.width), limit)
561
+ // Narrower than max-content (it met the limit): the wrapped height.
562
+ let resolvedHeight = height ?? ceil(resolvedWidth < ceil(maxContent.width)
537
563
  ? label.sizeThatFits(CGSize(width: resolvedWidth, height: unbounded)).height
538
- : natural.height)
564
+ : maxContent.height)
539
565
  return CGSize(width: resolvedWidth, height: resolvedHeight)
540
566
  case let image as UIImageView where image.image == nil:
541
567
  return CGSize(width: width ?? 0, height: height ?? 0)
@@ -583,7 +609,7 @@ enum CraftNativeFlexLayout {
583
609
  /// grid. It is a UIStackView in name only, so existing callers and tests keep
584
610
  /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
585
611
  /// constraints are never created, and every frame comes from `layout`.
586
- private final class CraftNativeFlowView: UIStackView {
612
+ private class CraftNativeFlowView: UIStackView {
587
613
  var pressActiveOpacity: CGFloat?
588
614
  var pressBaseOpacity: CGFloat = 1
589
615
  var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
@@ -595,7 +621,7 @@ private final class CraftNativeFlowView: UIStackView {
595
621
  var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
596
622
  var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
597
623
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
598
- var gridAutoRows: CGFloat? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
624
+ var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
599
625
  var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
600
626
  var padding = UIEdgeInsets.zero {
601
627
  didSet {
@@ -628,6 +654,36 @@ private final class CraftNativeFlowView: UIStackView {
628
654
 
629
655
  required init(coder: NSCoder) { super.init(coder: coder) }
630
656
 
657
+ /// A view made one accessibility element (a row with `accessibilityRole`)
658
+ /// hides its children from VoiceOver, so with no label of its own it reads
659
+ /// what they say, in order, as React Native's accessible View does. A
660
+ /// label given in props always wins.
661
+ override var accessibilityLabel: String? {
662
+ get {
663
+ if let own = super.accessibilityLabel, !own.isEmpty { return own }
664
+ guard isAccessibilityElement else { return nil }
665
+ // An avatar labelled with the name beside it is said once.
666
+ var spoken: [String] = []
667
+ for part in CraftNativeFlowView.spokenText(in: self) where !spoken.contains(part) { spoken.append(part) }
668
+ return spoken.isEmpty ? nil : spoken.joined(separator: ", ")
669
+ }
670
+ set { super.accessibilityLabel = newValue }
671
+ }
672
+
673
+ private static func spokenText(in view: UIView) -> [String] {
674
+ var parts: [String] = []
675
+ for child in view.subviews where !child.isHidden && child.alpha > 0.01 {
676
+ if child.isAccessibilityElement {
677
+ // A Text's label is its text (applyAccessibility leaves accessibilityLabel nil).
678
+ let said = child.accessibilityLabel ?? (child as? UILabel)?.text
679
+ if let label = said?.trimmingCharacters(in: .whitespacesAndNewlines), !label.isEmpty { parts.append(label) }
680
+ } else {
681
+ parts.append(contentsOf: spokenText(in: child))
682
+ }
683
+ }
684
+ return parts
685
+ }
686
+
631
687
  private var alignmentValue: UIStackView.Alignment {
632
688
  switch alignItems {
633
689
  case "flex-start": return .leading
@@ -853,7 +909,55 @@ private final class CraftNativeFlowView: UIStackView {
853
909
  return horizontal ? (size.width, size.height) : (size.height, size.width)
854
910
  }
855
911
 
856
- var items: [Item] = children.map { view in
912
+ var items: [Item] = []
913
+
914
+ func fallbackWidths(for line: Range<Int>, available: CGFloat, gap: CGFloat) -> [CGFloat] {
915
+ let hypothetical = line.reduce(0) { $0 + items[$1].basis + items[$1].mainMargins } + CGFloat(max(0, line.count - 1)) * gap
916
+ let free = available - hypothetical
917
+ let growTotal = line.reduce(0) { $0 + items[$1].grow }
918
+ let shrinkTotal = line.reduce(0) { $0 + items[$1].shrink * items[$1].basis }
919
+ let growing = free > 0 && growTotal > 0
920
+ let shrinking = free < 0 && shrinkTotal > 0
921
+ var widths = line.map { items[$0].basis }
922
+ guard growing || shrinking else { return widths }
923
+
924
+ var frozen = Set<Int>()
925
+ for _ in 0...line.count {
926
+ var fixed = CGFloat(max(0, line.count - 1)) * gap
927
+ var activeBasis: CGFloat = 0
928
+ var factor: CGFloat = 0
929
+ for (offset, index) in line.enumerated() {
930
+ let item = items[index]
931
+ fixed += item.mainMargins
932
+ if frozen.contains(offset) {
933
+ fixed += widths[offset]
934
+ } else {
935
+ activeBasis += item.basis
936
+ factor += growing ? item.grow : item.shrink * item.basis
937
+ }
938
+ }
939
+ guard factor > 0 else { break }
940
+ let remaining = available - fixed - activeBasis
941
+ var froze = false
942
+ for (offset, index) in line.enumerated() where !frozen.contains(offset) {
943
+ let item = items[index]
944
+ let share = growing
945
+ ? remaining * item.grow / factor
946
+ : -remaining * item.shrink * item.basis / factor
947
+ let raw = growing ? item.basis + share : item.basis - share
948
+ let clamped = clamp(raw, item.minMain, item.maxMain)
949
+ widths[offset] = clamped
950
+ if abs(clamped - raw) > 0.0001 {
951
+ frozen.insert(offset)
952
+ froze = true
953
+ }
954
+ }
955
+ if !froze { break }
956
+ }
957
+ return widths
958
+ }
959
+
960
+ items = children.map { view in
857
961
  let style = view.craftLayoutStyle
858
962
  let mainLeading = horizontal ? style.marginLeft : style.marginTop
859
963
  let mainTrailing = horizontal ? style.marginRight : style.marginBottom
@@ -971,22 +1075,8 @@ private final class CraftNativeFlowView: UIStackView {
971
1075
  if let sharedWidths {
972
1076
  for (offset, index) in line.enumerated() { items[index].main = sharedWidths[offset].width }
973
1077
  } 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
- }
1078
+ let fallback = fallbackWidths(for: line, available: lineMain, gap: mainGap)
1079
+ for (offset, index) in line.enumerated() { items[index].main = fallback[offset] }
990
1080
  }
991
1081
 
992
1082
  var lineCross: CGFloat = 0
@@ -1162,11 +1252,13 @@ private final class CraftNativeFlowView: UIStackView {
1162
1252
  )
1163
1253
  sizes.append(size)
1164
1254
  let row = index / columns
1165
- while rowHeights.count <= row { rowHeights.append(gridAutoRows ?? 0) }
1255
+ while rowHeights.count <= row { rowHeights.append(gridAutoRows?.resolve(innerHeight) ?? 0) }
1166
1256
  rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
1167
1257
  }
1168
1258
  let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
1169
1259
  let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
1260
+ // Older runtimes return nil for the optional symbol; the local layout
1261
+ // below remains the compatibility path in that case.
1170
1262
  let sharedFrames = CraftNativeGridBridge.frames(
1171
1263
  availableWidth: innerWidth ?? width,
1172
1264
  availableHeight: innerHeight ?? height,
@@ -1186,7 +1278,8 @@ private final class CraftNativeFlowView: UIStackView {
1186
1278
  style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
1187
1279
  align == "stretch", alignment
1188
1280
  )
1189
- }
1281
+ },
1282
+ autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 }
1190
1283
  )
1191
1284
  if place {
1192
1285
  if let sharedFrames {
@@ -1220,6 +1313,95 @@ private final class CraftNativeFlowView: UIStackView {
1220
1313
  }
1221
1314
  }
1222
1315
 
1316
+ /// A flex container that responds to the controller's keyboard overlap.
1317
+ /// `padding` keeps the content visible without moving the field; `position`
1318
+ /// lifts the whole container; `height` reduces its viewport.
1319
+ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
1320
+ private var authoredPadding = UIEdgeInsets.zero
1321
+ private var keyboardInset: CGFloat = 0
1322
+ private var keyboardVerticalOffset: CGFloat = 0
1323
+ private var keyboardBehavior = "padding"
1324
+ private var appliedHeightInset: CGFloat = 0
1325
+ private var adjustingHeight = false
1326
+
1327
+ func configureKeyboard(props: [String: Any]) {
1328
+ let requestedBehavior = (props["behavior"] as? String) ?? "padding"
1329
+ keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
1330
+ keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
1331
+ if keyboardBehavior != "height" { appliedHeightInset = 0 }
1332
+ authoredPadding = padding
1333
+ applyKeyboardAvoidance()
1334
+ }
1335
+
1336
+ func setKeyboardInset(_ inset: CGFloat) {
1337
+ guard abs(keyboardInset - inset) > 0.5 else { return }
1338
+ keyboardInset = max(0, inset)
1339
+ applyKeyboardAvoidance()
1340
+ }
1341
+
1342
+ private var effectiveInset: CGFloat { max(0, keyboardInset - keyboardVerticalOffset) }
1343
+
1344
+ private func applyKeyboardAvoidance() {
1345
+ let inset = effectiveInset
1346
+ switch keyboardBehavior {
1347
+ case "position":
1348
+ transform = CGAffineTransform(translationX: 0, y: -inset)
1349
+ padding = authoredPadding
1350
+ case "height":
1351
+ transform = .identity
1352
+ padding = authoredPadding
1353
+ default:
1354
+ transform = .identity
1355
+ padding = UIEdgeInsets(
1356
+ top: authoredPadding.top,
1357
+ left: authoredPadding.left,
1358
+ bottom: authoredPadding.bottom + inset,
1359
+ right: authoredPadding.right
1360
+ )
1361
+ }
1362
+ invalidateMeasurements()
1363
+ }
1364
+
1365
+ override func layoutSubviews() {
1366
+ if keyboardBehavior == "height", !adjustingHeight {
1367
+ let inset = effectiveInset
1368
+ let target = max(0, bounds.height + appliedHeightInset - inset)
1369
+ if abs(target - bounds.height) > 0.5 {
1370
+ adjustingHeight = true
1371
+ appliedHeightInset = inset
1372
+ bounds.size.height = target
1373
+ super.layoutSubviews()
1374
+ adjustingHeight = false
1375
+ return
1376
+ }
1377
+ }
1378
+ super.layoutSubviews()
1379
+ }
1380
+ }
1381
+
1382
+ /// An image-backed flex container. The image stays behind ordinary native
1383
+ /// children so `ImageBackground` keeps the same layout and interaction
1384
+ /// semantics as a `View` while using the shared image loader.
1385
+ private final class CraftNativeImageBackgroundView: UIImageView {
1386
+ let contentStack = CraftNativeFlowView()
1387
+
1388
+ override init(frame: CGRect) {
1389
+ super.init(frame: frame)
1390
+ isUserInteractionEnabled = true
1391
+ contentMode = .scaleAspectFill
1392
+ clipsToBounds = true
1393
+ contentStack.backgroundColor = .clear
1394
+ addSubview(contentStack)
1395
+ }
1396
+
1397
+ required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
1398
+
1399
+ override func layoutSubviews() {
1400
+ super.layoutSubviews()
1401
+ contentStack.frame = bounds
1402
+ }
1403
+ }
1404
+
1223
1405
  private final class CraftNativeModalView: UIView {
1224
1406
  let contentStack = CraftNativeFlowView()
1225
1407
  private let blocker = UIView()
@@ -1876,6 +2058,13 @@ private final class CraftNativeButton: UIButton {
1876
2058
  }
1877
2059
  }
1878
2060
 
2061
+ /// A native checkbox button. UIKit has no checkbox control, so use the system
2062
+ /// square/checkmark symbols while keeping the control's title and accessibility
2063
+ /// state native and keyboard/touch reachable.
2064
+ private final class CraftNativeCheckboxButton: UIButton {
2065
+ var indeterminate = false
2066
+ }
2067
+
1879
2068
  private final class CraftNativeLabel: UILabel {
1880
2069
  var insets = UIEdgeInsets.zero {
1881
2070
  didSet {
@@ -2052,6 +2241,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2052
2241
  private var imageSources: [ObjectIdentifier: String] = [:]
2053
2242
  private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
2054
2243
  private var imageErrors: [ObjectIdentifier: String] = [:]
2244
+ private var checkboxIndeterminate: [ObjectIdentifier: Bool] = [:]
2055
2245
  private var tintedImages = Set<ObjectIdentifier>()
2056
2246
  private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
2057
2247
  private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
@@ -2186,7 +2376,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2186
2376
 
2187
2377
  override func viewDidDisappear(_ animated: Bool) {
2188
2378
  super.viewDidDisappear(animated)
2189
- if isMovingFromParent || navigationController?.viewControllers.contains(where: { $0 === self }) == false {
2379
+ // Gone for good only when its stack no longer holds it. UIKit's
2380
+ // `isMovingFromParent` is no witness: a tab's first screen covered
2381
+ // by a push while its own first appearance was still settling (an
2382
+ // app script opening the sign-in screen at launch) reports it true
2383
+ // while still the root of its stack, and the screen's script was
2384
+ // torn down under it, frozen on whatever it showed then.
2385
+ let inStack = navigationController?.viewControllers.contains(where: { $0 === self }) ?? false
2386
+ if !inStack {
2190
2387
  disposeRuntime()
2191
2388
  } else {
2192
2389
  screenVisibilityChanged(false)
@@ -2960,12 +3157,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2960
3157
  }
2961
3158
  let duration = (notification.userInfo?[UIResponder.keyboardAnimationDurationUserInfoKey] as? NSNumber)?.doubleValue ?? 0.25
2962
3159
  let curve = (notification.userInfo?[UIResponder.keyboardAnimationCurveUserInfoKey] as? NSNumber)?.uintValue ?? 7
2963
- let changed = abs(inset - keyboardSafeAreaInset) > 0.5
3160
+ let globalInset = containsKeyboardAvoidingView() ? 0 : inset
3161
+ let safeAreaChanged = abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5
2964
3162
  keyboardSafeAreaInset = inset
2965
3163
  keyboardScreenFrame = keyboardFrame
3164
+ updateKeyboardAvoidingViews()
2966
3165
  UIView.animate(withDuration: duration, delay: 0, options: UIView.AnimationOptions(rawValue: UInt(curve << 16))) {
2967
- if changed {
2968
- self.additionalSafeAreaInsets.bottom = inset
3166
+ if safeAreaChanged {
3167
+ self.additionalSafeAreaInsets.bottom = globalInset
2969
3168
  self.view.layoutIfNeeded()
2970
3169
  }
2971
3170
  self.insetScrollViewsForKeyboard()
@@ -2973,6 +3172,33 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2973
3172
  }
2974
3173
  }
2975
3174
 
3175
+ private func updateKeyboardAvoidingViews() {
3176
+ func visit(_ node: RenderedNode?) {
3177
+ guard let node else { return }
3178
+ if let keyboard = node.view as? CraftNativeKeyboardAvoidingView {
3179
+ keyboard.setKeyboardInset(keyboardSafeAreaInset)
3180
+ }
3181
+ node.children.forEach(visit)
3182
+ }
3183
+ visit(renderedRoot)
3184
+ }
3185
+
3186
+ private func containsKeyboardAvoidingView() -> Bool {
3187
+ func visit(_ node: RenderedNode?) -> Bool {
3188
+ guard let node else { return false }
3189
+ if node.view is CraftNativeKeyboardAvoidingView { return true }
3190
+ return node.children.contains(where: visit)
3191
+ }
3192
+ return visit(renderedRoot)
3193
+ }
3194
+
3195
+ private func syncGlobalKeyboardSafeArea() {
3196
+ let globalInset = containsKeyboardAvoidingView() ? 0 : keyboardSafeAreaInset
3197
+ guard abs(additionalSafeAreaInsets.bottom - globalInset) > 0.5 else { return }
3198
+ additionalSafeAreaInsets.bottom = globalInset
3199
+ view.setNeedsLayout()
3200
+ }
3201
+
2976
3202
  /// A scroll view the keyboard still covers part of (one that does not end
2977
3203
  /// at the screen's bottom edge, or a keyboard taller than the safe area
2978
3204
  /// took account of) insets its content by that much, so everything in it
@@ -3311,6 +3537,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3311
3537
  rootStack.setLayoutStyle(rootStyle, for: next.view)
3312
3538
  rootStack.invalidateMeasurements()
3313
3539
  renderedRoot = next
3540
+ updateKeyboardAvoidingViews()
3541
+ syncGlobalKeyboardSafeArea()
3314
3542
  trackContentScrollView()
3315
3543
  restoreFocus(focused)
3316
3544
  }
@@ -3318,6 +3546,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3318
3546
  private func clearRenderedTree() {
3319
3547
  if let root = renderedRoot { detach(root, from: rootStack) }
3320
3548
  renderedRoot = nil
3549
+ syncGlobalKeyboardSafeArea()
3321
3550
  }
3322
3551
 
3323
3552
  private func firstResponder(in view: UIView) -> UIView? {
@@ -3334,7 +3563,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3334
3563
  }
3335
3564
 
3336
3565
  private func reconcile(_ node: [String: Any], identity: String, path: String, previous: RenderedNode?) -> RenderedNode {
3337
- let type = node["type"] as? String ?? "View"
3566
+ let type = canonicalType(node["type"] as? String ?? "View")
3338
3567
  let props = node["props"] as? [String: Any] ?? [:]
3339
3568
  let style = node["style"] as? [String: Any] ?? [:]
3340
3569
  let events = node["events"] as? [String: String] ?? [:]
@@ -3352,7 +3581,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3352
3581
  current.protocolId = node["id"] as? String
3353
3582
  current.style = style
3354
3583
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
3355
- if type != "View" && type != "SafeAreaView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
3584
+ if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "FlatList" && type != "Modal" {
3356
3585
  result.setContentHuggingPriority(.required, for: .vertical)
3357
3586
  }
3358
3587
  applyViewStyle(style, to: result, node: current)
@@ -3384,6 +3613,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3384
3613
  (button as? CraftNativeButton)?.padding = paddingInsets(style)
3385
3614
  button.isEnabled = props["disabled"] as? Bool != true
3386
3615
  updateHandler(nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: button)
3616
+ case "Checkbox":
3617
+ let checkbox = result as! CraftNativeCheckboxButton
3618
+ let label = props["label"] as? String ?? children.compactMap { $0 as? String }.joined()
3619
+ let transformedLabel = transformedText(label, style: style)
3620
+ let checked = (props["checked"] as? Bool) ?? (props["value"] as? Bool) ?? false
3621
+ let indeterminate = props["indeterminate"] as? Bool == true
3622
+ checkbox.isSelected = checked
3623
+ checkbox.indeterminate = indeterminate
3624
+ checkboxIndeterminate[ObjectIdentifier(checkbox)] = indeterminate
3625
+ checkbox.setTitle(transformedLabel, for: .normal)
3626
+ checkbox.setAttributedTitle(NSAttributedString(
3627
+ string: transformedLabel,
3628
+ attributes: buttonTitleAttributes(style, color: color(props["color"]) ?? color(style["color"]) ?? .label, font: textFont(style, default: .systemFont(ofSize: 16)), forceUnderline: false)
3629
+ ), for: .normal)
3630
+ let uncheckedImage = UIImage(systemName: indeterminate ? "minus.square" : "square")
3631
+ checkbox.setImage(uncheckedImage, for: .normal)
3632
+ checkbox.setImage(
3633
+ UIImage(systemName: indeterminate ? "minus.square" : "checkmark.square.fill"),
3634
+ for: .selected
3635
+ )
3636
+ checkbox.tintColor = color(props["color"]) ?? color(style["color"]) ?? .systemBlue
3637
+ checkbox.isEnabled = props["disabled"] as? Bool != true
3638
+ updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: checkbox)
3387
3639
  case "TextInput":
3388
3640
  if let textView = result as? UITextView {
3389
3641
  inputIdentities[ObjectIdentifier(textView)] = current.identity
@@ -3483,7 +3735,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3483
3735
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
3484
3736
  picker.minimumDate = Self.pickerDate(props["minimumDate"])
3485
3737
  picker.maximumDate = Self.pickerDate(props["maximumDate"])
3486
- if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
3738
+ if mode == .countDownTimer {
3739
+ if let duration = Self.countdownDuration(props["value"]), abs(picker.countDownDuration - duration) >= 1 {
3740
+ picker.countDownDuration = duration
3741
+ }
3742
+ } else if let date = Self.pickerDate(props["value"]), abs(picker.date.timeIntervalSince(date)) >= 1 {
3487
3743
  picker.setDate(date, animated: false)
3488
3744
  }
3489
3745
  picker.tintColor = color(props["color"]) ?? color(style["color"])
@@ -3604,6 +3860,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3604
3860
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
3605
3861
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
3606
3862
  updateImage(image, source: props["source"])
3863
+ case "ImageBackground":
3864
+ let background = result as! CraftNativeImageBackgroundView
3865
+ let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
3866
+ background.contentMode = imageContentMode(requestedResizeMode)
3867
+ background.clipsToBounds = background.contentMode == .scaleAspectFill
3868
+ updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
3869
+ updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
3870
+ updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
3871
+ updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
3872
+ updateImage(background, source: props["source"])
3873
+ configureStack(background.contentStack, style: style)
3874
+ reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
3875
+ case "KeyboardAvoidingView":
3876
+ let keyboard = result as! CraftNativeKeyboardAvoidingView
3877
+ configureStack(keyboard, style: style)
3878
+ keyboard.configureKeyboard(props: props)
3879
+ reconcileChildren(children, in: keyboard, parent: current, path: path, style: style)
3607
3880
  case "Video":
3608
3881
  let video = result as! CraftNativeVideoView
3609
3882
  let autoplay = (props["autoplay"] as? Bool) == true || (props["autoPlay"] as? Bool) == true
@@ -3670,7 +3943,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3670
3943
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
3671
3944
  }
3672
3945
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
3673
- if type != "Button" && type != "Link" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
3946
+ if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
3674
3947
  let disabled = props["disabled"] as? Bool == true
3675
3948
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
3676
3949
  updatePressFeedback(
@@ -3694,6 +3967,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3694
3967
  let button = CraftNativeButton(type: .system)
3695
3968
  button.addTarget(self, action: #selector(buttonPressed(_:)), for: .touchUpInside)
3696
3969
  return button
3970
+ case "Checkbox":
3971
+ let checkbox = CraftNativeCheckboxButton(type: .system)
3972
+ checkbox.semanticContentAttribute = .forceLeftToRight
3973
+ checkbox.contentHorizontalAlignment = .leading
3974
+ checkbox.addTarget(self, action: #selector(checkboxPressed(_:)), for: .touchUpInside)
3975
+ return checkbox
3697
3976
  case "TextInput":
3698
3977
  if props["multiline"] as? Bool == true {
3699
3978
  let textView = CraftNativeTextView()
@@ -3731,6 +4010,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3731
4010
  return UIActivityIndicatorView(style: .medium)
3732
4011
  case "Image":
3733
4012
  return UIImageView()
4013
+ case "ImageBackground":
4014
+ return CraftNativeImageBackgroundView(frame: .zero)
4015
+ case "KeyboardAvoidingView":
4016
+ return CraftNativeKeyboardAvoidingView()
3734
4017
  case "Video":
3735
4018
  return CraftNativeVideoView(host: self)
3736
4019
  case "Icon":
@@ -3752,12 +4035,47 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3752
4035
  }
3753
4036
  }
3754
4037
 
4038
+ private func canonicalType(_ raw: String) -> String {
4039
+ switch raw.lowercased() {
4040
+ case "view": return "View"
4041
+ case "safeareaview": return "SafeAreaView"
4042
+ case "keyboardavoidingview": return "KeyboardAvoidingView"
4043
+ case "text": return "Text"
4044
+ case "button": return "Button"
4045
+ case "link": return "Link"
4046
+ case "checkbox": return "Checkbox"
4047
+ case "textinput", "input": return "TextInput"
4048
+ case "picker", "select": return "Picker"
4049
+ case "modal": return "Modal"
4050
+ case "switch": return "Switch"
4051
+ case "slider": return "Slider"
4052
+ case "activityindicator": return "ActivityIndicator"
4053
+ case "image": return "Image"
4054
+ case "imagebackground": return "ImageBackground"
4055
+ case "video": return "Video"
4056
+ case "icon": return "Icon"
4057
+ case "scrollview": return "ScrollView"
4058
+ case "flatlist": return "FlatList"
4059
+ case "svg": return "Svg"
4060
+ case "touchableopacity": return "TouchableOpacity"
4061
+ case "touchablehighlight": return "TouchableHighlight"
4062
+ case "pressable": return "Pressable"
4063
+ default: return raw
4064
+ }
4065
+ }
4066
+
3755
4067
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
3756
4068
  // The current @stacksjs/stx native compiler emits key in props; accept the IR
3757
4069
  // field too so keyed children keep working when the compiler adopts it.
3758
- [node["key"], props["key"], props["testID"], node["id"]]
4070
+ let direct = [node["key"], props["key"], props["testID"]]
3759
4071
  .compactMap { $0 as? String }
3760
4072
  .first { !$0.isEmpty }
4073
+ if let direct { return direct }
4074
+ for child in node["children"] as? [[String: Any]] ?? [] {
4075
+ let childProps = child["props"] as? [String: Any] ?? [:]
4076
+ if let nested = explicitKey(child, props: childProps) { return nested }
4077
+ }
4078
+ return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
3761
4079
  }
3762
4080
 
3763
4081
  private func accessibilityIdentifier(_ node: [String: Any], props: [String: Any]) -> String? {
@@ -3976,6 +4294,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3976
4294
  imageLoadHandlers.removeValue(forKey: id)
3977
4295
  imageLoadEndHandlers.removeValue(forKey: id)
3978
4296
  imageErrorHandlers.removeValue(forKey: id)
4297
+ checkboxIndeterminate.removeValue(forKey: id)
3979
4298
  if let video = node.view as? CraftNativeVideoView { video.stop() }
3980
4299
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
3981
4300
  }
@@ -4241,7 +4560,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4241
4560
  stack.columnGap = number(style["columnGap"]) ?? gap
4242
4561
  stack.grid = style["display"] as? String == "grid"
4243
4562
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
4244
- stack.gridAutoRows = number(style["gridAutoRows"])
4563
+ stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
4245
4564
  stack.padding = paddingInsets(style)
4246
4565
  stack.invalidateMeasurements()
4247
4566
  }
@@ -4565,7 +4884,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4565
4884
 
4566
4885
  private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
4567
4886
  view.accessibilityLabel = (props["accessibilityLabel"] as? String)
4568
- ?? (type == "Image" ? props["alt"] as? String : nil)
4887
+ ?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
4569
4888
  view.accessibilityHint = props["accessibilityHint"] as? String
4570
4889
  if let value = props["accessibilityValue"] {
4571
4890
  view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
@@ -4573,10 +4892,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4573
4892
  view.accessibilityValue = nil
4574
4893
  }
4575
4894
  let role = props["accessibilityRole"] as? String
4576
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Image", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
4895
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
4577
4896
  var traits: UIAccessibilityTraits = []
4578
4897
  switch role ?? type.lowercased() {
4579
- case "button": traits.insert(.button)
4898
+ case "button", "checkbox": traits.insert(.button)
4580
4899
  case "image": traits.insert(.image)
4581
4900
  case "header": traits.insert(.header)
4582
4901
  case "link": traits.insert(.link)
@@ -4596,7 +4915,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4596
4915
  if state["checked"] as? Bool == true { traits.insert(.selected) }
4597
4916
  }
4598
4917
  if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
4599
- if type == "Image", let message = imageErrors[ObjectIdentifier(view)] {
4918
+ if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
4919
+ if checkbox.isSelected { traits.insert(.selected) }
4920
+ view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
4921
+ }
4922
+ if (type == "Image" || type == "ImageBackground"), let message = imageErrors[ObjectIdentifier(view)] {
4600
4923
  let current = view.accessibilityValue
4601
4924
  if current?.contains(message) != true {
4602
4925
  view.accessibilityValue = [current, message].compactMap { $0 }.joined(separator: ", ")
@@ -4631,6 +4954,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4631
4954
  }
4632
4955
  }
4633
4956
 
4957
+ @objc private func checkboxPressed(_ sender: CraftNativeCheckboxButton) {
4958
+ sender.indeterminate = false
4959
+ checkboxIndeterminate[ObjectIdentifier(sender)] = false
4960
+ sender.isSelected.toggle()
4961
+ if let handler = handlers[ObjectIdentifier(sender)] {
4962
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isSelected]])
4963
+ }
4964
+ applyAccessibility([:], type: "Checkbox", to: sender)
4965
+ }
4966
+
4634
4967
  /// Return on a `returnKeyType="next"` field the screen does not handle
4635
4968
  /// itself (no `@submit`) goes to the next field down the screen, keeping
4636
4969
  /// the keyboard up. Every other Return ends editing and fires `@submit`.
@@ -4730,7 +5063,24 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4730
5063
  /// milliseconds for code that wants those.
4731
5064
  @objc private func datePicked(_ sender: UIDatePicker) {
4732
5065
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
4733
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender.date, mode: sender.datePickerMode)])
5066
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": Self.datePickerEvent(sender)])
5067
+ }
5068
+
5069
+ static func datePickerEvent(_ picker: UIDatePicker) -> [String: Any] {
5070
+ if picker.datePickerMode == .countDownTimer { return countdownEvent(picker.countDownDuration) }
5071
+ return datePickerEvent(picker.date, mode: picker.datePickerMode)
5072
+ }
5073
+
5074
+ static func countdownEvent(_ duration: TimeInterval) -> [String: Any] {
5075
+ let totalSeconds = max(0, Int(duration.rounded()))
5076
+ let hours = totalSeconds / 3600
5077
+ let minutes = (totalSeconds % 3600) / 60
5078
+ let seconds = totalSeconds % 60
5079
+ return [
5080
+ "value": String(format: "%02d:%02d:%02d", hours, minutes, seconds),
5081
+ "iso": "PT\(totalSeconds)S",
5082
+ "timestamp": Double(totalSeconds * 1000),
5083
+ ]
4734
5084
  }
4735
5085
 
4736
5086
  static func datePickerEvent(_ date: Date, mode: UIDatePicker.Mode) -> [String: Any] {
@@ -4757,6 +5107,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4757
5107
  }
4758
5108
  }
4759
5109
 
5110
+ /// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
5111
+ static func countdownDuration(_ value: Any?) -> TimeInterval? {
5112
+ let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
5113
+ if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
5114
+ guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
5115
+ if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
5116
+ let parts = text.split(separator: ":").compactMap { Double($0) }
5117
+ guard parts.count == 2 || parts.count == 3 else { return nil }
5118
+ let hours = parts[0]
5119
+ let minutes = parts[1]
5120
+ let seconds = parts.count == 3 ? parts[2] : 0
5121
+ guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
5122
+ return min(maximum, hours * 3600 + minutes * 60 + seconds)
5123
+ }
5124
+
4760
5125
  /// A DatePicker's value: `2026-10-12` (that day), `07:30` (today at that
4761
5126
  /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
4762
5127
  /// milliseconds since 1970. Nil for anything else.