craft-native 0.0.175 → 0.0.177

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.
Files changed (34) hide show
  1. package/dist/android/src/index.d.ts +19 -0
  2. package/dist/android/src/index.js +19 -2
  3. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  4. package/dist/android/templates/CraftBridge.kt.template +51 -0
  5. package/dist/android/templates/CraftCrashReporter.kt.template +544 -0
  6. package/dist/android/templates/CraftNative.kt.template +17 -0
  7. package/dist/android/templates/CraftNativeFlatList.kt.template +20 -9
  8. package/dist/android/templates/CraftNativeMutation.kt.template +4 -1
  9. package/dist/android/templates/CraftNativeServices.kt.template +31 -1
  10. package/dist/android/templates/MainActivityNative.kt.template +986 -304
  11. package/dist/android/templates/build.gradle.kts.app.template +4 -2
  12. package/dist/api/mobile.d.ts +31 -0
  13. package/dist/cli.js +1 -1
  14. package/dist/components/index.d.ts +203 -2
  15. package/dist/index.cjs +1 -1
  16. package/dist/index.d.cts +1 -1
  17. package/dist/index.d.ts +1 -1
  18. package/dist/index.js +1 -1
  19. package/dist/ios/src/index.d.ts +19 -0
  20. package/dist/ios/src/index.js +7 -1
  21. package/dist/ios/src/testing.d.ts +5 -0
  22. package/dist/ios/templates/CraftApp.swift +51 -1
  23. package/dist/ios/templates/CraftCrashReporter.swift +1010 -0
  24. package/dist/ios/templates/CraftHybrid.swift +10 -0
  25. package/dist/ios/templates/CraftNativeActions.swift +49 -1
  26. package/dist/ios/templates/CraftNativeFlatList.swift +30 -28
  27. package/dist/ios/templates/CraftNativeMutation.swift +1 -1
  28. package/dist/ios/templates/CraftNativeScreen.swift +559 -86
  29. package/dist/ios/templates/project.yml.template +1 -0
  30. package/dist/ios/templates/testing/DriverTests.swift.template +8 -0
  31. package/dist/macos/templates/CraftNativeMutation.swift +1 -1
  32. package/dist/macos/templates/native_layout.zig +389 -17
  33. package/dist/mobile.js +66 -0
  34. package/package.json +1 -1
@@ -12,6 +12,15 @@ import UIKit
12
12
 
13
13
  private let craftNativeCapabilityTimeoutMilliseconds = 30_000
14
14
 
15
+ /// A finite number from a prop, for the views outside the screen controller,
16
+ /// which has its own: the keyboard-avoiding view and SVG text read theirs
17
+ /// with it, and without it the template did not compile.
18
+ private func number(_ value: Any?) -> CGFloat? {
19
+ guard let number = value as? NSNumber else { return nil }
20
+ let value = CGFloat(truncating: number)
21
+ return value.isFinite ? value : nil
22
+ }
23
+
15
24
  private func craftNativeGridColumnCount(_ value: Any?) -> Int {
16
25
  if let number = value as? NSNumber { return max(1, number.intValue) }
17
26
  guard let text = value as? String else { return 1 }
@@ -81,6 +90,178 @@ private func craftNativeJustifyCode(_ value: String) -> UInt32 {
81
90
  }
82
91
  }
83
92
 
93
+ /// Optional bridge to the host-neutral tree layout ABI. The host still owns
94
+ /// intrinsic measurement and keeps its local implementation for unsupported
95
+ /// trees or runtimes built before this symbol was exported.
96
+ private enum CraftNativeTreeLayoutBridge {
97
+ private struct StyleInput {
98
+ var direction: UInt32 = 0
99
+ var width: Float = .nan
100
+ var height: Float = .nan
101
+ var intrinsicWidth: Float = .nan
102
+ var intrinsicHeight: Float = .nan
103
+ var baseline: Float = .nan
104
+ var aspectRatio: Float = .nan
105
+ var flexBasis: Float = .nan
106
+ var flexGrow: Float = 0
107
+ var flexShrink: Float = 0
108
+ var minWidth: Float = .nan
109
+ var maxWidth: Float = .nan
110
+ var minHeight: Float = .nan
111
+ var maxHeight: Float = .nan
112
+ var gap: Float = 0
113
+ var justifyContent: UInt32 = 0
114
+ var alignItems: UInt32 = 3
115
+ var alignSelf: UInt32 = 0
116
+ var flexWrap: UInt32 = 0
117
+ var alignContent: UInt32 = 6
118
+ var marginTop: Float = 0
119
+ var marginRight: Float = 0
120
+ var marginBottom: Float = 0
121
+ var marginLeft: Float = 0
122
+ var paddingTop: Float = 0
123
+ var paddingRight: Float = 0
124
+ var paddingBottom: Float = 0
125
+ var paddingLeft: Float = 0
126
+ var grid: UInt32 = 0
127
+ var gridColumns: UInt32 = 1
128
+ var rowGap: Float = 0
129
+ var columnGap: Float = 0
130
+ }
131
+
132
+ private struct NodeInput {
133
+ var style: StyleInput
134
+ var firstChild: UInt32
135
+ var childCount: UInt32
136
+ }
137
+
138
+ private struct Frame {
139
+ var x: Float
140
+ var y: Float
141
+ var width: Float
142
+ var height: Float
143
+ }
144
+
145
+ private typealias Function = @convention(c) (
146
+ Float, Float, UnsafeRawPointer, Int, UnsafeMutableRawPointer, Int
147
+ ) -> Int32
148
+
149
+ private static let function: Function? = {
150
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree") else { return nil }
151
+ return unsafeBitCast(symbol, to: Function.self)
152
+ }()
153
+
154
+ private static func finite(_ value: CGFloat?) -> Float {
155
+ guard let value, value.isFinite else { return .nan }
156
+ return Float(value)
157
+ }
158
+
159
+ private static func length(_ value: CraftNativeLength?, base: CGFloat?) -> Float {
160
+ guard let value else { return .nan }
161
+ return finite(value.resolve(base))
162
+ }
163
+
164
+ private static func alignment(_ value: String?) -> UInt32 {
165
+ switch value {
166
+ case "center": return 1
167
+ case "flex-end": return 2
168
+ case "stretch": return 3
169
+ case "baseline": return 4
170
+ default: return 0
171
+ }
172
+ }
173
+
174
+ private static func alignSelf(_ value: String?) -> UInt32 {
175
+ switch value {
176
+ case "flex-start": return 1
177
+ case "center": return 2
178
+ case "flex-end": return 3
179
+ case "stretch": return 4
180
+ case "baseline": return 5
181
+ default: return 0
182
+ }
183
+ }
184
+
185
+ private static func justify(_ value: String) -> UInt32 {
186
+ craftNativeJustifyCode(value)
187
+ }
188
+
189
+ static func frames(
190
+ width: CGFloat,
191
+ height: CGFloat,
192
+ direction: NSLayoutConstraint.Axis,
193
+ gap: CGFloat,
194
+ rowGap: CGFloat?,
195
+ columnGap: CGFloat?,
196
+ wrap: Bool,
197
+ justifyContent: String,
198
+ alignItems: String,
199
+ padding: UIEdgeInsets,
200
+ children: [(UIView, CraftNativeLayoutStyle, CGSize, CGFloat)]
201
+ ) -> [(UIView, CGRect)]? {
202
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !children.isEmpty else { return nil }
203
+ let mainGap = direction == .horizontal ? (columnGap ?? gap) : (rowGap ?? gap)
204
+ let crossGap = direction == .horizontal ? (rowGap ?? gap) : (columnGap ?? gap)
205
+ var root = StyleInput(
206
+ direction: direction == .horizontal ? 0 : 1,
207
+ width: Float(width), height: Float(height), gap: Float(max(0, mainGap)),
208
+ justifyContent: justify(justifyContent), alignItems: alignment(alignItems), flexWrap: wrap ? 1 : 0,
209
+ paddingTop: Float(max(0, padding.top)), paddingRight: Float(max(0, padding.right)),
210
+ paddingBottom: Float(max(0, padding.bottom)), paddingLeft: Float(max(0, padding.left))
211
+ )
212
+ root.rowGap = Float(max(0, rowGap ?? 0))
213
+ root.columnGap = Float(max(0, columnGap ?? 0))
214
+ let contentWidth = max(0, width - padding.left - padding.right)
215
+ let contentHeight = max(0, height - padding.top - padding.bottom)
216
+ let mainSize = direction == .horizontal ? contentWidth : contentHeight
217
+ var nodes = [NodeInput(style: root, firstChild: 1, childCount: UInt32(children.count))]
218
+ for (view, style, intrinsic, baseline) in children {
219
+ let shrink: CGFloat
220
+ if let authored = style.flexShrink {
221
+ shrink = authored
222
+ } else if view is UIScrollView {
223
+ shrink = 1
224
+ } else if view is UILabel, direction == .horizontal {
225
+ shrink = 1
226
+ } else {
227
+ shrink = 0
228
+ }
229
+ var input = StyleInput(
230
+ width: length(style.width, base: contentWidth), height: length(style.height, base: contentHeight),
231
+ intrinsicWidth: Float(max(0, intrinsic.width)), intrinsicHeight: Float(max(0, intrinsic.height)),
232
+ baseline: Float(max(0, baseline)), aspectRatio: finite(style.aspectRatio),
233
+ flexBasis: length(style.flexBasis, base: mainSize), flexGrow: Float(max(0, style.flexGrow)), flexShrink: Float(max(0, shrink)),
234
+ minWidth: length(style.minWidth, base: contentWidth), maxWidth: length(style.maxWidth, base: contentWidth),
235
+ minHeight: length(style.minHeight, base: contentHeight), maxHeight: length(style.maxHeight, base: contentHeight),
236
+ alignSelf: alignSelf(style.alignSelf),
237
+ marginTop: Float(max(0, style.marginTop)), marginRight: Float(max(0, style.marginRight)),
238
+ marginBottom: Float(max(0, style.marginBottom)), marginLeft: Float(max(0, style.marginLeft))
239
+ )
240
+ input.direction = 0
241
+ nodes.append(NodeInput(style: input, firstChild: 0, childCount: 0))
242
+ }
243
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
244
+ let frameCount = output.count
245
+ let status = nodes.withUnsafeBufferPointer { nodeBuffer in
246
+ output.withUnsafeMutableBufferPointer { frameBuffer in
247
+ function(
248
+ Float(width), Float(height), UnsafeRawPointer(nodeBuffer.baseAddress!), nodes.count,
249
+ UnsafeMutableRawPointer(frameBuffer.baseAddress!), frameCount
250
+ )
251
+ }
252
+ }
253
+ guard status == 0 else { return nil }
254
+ let childFrames = output.dropFirst()
255
+ guard childFrames.allSatisfy({ frame in
256
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
257
+ frame.width >= 0 && frame.height >= 0
258
+ }) else { return nil }
259
+ return zip(children, childFrames).map { child, frame in
260
+ (child.0, CGRect(x: CGFloat(frame.x), y: CGFloat(frame.y), width: CGFloat(frame.width), height: CGFloat(frame.height)))
261
+ }
262
+ }
263
+ }
264
+
84
265
  private enum CraftNativeFlexLineBridge {
85
266
  private struct Item {
86
267
  var basis: Float
@@ -430,6 +611,7 @@ private final class CraftNativeVideoView: UIView {
430
611
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
431
612
  private var annotationsByID: [String: MKPointAnnotation] = [:]
432
613
  private var overlaysByID: [String: MKPolyline] = [:]
614
+ private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
433
615
  private var overlayColors: [ObjectIdentifier: UIColor] = [:]
434
616
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
435
617
  private(set) var markerHandlers: [String: String] = [:]
@@ -479,7 +661,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
479
661
  }
480
662
  } else if type == "mappolyline" || type == "polyline" {
481
663
  guard points.count > 1 else { continue }
482
- nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), CGFloat((childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3))
664
+ let authoredWidth = (childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3
665
+ let width = CGFloat(authoredWidth)
666
+ guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
667
+ nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
483
668
  }
484
669
  }
485
670
 
@@ -499,13 +684,31 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
499
684
  }
500
685
  markerHandlers = nextHandlers
501
686
 
502
- removeOverlays(Array(overlaysByID.values))
503
- overlaysByID.removeAll(keepingCapacity: true)
504
- overlayColors.removeAll(keepingCapacity: true)
505
- overlayWidths.removeAll(keepingCapacity: true)
687
+ let staleOverlays = overlaysByID.keys.filter { nextPolylines[$0] == nil }
688
+ for id in staleOverlays {
689
+ if let overlay = overlaysByID.removeValue(forKey: id) {
690
+ removeOverlay(overlay)
691
+ overlayColors.removeValue(forKey: ObjectIdentifier(overlay))
692
+ overlayWidths.removeValue(forKey: ObjectIdentifier(overlay))
693
+ }
694
+ overlayCoordinatesByID.removeValue(forKey: id)
695
+ }
506
696
  for (id, value) in nextPolylines {
697
+ if let overlay = overlaysByID[id],
698
+ let coordinates = overlayCoordinatesByID[id],
699
+ Self.coordinatesEqual(coordinates, value.coordinates),
700
+ overlayColors[ObjectIdentifier(overlay)]?.isEqual(value.color) == true,
701
+ overlayWidths[ObjectIdentifier(overlay)] == value.width {
702
+ continue
703
+ }
704
+ if let previous = overlaysByID.removeValue(forKey: id) {
705
+ removeOverlay(previous)
706
+ overlayColors.removeValue(forKey: ObjectIdentifier(previous))
707
+ overlayWidths.removeValue(forKey: ObjectIdentifier(previous))
708
+ }
507
709
  let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
508
710
  overlaysByID[id] = overlay
711
+ overlayCoordinatesByID[id] = value.coordinates
509
712
  overlayColors[ObjectIdentifier(overlay)] = value.color
510
713
  overlayWidths[ObjectIdentifier(overlay)] = value.width
511
714
  addOverlay(overlay)
@@ -557,8 +760,19 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
557
760
  let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
558
761
  ?? (value as? [NSNumber])?.map(\.doubleValue)
559
762
  ?? []
560
- return stride(from: 0, to: numbers.count - 1, by: 2).map {
561
- CLLocationCoordinate2D(latitude: numbers[$0], longitude: numbers[$0 + 1])
763
+ return stride(from: 0, to: numbers.count - 1, by: 2).compactMap {
764
+ let latitude = numbers[$0]
765
+ let longitude = numbers[$0 + 1]
766
+ guard latitude.isFinite, longitude.isFinite,
767
+ (-90...90).contains(latitude), (-180...180).contains(longitude) else { return nil }
768
+ return CLLocationCoordinate2D(latitude: latitude, longitude: longitude)
769
+ }
770
+ }
771
+
772
+ private static func coordinatesEqual(_ lhs: [CLLocationCoordinate2D], _ rhs: [CLLocationCoordinate2D]) -> Bool {
773
+ guard lhs.count == rhs.count else { return false }
774
+ return zip(lhs, rhs).allSatisfy { left, right in
775
+ left.latitude == right.latitude && left.longitude == right.longitude
562
776
  }
563
777
  }
564
778
 
@@ -569,7 +783,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
569
783
  let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
570
784
  let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
571
785
  let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
572
- let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue else { return nil }
786
+ let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue,
787
+ latitude.isFinite, longitude.isFinite, latitudeDelta.isFinite, longitudeDelta.isFinite,
788
+ (-90...90).contains(latitude), (-180...180).contains(longitude),
789
+ latitudeDelta > 0, latitudeDelta <= 180, longitudeDelta > 0, longitudeDelta <= 360,
790
+ latitude - latitudeDelta / 2 >= -90, latitude + latitudeDelta / 2 <= 90 else { return nil }
573
791
  return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
574
792
  }
575
793
 
@@ -631,15 +849,17 @@ private enum CraftNativeLength: Equatable {
631
849
 
632
850
  init?(_ value: Any?) {
633
851
  if let number = value as? NSNumber {
634
- self = .points(CGFloat(truncating: number))
852
+ let points = CGFloat(truncating: number)
853
+ guard points.isFinite else { return nil }
854
+ self = .points(points)
635
855
  return
636
856
  }
637
857
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
638
- if text.hasSuffix("%"), let fraction = Double(text.dropLast()) {
858
+ if text.hasSuffix("%"), let fraction = Double(text.dropLast()), fraction.isFinite {
639
859
  self = .percent(CGFloat(fraction) / 100)
640
- } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)) {
860
+ } else if text.hasSuffix("px"), let points = Double(text.dropLast(2)), points.isFinite {
641
861
  self = .points(CGFloat(points))
642
- } else if let points = Double(text) {
862
+ } else if let points = Double(text), points.isFinite {
643
863
  self = .points(CGFloat(points))
644
864
  } else {
645
865
  return nil
@@ -648,7 +868,7 @@ private enum CraftNativeLength: Equatable {
648
868
 
649
869
  func resolve(_ base: CGFloat?) -> CGFloat? {
650
870
  switch self {
651
- case .points(let value): return value
871
+ case .points(let value): return max(0, value)
652
872
  case .percent(let fraction): return base.map { max(0, $0 * fraction) }
653
873
  }
654
874
  }
@@ -685,7 +905,11 @@ private struct CraftNativeLayoutStyle {
685
905
  static let empty = CraftNativeLayoutStyle([:])
686
906
 
687
907
  init(_ raw: [String: Any]) {
688
- func number(_ value: Any?) -> CGFloat? { (value as? NSNumber).map { CGFloat(truncating: $0) } }
908
+ func number(_ value: Any?) -> CGFloat? {
909
+ guard let number = value as? NSNumber else { return nil }
910
+ let value = CGFloat(truncating: number)
911
+ return value.isFinite ? value : nil
912
+ }
689
913
  width = CraftNativeLength(raw["width"])
690
914
  height = CraftNativeLength(raw["height"])
691
915
  let ratio = number(raw["aspectRatio"])
@@ -942,6 +1166,18 @@ enum CraftNativeFlexLayout {
942
1166
  /// grid. It is a UIStackView in name only, so existing callers and tests keep
943
1167
  /// `axis`, `alignment` and `arrangedSubviews`; the stack view's own
944
1168
  /// constraints are never created, and every frame comes from `layout`.
1169
+ extension UIView {
1170
+ /// The native screen this view is drawn on, through the responder chain.
1171
+ var craftScreenController: CraftNativeScreenController? {
1172
+ var responder: UIResponder? = next
1173
+ while let current = responder {
1174
+ if let screen = current as? CraftNativeScreenController { return screen }
1175
+ responder = current.next
1176
+ }
1177
+ return nil
1178
+ }
1179
+ }
1180
+
945
1181
  private class CraftNativeFlowView: UIStackView {
946
1182
  var pressActiveOpacity: CGFloat?
947
1183
  var pressBaseOpacity: CGFloat = 1
@@ -987,6 +1223,13 @@ private class CraftNativeFlowView: UIStackView {
987
1223
 
988
1224
  private var flowChildren: [UIView] = []
989
1225
  private var measurements: [MeasureKey: CGSize] = [:]
1226
+ /// The screen's rendered root gets a definite viewport from the host. A
1227
+ /// root with `flex: 1` must keep that height even when its first child is
1228
+ /// a ScrollView whose content is taller than the screen; otherwise an
1229
+ /// absolute sibling is positioned against the scroll content and moves
1230
+ /// with it.
1231
+ private weak var viewportChild: UIView?
1232
+ private var viewportChildFillsHeight = false
990
1233
 
991
1234
  private struct MeasureKey: Hashable {
992
1235
  let width: CGFloat?
@@ -1088,6 +1331,12 @@ private class CraftNativeFlowView: UIStackView {
1088
1331
  invalidateMeasurements()
1089
1332
  }
1090
1333
 
1334
+ func setViewportChild(_ view: UIView?, fillsHeight: Bool) {
1335
+ viewportChild = view
1336
+ viewportChildFillsHeight = fillsHeight
1337
+ invalidateMeasurements()
1338
+ }
1339
+
1091
1340
  func removeLayoutStyle(for view: UIView) {
1092
1341
  view.craftLayoutStyle = .empty
1093
1342
  }
@@ -1121,8 +1370,20 @@ private class CraftNativeFlowView: UIStackView {
1121
1370
  craftApplyCornerRadius()
1122
1371
  let scale = window?.screen.scale ?? traitCollection.displayScale
1123
1372
  let result = layout(width: bounds.width, height: bounds.height, maxWidth: bounds.width, maxHeight: bounds.height, place: true)
1373
+ var moved = false
1374
+ defer {
1375
+ // The screen reports onLayout from its own layout pass, which a
1376
+ // change deep in a scroll view's content never runs: a view that
1377
+ // went from 240 to 180 wide kept saying 240.
1378
+ if moved { craftScreenController?.scheduleLayoutEvents() }
1379
+ }
1124
1380
  for (child, frame) in result.frames {
1125
- var snapped = CraftNativeFlexLayout.snapped(frame, scale: scale)
1381
+ var viewportFrame = frame
1382
+ if child === viewportChild, viewportChildFillsHeight {
1383
+ viewportFrame.origin.y = 0
1384
+ viewportFrame.size.height = bounds.height
1385
+ }
1386
+ var snapped = CraftNativeFlexLayout.snapped(viewportFrame, scale: scale)
1126
1387
  // Snapping each edge can take a pixel off a label (15.83…33.83
1127
1388
  // becomes 16…33.67), and a label a pixel narrower than its text
1128
1389
  // truncates it ("13" drew as "…"). Text keeps its whole size.
@@ -1130,14 +1391,14 @@ private class CraftNativeFlowView: UIStackView {
1130
1391
  snapped.size = CraftNativeFlexLayout.ceiled(frame.size, scale: scale, atLeast: snapped.size)
1131
1392
  }
1132
1393
  if child.transform.isIdentity {
1133
- if child.frame != snapped { child.frame = snapped }
1394
+ if child.frame != snapped { child.frame = snapped; moved = true }
1134
1395
  } else {
1135
1396
  // A transformed child (a sticky header, a scaled spinner) is
1136
1397
  // placed by bounds and center: its frame is undefined then.
1137
1398
  let size = CGRect(origin: child.bounds.origin, size: snapped.size)
1138
1399
  let center = CGPoint(x: snapped.midX, y: snapped.midY)
1139
- if child.bounds != size { child.bounds = size }
1140
- if child.center != center { child.center = center }
1400
+ if child.bounds != size { child.bounds = size; moved = true }
1401
+ if child.center != center { child.center = center; moved = true }
1141
1402
  }
1142
1403
  child.craftApplyCornerRadius()
1143
1404
  }
@@ -1205,6 +1466,29 @@ private class CraftNativeFlowView: UIStackView {
1205
1466
  /// outer size when its parent fixes it, nil when it sizes to its content
1206
1467
  /// within `maxWidth`/`maxHeight`. Returns its outer size and, when
1207
1468
  /// placing, every child's frame in its own coordinates.
1469
+ private func sharedDirectFrames(_ children: [UIView], width: CGFloat?, height: CGFloat?) -> [(UIView, CGRect)]? {
1470
+ guard let width, let height, !grid, !reverse, !wrapReverse,
1471
+ width.isFinite, height.isFinite, width >= 0, height >= 0,
1472
+ children.allSatisfy({ child in
1473
+ let style = child.craftLayoutStyle
1474
+ return style.position != "absolute" && style.display != "none"
1475
+ }) else { return nil }
1476
+
1477
+ let intrinsic = children.map { child in
1478
+ CraftNativeFlexLayout.measure(child, width: nil, height: nil, maxWidth: width, maxHeight: height)
1479
+ }
1480
+ let baselines = children.enumerated().map { index, child in
1481
+ CraftNativeFlowView.firstBaseline(child, cross: intrinsic[index].height)
1482
+ }
1483
+ let payload = zip(children, zip(intrinsic, baselines)).map { child, measured in
1484
+ (child, child.craftLayoutStyle, measured.0, measured.1)
1485
+ }
1486
+ return CraftNativeTreeLayoutBridge.frames(
1487
+ width: width, height: height, direction: axis, gap: gap, rowGap: rowGap, columnGap: columnGap, wrap: wrap,
1488
+ justifyContent: justifyContent, alignItems: alignItems, padding: effectivePadding, children: payload
1489
+ )
1490
+ }
1491
+
1208
1492
  private func layout(
1209
1493
  width: CGFloat?,
1210
1494
  height: CGFloat?,
@@ -1239,6 +1523,9 @@ private class CraftNativeFlowView: UIStackView {
1239
1523
  frames: &frames
1240
1524
  )
1241
1525
  }
1526
+ if place, flow.count == children.count, let shared = sharedDirectFrames(flow, width: width, height: height) {
1527
+ frames = shared
1528
+ }
1242
1529
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
1243
1530
 
1244
1531
  if place {
@@ -1286,6 +1573,7 @@ private class CraftNativeFlowView: UIStackView {
1286
1573
  place: Bool,
1287
1574
  frames: inout [(UIView, CGRect)]
1288
1575
  ) -> CGSize {
1576
+ let contentPadding = effectivePadding
1289
1577
  let definiteMain = horizontal ? innerWidth : innerHeight
1290
1578
  let definiteCross = horizontal ? innerHeight : innerWidth
1291
1579
  let roomMain = horizontal ? roomWidth : roomHeight
@@ -1647,6 +1935,7 @@ private class CraftNativeFlowView: UIStackView {
1647
1935
  place: Bool,
1648
1936
  frames: inout [(UIView, CGRect)]
1649
1937
  ) -> CGSize {
1938
+ let contentPadding = effectivePadding
1650
1939
  let columns = max(1, gridColumns)
1651
1940
  let horizontalGap = columnGap ?? gap
1652
1941
  let verticalGap = rowGap ?? gap
@@ -1670,7 +1959,7 @@ private class CraftNativeFlowView: UIStackView {
1670
1959
  let available = max(0, cellWidth - style.marginLeft - style.marginRight)
1671
1960
  let align = style.alignSelf ?? alignItems
1672
1961
  var fixedWidth = style.width?.resolve(cellWidth)
1673
- var fixedHeight = style.height?.resolve(nil)
1962
+ var fixedHeight = style.height?.resolve(innerHeight)
1674
1963
  // A cell's item is as wide as its column unless it sets a width
1675
1964
  // (or an aspect ratio derives one from its height), whatever its
1676
1965
  // height: a fixed-height swatch with no content was drawn 0 wide.
@@ -1704,7 +1993,7 @@ private class CraftNativeFlowView: UIStackView {
1704
1993
  let size = sizes[index]
1705
1994
  let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1706
1995
  var explicitWidth = style.width?.resolve(cellWidth)
1707
- var explicitHeight = style.height?.resolve(nil)
1996
+ var explicitHeight = style.height?.resolve(innerHeight)
1708
1997
  if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
1709
1998
  explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1710
1999
  }
@@ -1713,7 +2002,7 @@ private class CraftNativeFlowView: UIStackView {
1713
2002
  size.width, size.height,
1714
2003
  explicitWidth, explicitHeight,
1715
2004
  style.minWidth?.resolve(cellWidth), style.maxWidth?.resolve(cellWidth),
1716
- style.minHeight?.resolve(nil), style.maxHeight?.resolve(nil),
2005
+ style.minHeight?.resolve(innerHeight), style.maxHeight?.resolve(innerHeight),
1717
2006
  style.marginLeft, style.marginRight, style.marginTop, style.marginBottom,
1718
2007
  align == "stretch", alignment
1719
2008
  )
@@ -1822,7 +2111,7 @@ private final class CraftNativeKeyboardAvoidingView: CraftNativeFlowView {
1822
2111
  func configureKeyboard(props: [String: Any]) {
1823
2112
  let requestedBehavior = (props["behavior"] as? String) ?? "padding"
1824
2113
  keyboardBehavior = ["height", "position", "padding"].contains(requestedBehavior) ? requestedBehavior : "padding"
1825
- keyboardVerticalOffset = (props["keyboardVerticalOffset"] as? NSNumber).map { CGFloat(truncating: $0) } ?? 0
2114
+ keyboardVerticalOffset = number(props["keyboardVerticalOffset"]) ?? 0
1826
2115
  if keyboardBehavior != "height" { appliedHeightInset = 0 }
1827
2116
  authoredPadding = padding
1828
2117
  applyKeyboardAvoidance()
@@ -1922,6 +2211,10 @@ private final class CraftNativeModalView: UIView {
1922
2211
 
1923
2212
  required init?(coder: NSCoder) { nil }
1924
2213
 
2214
+ /// Whether the modal takes touches is its own to say: shown, it takes
2215
+ /// every one (the scrim and its controls); the screen's press and long
2216
+ /// press bookkeeping once turned that off for a view with no handler of
2217
+ /// its own, and a shown modal let every tap through to the screen behind.
1925
2218
  func setVisible(_ visible: Bool, opacity: CGFloat, animationType: String?) {
1926
2219
  if visible { isUserInteractionEnabled = true }
1927
2220
  if renderedVisible == visible && isHidden == !visible {
@@ -2003,7 +2296,27 @@ private final class CraftNativeModalView: UIView {
2003
2296
  override func layoutSubviews() {
2004
2297
  super.layoutSubviews()
2005
2298
  blocker.frame = bounds
2006
- contentStack.frame = bounds
2299
+ // The scrim covers the whole screen; what the modal holds stays
2300
+ // clear of the bars and the home indicator it is drawn over.
2301
+ contentStack.frame = bounds.inset(by: UIEdgeInsets(top: contentInsets.top, left: 0, bottom: contentInsets.bottom, right: 0))
2302
+ }
2303
+
2304
+ /// Where the screen's own content starts and ends: the scrim covers the
2305
+ /// whole screen, what the modal holds stays clear of the bars.
2306
+ var contentInsets: UIEdgeInsets = .zero {
2307
+ didSet { if contentInsets != oldValue { setNeedsLayout() } }
2308
+ }
2309
+ }
2310
+
2311
+ /// The delegate of a Pressable's press-in/out feedback. That recognizer is
2312
+ /// a long press of no duration, so it begins the moment a finger lands; on its
2313
+ /// own it would claim the touch, and the tap that is the Pressable's onPress
2314
+ /// failed whenever onPressIn or onPressOut were set too. It only watches.
2315
+ final class CraftPressFeedbackDelegate: NSObject, UIGestureRecognizerDelegate {
2316
+ static let shared = CraftPressFeedbackDelegate()
2317
+
2318
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2319
+ true
2007
2320
  }
2008
2321
  }
2009
2322
 
@@ -2275,14 +2588,24 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
2275
2588
 
2276
2589
  @objc private func keyboardTap() {
2277
2590
  guard keyboardShouldPersistTaps != "always" else { return }
2278
- if keyboardShouldPersistTaps == "handled" {
2279
- let point = keyboardTapGesture.location(in: self)
2280
- var hit = hitTest(point, with: nil)
2281
- while let view = hit, view !== self {
2282
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { return }
2283
- hit = view.superview
2284
- }
2285
- }
2591
+ let point = keyboardTapGesture.location(in: self)
2592
+ var hit = hitTest(point, with: nil)
2593
+ var onControl = false
2594
+ var onButton = false
2595
+ while let view = hit, view !== self {
2596
+ if view is UIButton { onButton = true }
2597
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
2598
+ hit = view.superview
2599
+ }
2600
+ if keyboardShouldPersistTaps == "handled", onControl { return }
2601
+ // A button keeps the keyboard: its press gives the focus straight
2602
+ // back. Ending editing first only let the keyboard's inset go while
2603
+ // the finger was down, the button moved out from under it, and the
2604
+ // press was lost.
2605
+ if onButton { return }
2606
+ // Otherwise the keyboard goes; after a tap on nothing, for good (the
2607
+ // next render must not bring the last input back).
2608
+ if !onControl { craftScreenController?.forgetFocusedInput() }
2286
2609
  window?.endEditing(true)
2287
2610
  }
2288
2611
 
@@ -2323,7 +2646,7 @@ private final class CraftNativeSvgView: UIView {
2323
2646
  let props = drawing["props"] as? [String: Any] ?? [:]
2324
2647
  guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
2325
2648
  let n = box.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
2326
- guard n.count == 4, n[2] > 0, n[3] > 0 else { return nil }
2649
+ guard n.count == 4, n.allSatisfy(\.isFinite), n[2] > 0, n[3] > 0 else { return nil }
2327
2650
  return CGRect(x: n[0], y: n[1], width: n[2], height: n[3])
2328
2651
  }
2329
2652
 
@@ -2344,21 +2667,29 @@ private final class CraftNativeSvgView: UIView {
2344
2667
  var linecap: String?
2345
2668
  var linejoin: String?
2346
2669
  var dashes: [CGFloat]?
2670
+ var vectorEffect: String?
2347
2671
 
2348
2672
  func merged(_ node: [String: Any]) -> Paint {
2349
2673
  var next = self
2350
2674
  let props = node["props"] as? [String: Any] ?? [:]
2351
2675
  let style = node["style"] as? [String: Any] ?? [:]
2676
+ func value(_ key: String) -> Any? { style[key] ?? props[key] }
2677
+ func finite(_ raw: Any?) -> CGFloat? {
2678
+ guard let number = raw as? NSNumber else { return nil }
2679
+ let value = CGFloat(truncating: number)
2680
+ return value.isFinite ? value : nil
2681
+ }
2352
2682
  if let color = style["color"] as? String { next.color = color }
2353
2683
  if let fill = style["fill"] as? String ?? props["fill"] as? String { next.fill = fill }
2354
2684
  if let stroke = style["stroke"] as? String ?? props["stroke"] as? String { next.stroke = stroke }
2355
- if let width = (style["strokeWidth"] as? NSNumber ?? props["strokeWidth"] as? NSNumber) { next.strokeWidth = CGFloat(truncating: width) }
2685
+ if let width = finite(style["strokeWidth"] ?? props["strokeWidth"]) { next.strokeWidth = width }
2356
2686
  // Computed style wins over the component prop, matching Android
2357
2687
  // and the browser's cascade when both provide opacity.
2358
- if let opacity = style["opacity"] as? NSNumber ?? props["opacity"] as? NSNumber { next.opacity *= CGFloat(truncating: opacity) }
2359
- if let cap = props["strokeLinecap"] as? String { next.linecap = cap }
2360
- if let join = props["strokeLinejoin"] as? String { next.linejoin = join }
2361
- if let dashes = props["strokeDasharray"] as? [Any] { next.dashes = dashes.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } } }
2688
+ if let opacity = finite(style["opacity"] ?? props["opacity"]) { next.opacity *= opacity }
2689
+ if let cap = value("strokeLinecap") as? String { next.linecap = cap }
2690
+ if let join = value("strokeLinejoin") as? String { next.linejoin = join }
2691
+ if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
2692
+ if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
2362
2693
  return next
2363
2694
  }
2364
2695
 
@@ -2425,7 +2756,7 @@ private final class CraftNativeSvgView: UIView {
2425
2756
  // `vector-effect="non-scaling-stroke"`: the stroke is in the
2426
2757
  // view's points, so a stretched chart's line keeps its width
2427
2758
  // and a round cap stays a round dot.
2428
- let nonScaling = (shape["props"] as? [String: Any])?["vectorEffect"] as? String == "non-scaling-stroke"
2759
+ let nonScaling = own.vectorEffect == "non-scaling-stroke"
2429
2760
  let line: UIBezierPath
2430
2761
  if nonScaling, let copy = path.copy() as? UIBezierPath {
2431
2762
  copy.apply(viewTransform)
@@ -2452,14 +2783,14 @@ private final class CraftNativeSvgView: UIView {
2452
2783
  guard !text.isEmpty else { return }
2453
2784
  let props = node["props"] as? [String: Any] ?? [:]
2454
2785
  let style = node["style"] as? [String: Any] ?? [:]
2455
- let size = CGFloat(truncating: props["fontSize"] as? NSNumber ?? style["fontSize"] as? NSNumber ?? 12)
2786
+ let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
2456
2787
  let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
2457
2788
  let string = NSAttributedString(string: text, attributes: [
2458
2789
  .font: UIFont.systemFont(ofSize: size, weight: weight),
2459
2790
  .foregroundColor: paint.resolve(paint.fill) ?? UIColor.label,
2460
2791
  ])
2461
- var x = CGFloat(truncating: props["x"] as? NSNumber ?? 0)
2462
- let y = CGFloat(truncating: props["y"] as? NSNumber ?? 0)
2792
+ var x = number(props["x"]) ?? 0
2793
+ let y = number(props["y"]) ?? 0
2463
2794
  switch props["textAnchor"] as? String {
2464
2795
  case "middle": x -= string.size().width / 2
2465
2796
  case "end": x -= string.size().width
@@ -2472,7 +2803,7 @@ private final class CraftNativeSvgView: UIView {
2472
2803
  static func path(_ value: Any?) -> UIBezierPath? {
2473
2804
  guard let list = value as? [Any] else { return nil }
2474
2805
  let n = list.compactMap { ($0 as? NSNumber).map { CGFloat(truncating: $0) } }
2475
- guard !n.isEmpty, n.count == list.count else { return nil }
2806
+ guard !n.isEmpty, n.count == list.count, n.allSatisfy(\.isFinite) else { return nil }
2476
2807
  let path = UIBezierPath()
2477
2808
  var i = 0
2478
2809
  func point(_ at: Int) -> CGPoint { CGPoint(x: n[at], y: n[at + 1]) }
@@ -2778,6 +3109,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2778
3109
  private var linkTargets: [ObjectIdentifier: String] = [:]
2779
3110
  private var renderedRoot: RenderedNode?
2780
3111
  private weak var lastFocusedInput: UIView?
3112
+
3113
+ /// The person put the keyboard away (Return, or a tap on nothing in a
3114
+ /// scroll view): the last input is let go. Kept, the next render or
3115
+ /// button press focused it again and the keyboard came straight back.
3116
+ func forgetFocusedInput(_ input: UIView? = nil) {
3117
+ if input == nil || lastFocusedInput === input { lastFocusedInput = nil }
3118
+ }
2781
3119
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
2782
3120
  private var flatListOwners: [String: String] = [:]
2783
3121
  /// A drawing's shapes are not views: a change to one redraws its `Svg`.
@@ -2909,6 +3247,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2909
3247
  override func viewDidAppear(_ animated: Bool) {
2910
3248
  super.viewDidAppear(animated)
2911
3249
  screenVisibilityChanged(true)
3250
+ // A navigation breadcrumb for the crash reporter: the trail a crash
3251
+ // report carries is the screens that led to it.
3252
+ if !isAppScript, let routeName, !routeName.isEmpty {
3253
+ CraftCrashReporter.shared.screenAppeared(routeName)
3254
+ }
3255
+ // onLayout reports views that are on screen: a pushed screen lays
3256
+ // out before it has a window, and nothing moved again once it had
3257
+ // one, so its rows never reported.
3258
+ emitLayoutEvents()
2912
3259
  }
2913
3260
 
2914
3261
  override func viewDidDisappear(_ animated: Bool) {
@@ -2983,9 +3330,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2983
3330
 
2984
3331
  override func viewDidLayoutSubviews() {
2985
3332
  super.viewDidLayoutSubviews()
3333
+ positionModals()
2986
3334
  emitLayoutEvents()
2987
3335
  }
2988
3336
 
3337
+ /// Modals cover the screen; their content starts below its bars (the
3338
+ /// status bar is all the safe area counts on a native screen, and a
3339
+ /// modal's first control sat under the navigation bar).
3340
+ private func positionModals() {
3341
+ let modals = view.subviews.compactMap { $0 as? CraftNativeModalView }
3342
+ guard !modals.isEmpty else { return }
3343
+ var insets = view.safeAreaInsets
3344
+ // The navigation bar floats over the screen without counting in its
3345
+ // safe area, and it is drawn above anything the screen adds.
3346
+ if let nav = navigationController, !nav.isNavigationBarHidden, nav.navigationBar.window != nil {
3347
+ let bar = nav.navigationBar.convert(nav.navigationBar.bounds, to: view)
3348
+ if bar.intersects(view.bounds) { insets.top = max(insets.top, bar.maxY) }
3349
+ }
3350
+ for modal in modals {
3351
+ if modal.frame != view.bounds { modal.frame = view.bounds }
3352
+ modal.contentInsets = insets
3353
+ }
3354
+ }
3355
+
2989
3356
  private static func safeJavaScriptString(_ value: JSValue?) -> String? {
2990
3357
  guard let value, !value.isUndefined, !value.isNull, value.isString else { return nil }
2991
3358
  return value.toString()
@@ -3004,7 +3371,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3004
3371
  ?? Self.safeJavaScriptString(exception)
3005
3372
  ?? "unknown"
3006
3373
  let stack = Self.safeJavaScriptString(exception?.objectForKeyedSubscript("stack")) ?? ""
3007
- CraftNativeConsole.write("error", category: self?.screenName(in: context) ?? "screen", message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
3374
+ let screen = self?.screenName(in: context) ?? "screen"
3375
+ CraftNativeConsole.write("error", category: screen, message: stack.isEmpty || stack == "undefined" ? "Uncaught \(message)" : "Uncaught \(message)\n\(stack)")
3376
+ // To the app's crash reporter too (bughq, when configured): an
3377
+ // uncaught error in a shipped screen is otherwise only in a log
3378
+ // nobody reads on someone else's phone.
3379
+ CraftCrashReporter.shared.captureJavaScript(
3380
+ name: Self.safeJavaScriptString(exception?.objectForKeyedSubscript("name")),
3381
+ message: message,
3382
+ stack: stack,
3383
+ route: screen,
3384
+ isAppScript: self?.isAppScript ?? false
3385
+ )
3008
3386
  self?.hybridFailed(message)
3009
3387
  }
3010
3388
  let postMessage: @convention(block) (String) -> Void = { [weak self] json in
@@ -4175,6 +4553,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4175
4553
  guard let target = drawnTarget(id) else { return false }
4176
4554
  guard applied.insert(target).inserted else { continue }
4177
4555
  guard let document = mutationDocument.node(target), let previous = renderedNode(target) else { return false }
4556
+ // A node that needs a new view (a TextInput turned multiline) is
4557
+ // its parent's to swap in: a targeted update only redraws in
4558
+ // place, and the new view was made and never attached.
4559
+ guard canReuseView(document, previous: previous) else { return false }
4178
4560
  let next = reconcile(document, identity: previous.identity, path: target, previous: previous)
4179
4561
  // A style patch is applied in place, layout included: the parent
4180
4562
  // reads the node's new layout style on its next pass.
@@ -4232,6 +4614,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4232
4614
  var rootStyle = next.style
4233
4615
  if rootStyle["flex"] == nil, rootStyle["height"] == nil { rootStyle["flex"] = 1 }
4234
4616
  rootStack.setLayoutStyle(rootStyle, for: next.view)
4617
+ rootStack.setViewportChild(next.view, fillsHeight: rootStyle["height"] == nil)
4235
4618
  rootStack.invalidateMeasurements()
4236
4619
  renderedRoot = next
4237
4620
  rootIsSafeAreaView = next.type == "SafeAreaView"
@@ -4246,6 +4629,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4246
4629
  if let root = renderedRoot { detach(root, from: rootStack) }
4247
4630
  renderedRoot = nil
4248
4631
  rootIsSafeAreaView = false
4632
+ rootStack.setViewportChild(nil, fillsHeight: false)
4249
4633
  rootTopToSafeArea?.isActive = true
4250
4634
  rootTopToView?.isActive = false
4251
4635
  rootBottomToSafeArea?.isActive = true
@@ -4268,6 +4652,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4268
4652
  return nil
4269
4653
  }
4270
4654
 
4655
+ /// Whether `previous`'s view can draw `node`: the same type, and for a
4656
+ /// TextInput the same kind of field (multiline is a UITextView).
4657
+ private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
4658
+ let type = canonicalType(node["type"] as? String ?? "View")
4659
+ guard previous.type == type else { return false }
4660
+ guard type == "TextInput" else { return true }
4661
+ let props = node["props"] as? [String: Any] ?? [:]
4662
+ return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
4663
+ }
4664
+
4271
4665
  private func restoreFocus(_ focused: UIView?) {
4272
4666
  guard let focused, focused.window != nil, !focused.isFirstResponder else { return }
4273
4667
  _ = focused.becomeFirstResponder()
@@ -4280,9 +4674,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4280
4674
  let events = node["events"] as? [String: String] ?? [:]
4281
4675
  let children = node["children"] as? [Any] ?? []
4282
4676
  let current: RenderedNode
4283
- let wantsMultilineTextView = type == "TextInput" && props["multiline"] as? Bool == true
4284
- let canReuseInput = type != "TextInput" || ((previous?.view is UITextView) == wantsMultilineTextView)
4285
- if let previous = previous, previous.identity == identity, previous.type == type, canReuseInput {
4677
+ if let previous = previous, previous.identity == identity, canReuseView(node, previous: previous) {
4286
4678
  current = previous
4287
4679
  } else {
4288
4680
  current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
@@ -4500,6 +4892,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4500
4892
  let modalID = ObjectIdentifier(modal)
4501
4893
  let wasVisible = modalVisibility[modalID]
4502
4894
  modalVisibility[modalID] = visible
4895
+ if visible, wasVisible != true { positionModals() }
4503
4896
  modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
4504
4897
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
4505
4898
  { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
@@ -4537,10 +4930,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4537
4930
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: toggle)
4538
4931
  case "Slider":
4539
4932
  let slider = result as! UISlider
4540
- slider.minimumValue = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
4541
- slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, (props["maximumValue"] as? NSNumber)?.floatValue ?? 1)
4542
- let value = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
4543
- if let step = (props["step"] as? NSNumber)?.floatValue, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
4933
+ let minimum = (props["minimumValue"] as? NSNumber)?.floatValue ?? 0
4934
+ slider.minimumValue = minimum.isFinite ? minimum : 0
4935
+ let maximum = (props["maximumValue"] as? NSNumber)?.floatValue ?? 1
4936
+ slider.maximumValue = max(slider.minimumValue + .leastNonzeroMagnitude, maximum.isFinite ? maximum : 1)
4937
+ let authoredValue = (props["value"] as? NSNumber)?.floatValue ?? slider.minimumValue
4938
+ let value = authoredValue.isFinite ? authoredValue : slider.minimumValue
4939
+ if let step = (props["step"] as? NSNumber)?.floatValue, step.isFinite, step > 0 { sliderSteps[ObjectIdentifier(slider)] = step }
4544
4940
  else { sliderSteps.removeValue(forKey: ObjectIdentifier(slider)) }
4545
4941
  slider.value = snappedSliderValue(value, for: slider)
4546
4942
  slider.minimumTrackTintColor = color(props["minimumTrackTintColor"])
@@ -4555,7 +4951,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4555
4951
  indicator.style = (requestedSize as? String) == "large" ? .large : .medium
4556
4952
  let scale: CGFloat
4557
4953
  if let numericSize = requestedSize as? NSNumber {
4558
- scale = max(0.5, CGFloat(numericSize.doubleValue / 20.0))
4954
+ let value = CGFloat(numericSize.doubleValue / 20.0)
4955
+ scale = value.isFinite ? max(0.5, value) : 1
4559
4956
  } else if (requestedSize as? String) == "small" {
4560
4957
  scale = 0.75
4561
4958
  } else {
@@ -4811,6 +5208,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4811
5208
  case "checkbox": return "Checkbox"
4812
5209
  case "textinput", "input": return "TextInput"
4813
5210
  case "picker", "select": return "Picker"
5211
+ case "datepicker", "date-picker", "date": return "DatePicker"
4814
5212
  case "modal": return "Modal"
4815
5213
  case "switch": return "Switch"
4816
5214
  case "slider": return "Slider"
@@ -4898,14 +5296,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4898
5296
  }
4899
5297
  for child in next where child.type == "Modal" {
4900
5298
  guard let modal = child.view as? CraftNativeModalView else { continue }
4901
- if modal.superview !== stack {
5299
+ // A modal covers the screen, wherever it is declared. Laid over
5300
+ // its parent instead, one declared in a scroll view covered the
5301
+ // whole content: its controls sat wherever the content had
5302
+ // scrolled to, under the bar or off screen, out of reach.
5303
+ if modal.superview !== view {
4902
5304
  modal.removeFromSuperview()
4903
- stack.addSubview(modal)
5305
+ view.addSubview(modal)
4904
5306
  }
4905
- modal.frame = stack.bounds
4906
5307
  modal.autoresizingMask = [.flexibleWidth, .flexibleHeight]
4907
- stack.bringSubviewToFront(modal)
5308
+ view.bringSubviewToFront(modal)
4908
5309
  }
5310
+ positionModals()
4909
5311
  parent.children = next
4910
5312
  addJustificationSpacers(to: stack, value: style["justifyContent"] as? String)
4911
5313
  }
@@ -4950,14 +5352,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4950
5352
  { [weak self] scrollView in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": self?.scrollEvent(scrollView) ?? [:]]) }
4951
5353
  }
4952
5354
 
5355
+ let threshold = max(
5356
+ 0,
5357
+ Double(number(props["onEndReachedThreshold"]) ?? number(props["threshold"]) ?? 0.1)
5358
+ )
4953
5359
  list.apply(
4954
5360
  nodes: children,
4955
5361
  horizontal: props["horizontal"] as? Bool == true,
4956
5362
  columns: (props["numColumns"] as? NSNumber)?.intValue ?? 1,
4957
5363
  inverted: props["inverted"] as? Bool == true,
4958
- endReachedThreshold: (props["onEndReachedThreshold"] as? NSNumber)?.doubleValue
4959
- ?? (props["threshold"] as? NSNumber)?.doubleValue
4960
- ?? 0.1,
5364
+ endReachedThreshold: threshold,
4961
5365
  renderItem: { [weak self] node, identity, _ in
4962
5366
  guard let self = self else { return UIView() }
4963
5367
  let previous = self.flatListRows[listKey]?[identity]
@@ -5023,7 +5427,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5023
5427
  }
5024
5428
  if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
5025
5429
  let id = ObjectIdentifier(node.view)
5026
- if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
5430
+ if let modal = node.view as? CraftNativeModalView {
5431
+ modal.resetForTeardown()
5432
+ // It sits on the screen, not in its parent: leaving with the
5433
+ // parent's subtree does not take it away.
5434
+ modal.removeFromSuperview()
5435
+ }
5027
5436
  handlers.removeValue(forKey: id)
5028
5437
  linkTargets.removeValue(forKey: id)
5029
5438
  pressInHandlers.removeValue(forKey: id)
@@ -5098,9 +5507,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5098
5507
  emitLayoutEvents(for: renderedRoot)
5099
5508
  }
5100
5509
 
5510
+ private var layoutEventsScheduled = false
5511
+
5512
+ /// A view inside the screen moved: report onLayout once the pass that
5513
+ /// moved it is over, however deep it was.
5514
+ fileprivate func scheduleLayoutEvents() {
5515
+ guard !layoutEventsScheduled, !layoutHandlers.isEmpty else { return }
5516
+ layoutEventsScheduled = true
5517
+ DispatchQueue.main.async { [weak self] in
5518
+ guard let self else { return }
5519
+ self.layoutEventsScheduled = false
5520
+ self.emitLayoutEvents()
5521
+ }
5522
+ }
5523
+
5101
5524
  private func emitLayoutEvents(for node: RenderedNode) {
5102
5525
  let id = ObjectIdentifier(node.view)
5103
- if let handler = layoutHandlers[id], node.view.window != nil {
5526
+ if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
5104
5527
  let frame = node.view.frame
5105
5528
  if lastLayoutFrames[id] != frame {
5106
5529
  lastLayoutFrames[id] = frame
@@ -5121,6 +5544,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5121
5544
  }
5122
5545
  }
5123
5546
 
5547
+ private func isEffectivelyVisible(_ view: UIView) -> Bool {
5548
+ guard view.window != nil, !view.isHidden else { return false }
5549
+ var ancestor = view.superview
5550
+ while let current = ancestor {
5551
+ if current.isHidden { return false }
5552
+ ancestor = current.superview
5553
+ }
5554
+ return true
5555
+ }
5556
+
5124
5557
  private func updateAuxiliaryHandler(
5125
5558
  _ handler: String?,
5126
5559
  in handlers: inout [ObjectIdentifier: String],
@@ -5150,7 +5583,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5150
5583
  }
5151
5584
  // A container keeps receiving touches without a handler of its own,
5152
5585
  // or every control inside it (and the screen root) would be dead.
5153
- if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5586
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5154
5587
  }
5155
5588
 
5156
5589
  private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
@@ -5159,7 +5592,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5159
5592
  if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
5160
5593
  guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
5161
5594
  if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
5162
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5595
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5163
5596
  return
5164
5597
  }
5165
5598
  if pressFeedbackRecognizers[id] == nil {
@@ -5167,17 +5600,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5167
5600
  recognizer.minimumPressDuration = 0
5168
5601
  recognizer.allowableMovement = 10
5169
5602
  recognizer.cancelsTouchesInView = false
5603
+ recognizer.delegate = CraftPressFeedbackDelegate.shared
5170
5604
  pressFeedbackRecognizers[id] = recognizer
5171
5605
  view.addGestureRecognizer(recognizer)
5172
5606
  }
5173
- if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
5607
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
5174
5608
  }
5175
5609
 
5176
5610
  private func updateLongPressHandler(_ handler: String?, for view: UIView) {
5177
5611
  let id = ObjectIdentifier(view)
5178
5612
  if let handler, !handler.isEmpty {
5179
5613
  longPressHandlers[id] = handler
5180
- if !(view is UIScrollView) { view.isUserInteractionEnabled = true }
5614
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = true }
5181
5615
  if longPressRecognizers[id] == nil {
5182
5616
  let recognizer = UILongPressGestureRecognizer(target: self, action: #selector(viewLongPressed(_:)))
5183
5617
  longPressRecognizers[id] = recognizer
@@ -5188,7 +5622,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5188
5622
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
5189
5623
  view.removeGestureRecognizer(recognizer)
5190
5624
  }
5191
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
5625
+ if !(view is UIScrollView || view is CraftNativeModalView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
5192
5626
  }
5193
5627
  }
5194
5628
 
@@ -5327,10 +5761,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5327
5761
  stack.alignItems = style["alignItems"] as? String ?? "stretch"
5328
5762
  stack.alignContent = style["alignContent"] as? String ?? "flex-start"
5329
5763
  stack.justifyContent = style["justifyContent"] as? String ?? "flex-start"
5330
- let gap = number(style["gap"]) ?? 0
5764
+ let gap = max(0, number(style["gap"]) ?? 0)
5331
5765
  stack.gap = gap
5332
- stack.rowGap = number(style["rowGap"]) ?? gap
5333
- stack.columnGap = number(style["columnGap"]) ?? gap
5766
+ stack.rowGap = max(0, number(style["rowGap"]) ?? gap)
5767
+ stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
5334
5768
  stack.grid = style["display"] as? String == "grid"
5335
5769
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
5336
5770
  stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
@@ -5405,14 +5839,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5405
5839
  }
5406
5840
 
5407
5841
  private func paddingInsets(_ style: [String: Any]) -> UIEdgeInsets {
5408
- let padding = number(style["padding"]) ?? 0
5409
- let horizontal = number(style["paddingHorizontal"]) ?? padding
5410
- let vertical = number(style["paddingVertical"]) ?? padding
5842
+ func nonNegative(_ value: Any?) -> CGFloat? {
5843
+ guard let value = number(value), value >= 0 else { return nil }
5844
+ return value
5845
+ }
5846
+ let padding = nonNegative(style["padding"]) ?? 0
5847
+ let horizontal = nonNegative(style["paddingHorizontal"]) ?? padding
5848
+ let vertical = nonNegative(style["paddingVertical"]) ?? padding
5411
5849
  return UIEdgeInsets(
5412
- top: number(style["paddingTop"]) ?? vertical,
5413
- left: number(style["paddingLeft"]) ?? horizontal,
5414
- bottom: number(style["paddingBottom"]) ?? vertical,
5415
- right: number(style["paddingRight"]) ?? horizontal
5850
+ top: nonNegative(style["paddingTop"]) ?? vertical,
5851
+ left: nonNegative(style["paddingLeft"]) ?? horizontal,
5852
+ bottom: nonNegative(style["paddingBottom"]) ?? vertical,
5853
+ right: nonNegative(style["paddingRight"]) ?? horizontal
5416
5854
  )
5417
5855
  }
5418
5856
 
@@ -5672,6 +6110,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5672
6110
  imageSources[id] = uri
5673
6111
  imageErrors.removeValue(forKey: id)
5674
6112
  view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
6113
+ imageDidChange(view)
5675
6114
  view.accessibilityValue = nil
5676
6115
  emitImageEvent(view, handler: imageLoadStartHandlers[id])
5677
6116
 
@@ -5683,6 +6122,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5683
6122
  return
5684
6123
  }
5685
6124
  view.image = imageForDisplay(image, view: view)
6125
+ imageDidChange(view)
5686
6126
  emitImageEvent(view, handler: imageLoadHandlers[id])
5687
6127
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
5688
6128
  return
@@ -5698,6 +6138,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5698
6138
  return
5699
6139
  }
5700
6140
  view.image = self.imageForDisplay(image, view: view)
6141
+ self.imageDidChange(view)
5701
6142
  self.emitImageEvent(view, handler: self.imageLoadHandlers[ObjectIdentifier(view)])
5702
6143
  self.emitImageEvent(view, handler: self.imageLoadEndHandlers[ObjectIdentifier(view)])
5703
6144
  }
@@ -5712,6 +6153,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5712
6153
  return
5713
6154
  }
5714
6155
  view.image = imageForDisplay(image, view: view)
6156
+ imageDidChange(view)
5715
6157
  emitImageEvent(view, handler: imageLoadHandlers[id])
5716
6158
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
5717
6159
  return
@@ -5736,6 +6178,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5736
6178
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
5737
6179
  imageErrors[ObjectIdentifier(view)] = message
5738
6180
  view.image = nil
6181
+ imageDidChange(view)
5739
6182
  view.accessibilityValue = message
5740
6183
  let id = ObjectIdentifier(view)
5741
6184
  emitImageEvent(view, handler: imageErrorHandlers[id], nativeEvent: ["error": ["message": message]])
@@ -5743,6 +6186,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5743
6186
  NSLog("[craft native] %@: %@", message, uri ?? "<missing>")
5744
6187
  }
5745
6188
 
6189
+ private func imageDidChange(_ view: UIImageView) {
6190
+ view.invalidateIntrinsicContentSize()
6191
+ CraftNativeFlexLayout.invalidateAncestors(of: view)
6192
+ }
6193
+
5746
6194
  private func imageForDisplay(_ image: UIImage, view: UIImageView) -> UIImage {
5747
6195
  image.withRenderingMode(tintedImages.contains(ObjectIdentifier(view)) ? .alwaysTemplate : .alwaysOriginal)
5748
6196
  }
@@ -5766,7 +6214,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5766
6214
  view.accessibilityValue = nil
5767
6215
  }
5768
6216
  let role = props["accessibilityRole"] as? String
5769
- view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "Switch", "Slider"].contains(type))
6217
+ view.isAccessibilityElement = role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
5770
6218
  var traits: UIAccessibilityTraits = []
5771
6219
  switch role ?? type.lowercased() {
5772
6220
  case "button", "checkbox": traits.insert(.button)
@@ -5789,6 +6237,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5789
6237
  if state["checked"] as? Bool == true { traits.insert(.selected) }
5790
6238
  }
5791
6239
  if let control = view as? UIControl, !control.isEnabled { traits.insert(.notEnabled) }
6240
+ // A disabled Pressable or touchable takes no touches; it says so too.
6241
+ if props["disabled"] as? Bool == true { traits.insert(.notEnabled) }
5792
6242
  if type == "Checkbox", let checkbox = view as? CraftNativeCheckboxButton {
5793
6243
  if checkbox.isSelected { traits.insert(.selected) }
5794
6244
  view.accessibilityValue = checkbox.indeterminate ? "mixed" : checkbox.isSelected ? "checked" : "unchecked"
@@ -5939,13 +6389,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5939
6389
  let value = snappedSliderValue(sender.value, for: sender)
5940
6390
  if sender.value != value { sender.value = value }
5941
6391
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
5942
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
6392
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value, step: sliderSteps[ObjectIdentifier(sender)])]])
5943
6393
  }
5944
6394
 
5945
- /// A slider's value as the script reads it: the shortest decimal that is
5946
- /// the same Float, so a step of 0.1 gives 0.8 and not 0.800000011920929.
5947
- static func sliderEventValue(_ value: Float) -> Double {
5948
- Double(value.description) ?? Double(value)
6395
+ /// A slider's value as the script reads it. UISlider keeps a Float, and a
6396
+ /// step of 0.1 counted up in Floats lands beside the decimal (0.8 read as
6397
+ /// 0.800000011920929, nine steps as 0.90000004), so the value is the
6398
+ /// shortest decimal for that Float, rounded to as many places as the step
6399
+ /// has.
6400
+ static func sliderEventValue(_ value: Float, step: Float? = nil) -> Double {
6401
+ let decimal = Double(value.description) ?? Double(value)
6402
+ guard let step, step > 0, step.isFinite else { return decimal }
6403
+ // "0.1" is one place, "0.25" two, "1.0" none; a step as small as
6404
+ // "1e-07" is held to six.
6405
+ let fraction = step.description.split(separator: ".").dropFirst().first
6406
+ let places = step.description.contains("e") ? 6 : min(6, fraction.map { $0 == "0" ? 0 : $0.count } ?? 0)
6407
+ let scale = pow(10, Double(places))
6408
+ return (decimal * scale).rounded() / scale
5949
6409
  }
5950
6410
 
5951
6411
  /// `@change` on a DatePicker: the value as `<input type="date|time|
@@ -6000,7 +6460,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6000
6460
  /// Countdown values use seconds when numeric, or `HH:mm[:ss]` text.
6001
6461
  static func countdownDuration(_ value: Any?) -> TimeInterval? {
6002
6462
  let maximum: TimeInterval = 23 * 3600 + 59 * 60 + 59
6003
- if let number = value as? NSNumber, !(value is Bool) { return min(maximum, max(0, number.doubleValue)) }
6463
+ if let number = value as? NSNumber, !(value is Bool) {
6464
+ let seconds = number.doubleValue
6465
+ guard seconds.isFinite else { return nil }
6466
+ return min(maximum, max(0, seconds))
6467
+ }
6004
6468
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
6005
6469
  if let seconds = Double(text), seconds.isFinite { return min(maximum, max(0, seconds)) }
6006
6470
  let parts = text.split(separator: ":")
@@ -6008,7 +6472,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6008
6472
  let hours = Double(parts[0]),
6009
6473
  let minutes = Double(parts[1]) else { return nil }
6010
6474
  let seconds = parts.count == 3 ? (Double(parts[2]) ?? -1) : 0
6011
- guard hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
6475
+ guard hours.isFinite, minutes.isFinite, seconds.isFinite,
6476
+ hours >= 0, minutes >= 0, minutes < 60, seconds >= 0, seconds < 60 else { return nil }
6012
6477
  return min(maximum, hours * 3600 + minutes * 60 + seconds)
6013
6478
  }
6014
6479
 
@@ -6016,7 +6481,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6016
6481
  /// time), `2026-10-12T07:30` (local), a full ISO 8601 string, or
6017
6482
  /// milliseconds since 1970. Nil for anything else.
6018
6483
  static func pickerDate(_ value: Any?) -> Date? {
6019
- if let number = value as? NSNumber, !(value is Bool) { return Date(timeIntervalSince1970: number.doubleValue / 1000) }
6484
+ if let number = value as? NSNumber, !(value is Bool) {
6485
+ let milliseconds = number.doubleValue
6486
+ guard milliseconds.isFinite else { return nil }
6487
+ return Date(timeIntervalSince1970: milliseconds / 1000)
6488
+ }
6020
6489
  guard let text = (value as? String)?.trimmingCharacters(in: .whitespaces), !text.isEmpty else { return nil }
6021
6490
  let local = DateFormatter()
6022
6491
  local.locale = Locale(identifier: "en_US_POSIX")
@@ -6042,7 +6511,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6042
6511
 
6043
6512
  @objc private func sliderFinished(_ sender: UISlider) {
6044
6513
  guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
6045
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
6514
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender), step: sliderSteps[ObjectIdentifier(sender)])]])
6046
6515
  }
6047
6516
 
6048
6517
  func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
@@ -6087,6 +6556,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6087
6556
  }
6088
6557
 
6089
6558
  @objc private func textSubmitted(_ sender: UITextField) {
6559
+ // Return put the keyboard away.
6560
+ forgetFocusedInput(sender)
6090
6561
  guard let handler = submitHandlers[ObjectIdentifier(sender)] else { return }
6091
6562
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["text": sender.text ?? ""]])
6092
6563
  }
@@ -6174,7 +6645,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6174
6645
  }
6175
6646
 
6176
6647
  private func number(_ value: Any?) -> CGFloat? {
6177
- (value as? NSNumber).map { CGFloat(truncating: $0) }
6648
+ guard let number = value as? NSNumber else { return nil }
6649
+ let value = CGFloat(truncating: number)
6650
+ return value.isFinite ? value : nil
6178
6651
  }
6179
6652
 
6180
6653
  private func keyboardType(_ value: Any?) -> UIKeyboardType {