@craft-native/ios 0.0.247 → 0.0.248

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.
@@ -59,6 +59,19 @@ private func craftNativeGridColumnCount(_ value: Any?) -> Int {
59
59
  return max(1, tracks.count)
60
60
  }
61
61
 
62
+ private func craftNativeGridRowCount(_ value: Any?) -> Int? {
63
+ if let number = craftNativeNumber(value) { return max(1, number.intValue) }
64
+ guard let text = value as? String else { return nil }
65
+ let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines)
66
+ guard !trimmed.isEmpty else { return nil }
67
+ if trimmed.hasPrefix("repeat(") {
68
+ let digits = trimmed.dropFirst("repeat(".count).prefix { $0.isNumber }
69
+ if let count = Int(digits) { return max(1, count) }
70
+ }
71
+ let tracks = trimmed.split(whereSeparator: { $0 == " " || $0 == "\t" || $0 == "\n" })
72
+ return tracks.isEmpty ? nil : max(1, tracks.count)
73
+ }
74
+
62
75
  /// Optional bridge to the shared Zig flex rule. Native hosts retain their
63
76
  /// local implementation when no Craft runtime is linked (the normal SDK
64
77
  /// fallback), while runtime-backed apps get one cross-platform calculation.
@@ -240,12 +253,56 @@ private enum CraftNativeTreeLayoutBridge {
240
253
  var gridAutoRows: Float = .nan
241
254
  }
242
255
 
256
+ private struct StyleInputV5 {
257
+ var direction: UInt32 = 0
258
+ var width: Float = .nan
259
+ var height: Float = .nan
260
+ var intrinsicWidth: Float = .nan
261
+ var intrinsicHeight: Float = .nan
262
+ var baseline: Float = .nan
263
+ var aspectRatio: Float = .nan
264
+ var flexBasis: Float = .nan
265
+ var flexGrow: Float = 0
266
+ var flexShrink: Float = 0
267
+ var minWidth: Float = .nan
268
+ var maxWidth: Float = .nan
269
+ var minHeight: Float = .nan
270
+ var maxHeight: Float = .nan
271
+ var gap: Float = 0
272
+ var justifyContent: UInt32 = 0
273
+ var alignItems: UInt32 = 3
274
+ var alignSelf: UInt32 = 0
275
+ var flexWrap: UInt32 = 0
276
+ var alignContent: UInt32 = 5
277
+ var marginTop: Float = 0
278
+ var marginRight: Float = 0
279
+ var marginBottom: Float = 0
280
+ var marginLeft: Float = 0
281
+ var paddingTop: Float = 0
282
+ var paddingRight: Float = 0
283
+ var paddingBottom: Float = 0
284
+ var paddingLeft: Float = 0
285
+ var grid: UInt32 = 0
286
+ var gridColumns: UInt32 = 1
287
+ var rowGap: Float = 0
288
+ var columnGap: Float = 0
289
+ var reverse: UInt32 = 0
290
+ var gridAutoRows: Float = .nan
291
+ var gridRows: UInt32 = 0
292
+ }
293
+
243
294
  private struct NodeInputV4 {
244
295
  var style: StyleInputV4
245
296
  var firstChild: UInt32
246
297
  var childCount: UInt32
247
298
  }
248
299
 
300
+ private struct NodeInputV5 {
301
+ var style: StyleInputV5
302
+ var firstChild: UInt32
303
+ var childCount: UInt32
304
+ }
305
+
249
306
  fileprivate struct Frame {
250
307
  var x: Float
251
308
  var y: Float
@@ -269,6 +326,7 @@ private enum CraftNativeTreeLayoutBridge {
269
326
  let padding: UIEdgeInsets
270
327
  let grid: Bool
271
328
  let gridColumns: Int
329
+ let gridRows: Int?
272
330
  let gridAutoRows: CraftNativeLength?
273
331
  let reverse: Bool
274
332
  let wrapReverse: Bool
@@ -309,6 +367,11 @@ private enum CraftNativeTreeLayoutBridge {
309
367
  return unsafeBitCast(symbol, to: FunctionV2.self)
310
368
  }()
311
369
 
370
+ private static let functionV5: FunctionV2? = {
371
+ guard let symbol = dlsym(UnsafeMutableRawPointer(bitPattern: -2), "craft_native_layout_tree_v5") else { return nil }
372
+ return unsafeBitCast(symbol, to: FunctionV2.self)
373
+ }()
374
+
312
375
  private static func finite(_ value: CGFloat?) -> Float {
313
376
  guard let value, value.isFinite else { return .nan }
314
377
  return Float(value)
@@ -421,6 +484,9 @@ private enum CraftNativeTreeLayoutBridge {
421
484
 
422
485
  fileprivate static func treeFrames(root: TreeNode, width: CGFloat, height: CGFloat) -> TreeResult? {
423
486
  guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
487
+ if containsGridRows(root) {
488
+ return treeFramesV5(root: root, width: width, height: height, function: functionV5)
489
+ }
424
490
  if containsGridAutoRows(root) {
425
491
  return treeFramesV4(root: root, width: width, height: height, function: functionV4)
426
492
  }
@@ -529,6 +595,10 @@ private enum CraftNativeTreeLayoutBridge {
529
595
  node.gridAutoRows != nil || node.children.contains(where: containsGridAutoRows)
530
596
  }
531
597
 
598
+ private static func containsGridRows(_ node: TreeNode) -> Bool {
599
+ node.gridRows != nil || node.children.contains(where: containsGridRows)
600
+ }
601
+
532
602
  private static func treeFramesV2(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
533
603
  guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
534
604
  var nodes: [TreeNode] = []
@@ -712,6 +782,100 @@ private enum CraftNativeTreeLayoutBridge {
712
782
  }) else { return nil }
713
783
  return TreeResult(nodes: nodes, parents: parents, frames: output)
714
784
  }
785
+
786
+ /// v5 appends the explicit grid-template-rows count to v4's payload.
787
+ /// Older runtimes continue through v4 when no explicit rows are present.
788
+ private static func treeFramesV5(root: TreeNode, width: CGFloat, height: CGFloat, function: FunctionV2?) -> TreeResult? {
789
+ guard let function, width.isFinite, height.isFinite, width >= 0, height >= 0, !root.children.isEmpty else { return nil }
790
+ var nodes: [TreeNode] = []
791
+ var parents: [Int] = []
792
+ func append(_ node: TreeNode, parent: Int) {
793
+ let index = nodes.count
794
+ nodes.append(node)
795
+ parents.append(parent)
796
+ node.children.forEach { append($0, parent: index) }
797
+ }
798
+ append(root, parent: -1)
799
+
800
+ func put(_ input: inout StyleInputV5, _ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
801
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
802
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
803
+ let mainSize = node.direction == .horizontal ? contentWidth : contentHeight
804
+ input.direction = node.direction == .horizontal ? 0 : 1
805
+ input.width = rootNode ? Float(width) : length(node.style.width, base: parentWidth)
806
+ input.height = rootNode ? Float(height) : length(node.style.height, base: parentHeight)
807
+ input.intrinsicWidth = rootNode ? .nan : Float(max(0, node.intrinsic.width))
808
+ input.intrinsicHeight = rootNode ? .nan : Float(max(0, node.intrinsic.height))
809
+ input.baseline = rootNode ? .nan : Float(max(0, node.baseline))
810
+ input.aspectRatio = finite(node.style.aspectRatio)
811
+ input.flexBasis = length(node.style.flexBasis, base: mainSize)
812
+ input.flexGrow = rootNode ? 0 : Float(max(0, node.style.flexGrow))
813
+ let defaultShrink: CGFloat = node.view is UIScrollView || (node.view is UILabel && parentDirection == .horizontal) ? 1 : 0
814
+ input.flexShrink = rootNode ? 0 : Float(max(0, node.style.flexShrink ?? defaultShrink))
815
+ input.minWidth = length(node.style.minWidth, base: contentWidth)
816
+ input.maxWidth = length(node.style.maxWidth, base: contentWidth)
817
+ input.minHeight = length(node.style.minHeight, base: contentHeight)
818
+ input.maxHeight = length(node.style.maxHeight, base: contentHeight)
819
+ input.gap = Float(max(0, node.gap))
820
+ input.justifyContent = justify(node.justifyContent)
821
+ input.alignItems = alignment(node.alignItems)
822
+ input.alignSelf = rootNode ? 0 : alignSelf(node.style.alignSelf)
823
+ input.flexWrap = node.wrap ? 1 : 0
824
+ input.alignContent = craftNativeAlignContentCode(node.alignContent)
825
+ input.marginTop = Float(max(0, node.style.marginTop))
826
+ input.marginRight = Float(max(0, node.style.marginRight))
827
+ input.marginBottom = Float(max(0, node.style.marginBottom))
828
+ input.marginLeft = Float(max(0, node.style.marginLeft))
829
+ input.paddingTop = Float(max(0, node.padding.top))
830
+ input.paddingRight = Float(max(0, node.padding.right))
831
+ input.paddingBottom = Float(max(0, node.padding.bottom))
832
+ input.paddingLeft = Float(max(0, node.padding.left))
833
+ input.grid = node.grid ? 1 : 0
834
+ input.gridColumns = UInt32(max(1, node.gridColumns))
835
+ input.rowGap = Float(max(0, node.rowGap))
836
+ input.columnGap = Float(max(0, node.columnGap))
837
+ input.reverse = (node.reverse ? 1 : 0) | (node.wrapReverse ? 2 : 0)
838
+ input.gridAutoRows = node.gridAutoRows.map { length($0, base: contentHeight) } ?? .nan
839
+ input.gridRows = UInt32(max(0, node.gridRows ?? 0))
840
+ }
841
+
842
+ var styles = [StyleInputV5](repeating: StyleInputV5(), count: nodes.count)
843
+ var metadata = [Float](repeating: 0, count: nodes.count * 2)
844
+ var cursor = 0
845
+ func write(_ node: TreeNode, parentWidth: CGFloat, parentHeight: CGFloat, rootNode: Bool, parentDirection: NSLayoutConstraint.Axis) {
846
+ let index = cursor
847
+ cursor += 1
848
+ put(&styles[index], node, parentWidth: parentWidth, parentHeight: parentHeight, rootNode: rootNode, parentDirection: parentDirection)
849
+ let contentWidth = max(0, (rootNode ? width : node.intrinsic.width) - node.padding.left - node.padding.right)
850
+ let contentHeight = max(0, (rootNode ? height : node.intrinsic.height) - node.padding.top - node.padding.bottom)
851
+ let firstChild = cursor
852
+ node.children.forEach { write($0, parentWidth: contentWidth, parentHeight: contentHeight, rootNode: false, parentDirection: node.direction) }
853
+ metadata[index * 2] = Float(firstChild)
854
+ metadata[index * 2 + 1] = Float(cursor - firstChild)
855
+ }
856
+ write(root, parentWidth: width, parentHeight: height, rootNode: true, parentDirection: root.direction)
857
+ let inputs = zip(styles, stride(from: 0, to: metadata.count, by: 2)).map { style, offset in
858
+ NodeInputV5(style: style, firstChild: UInt32(metadata[offset]), childCount: UInt32(metadata[offset + 1]))
859
+ }
860
+
861
+ var output = [Frame](repeating: Frame(x: 0, y: 0, width: 0, height: 0), count: nodes.count)
862
+ let frameCount = output.count
863
+ let status = inputs.withUnsafeBufferPointer { inputBuffer in
864
+ metadata.withUnsafeBufferPointer { metadataBuffer in
865
+ output.withUnsafeMutableBufferPointer { outputBuffer in
866
+ function(
867
+ Float(width), Float(height), UnsafeRawPointer(inputBuffer.baseAddress!), inputs.count,
868
+ UnsafeMutableRawPointer(outputBuffer.baseAddress!), frameCount
869
+ )
870
+ }
871
+ }
872
+ }
873
+ guard status == 0, output.allSatisfy({ frame in
874
+ frame.x.isFinite && frame.y.isFinite && frame.width.isFinite && frame.height.isFinite &&
875
+ frame.width >= 0 && frame.height >= 0
876
+ }) else { return nil }
877
+ return TreeResult(nodes: nodes, parents: parents, frames: output)
878
+ }
715
879
  }
716
880
 
717
881
  private enum CraftNativeFlexLineBridge {
@@ -1140,10 +1304,13 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1140
1304
  func stop() {
1141
1305
  detachPlayer()
1142
1306
  playerController.player = nil
1307
+ playerController.delegate = nil
1143
1308
  sourceURI = ""
1144
1309
  playIntent = nil
1145
1310
  userPaused = false
1146
1311
  ended = false
1312
+ handlers.removeAll()
1313
+ emit = nil
1147
1314
  if playerController.parent != nil {
1148
1315
  playerController.willMove(toParent: nil)
1149
1316
  playerController.view.removeFromSuperview()
@@ -1160,6 +1327,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1160
1327
  private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
1161
1328
  private var overlayColors: [ObjectIdentifier: UIColor] = [:]
1162
1329
  private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
1330
+ private var polygonsByID: [String: MKPolygon] = [:]
1331
+ private var polygonCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
1332
+ private var polygonFills: [ObjectIdentifier: UIColor] = [:]
1333
+ private var polygonStrokes: [ObjectIdentifier: UIColor] = [:]
1334
+ private var polygonWidths: [ObjectIdentifier: CGFloat] = [:]
1163
1335
  private var initialCameraKey: String?
1164
1336
  private var pendingFitKey: String?
1165
1337
  private var pendingRegion: MKCoordinateRegion?
@@ -1193,6 +1365,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1193
1365
  override init(frame: CGRect) {
1194
1366
  super.init(frame: frame)
1195
1367
  delegate = self
1368
+ refreshThemeDefaults()
1196
1369
  showsCompass = false
1197
1370
  showsScale = false
1198
1371
  isRotateEnabled = false
@@ -1204,9 +1377,29 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1204
1377
  required init?(coder: NSCoder) {
1205
1378
  super.init(coder: coder)
1206
1379
  delegate = self
1380
+ refreshThemeDefaults()
1381
+ }
1382
+
1383
+ func refreshThemeDefaults() {
1384
+ tileAttribution.textColor = .secondaryLabel
1385
+ tileAttribution.backgroundColor = UIColor.secondarySystemBackground.withAlphaComponent(0.85)
1386
+ // Marker images are rasterized from the resolved trait color. Refresh
1387
+ // mounted annotations as well as the utility chrome so a retained map
1388
+ // does not keep light-trait artwork after a dark-mode switch.
1389
+ Self.markerImages.removeAllObjects()
1390
+ for (id, annotation) in annotationsByID {
1391
+ if let view = view(for: annotation) { configureMarkerView(view, id: id) }
1392
+ }
1393
+ }
1394
+
1395
+ override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
1396
+ super.traitCollectionDidChange(previousTraitCollection)
1397
+ guard previousTraitCollection?.userInterfaceStyle != traitCollection.userInterfaceStyle else { return }
1398
+ refreshThemeDefaults()
1207
1399
  }
1208
1400
 
1209
1401
  func configure(props: [String: Any], children: [Any], markerHandler: @escaping (String) -> Void) {
1402
+ delegate = self
1210
1403
  onMarkerTap = markerHandler
1211
1404
  isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
1212
1405
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
@@ -1235,6 +1428,31 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1235
1428
  var nextHandlers: [String: String] = [:]
1236
1429
  var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
1237
1430
  var nextPolylineIDs: [String] = []
1431
+ var nextPolygons: [String: (coordinates: [CLLocationCoordinate2D], fill: UIColor, stroke: UIColor, width: CGFloat)] = [:]
1432
+ if let polylines = props["polylines"] as? [[String: Any]] {
1433
+ for overlay in polylines {
1434
+ let points = Self.coordinates(overlay["geometry"])
1435
+ guard let id = (overlay["id"] as? CustomStringConvertible)?.description,
1436
+ points.count > 1 else { continue }
1437
+ let authoredWidth = Self.numeric(overlay["strokeWidth"]) ?? 3
1438
+ let width = CGFloat(authoredWidth)
1439
+ guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
1440
+ nextPolylineIDs.append(id)
1441
+ nextPolylines[id] = (points, Self.color(overlay["strokeColor"] as? String), width)
1442
+ }
1443
+ }
1444
+ if let markers = props["markers"] as? [[String: Any]] {
1445
+ for overlay in markers {
1446
+ let points = Self.coordinates(overlay["geometry"])
1447
+ guard let id = (overlay["id"] as? CustomStringConvertible)?.description,
1448
+ let point = points.first else { continue }
1449
+ nextMarkers[id] = (point, overlay["title"] as? String)
1450
+ if let color = overlay["pinColor"] as? String { nextPinColors[id] = Self.color(color) }
1451
+ if let bearing = Self.numeric(overlay["bearing"]), bearing.isFinite {
1452
+ nextBearings[id] = bearing.truncatingRemainder(dividingBy: 360)
1453
+ }
1454
+ }
1455
+ }
1238
1456
  for (index, child) in children.enumerated() {
1239
1457
  guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
1240
1458
  let type = rawType.lowercased()
@@ -1262,6 +1480,14 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1262
1480
  guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
1263
1481
  if nextPolylines[id] == nil { nextPolylineIDs.append(id) }
1264
1482
  nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), width)
1483
+ } else if type == "mappolygon" || type == "polygon" {
1484
+ guard points.count > 2 else { continue }
1485
+ let authoredWidth = Self.numeric(childProps["strokeWidth"]) ?? 2
1486
+ let width = CGFloat(authoredWidth)
1487
+ guard authoredWidth.isFinite, width.isFinite, width > 0 else { continue }
1488
+ let fill = Self.color(childProps["fillColor"] as? String).withAlphaComponent(0.28)
1489
+ let stroke = Self.color(childProps["strokeColor"] as? String)
1490
+ nextPolygons[id] = (points, fill, stroke, width)
1265
1491
  }
1266
1492
  }
1267
1493
 
@@ -1314,6 +1540,39 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1314
1540
  overlayWidths[ObjectIdentifier(overlay)] = value.width
1315
1541
  addOverlay(overlay)
1316
1542
  }
1543
+ let stalePolygons = polygonsByID.keys.filter { nextPolygons[$0] == nil }
1544
+ for id in stalePolygons {
1545
+ if let polygon = polygonsByID.removeValue(forKey: id) {
1546
+ removeOverlay(polygon)
1547
+ polygonFills.removeValue(forKey: ObjectIdentifier(polygon))
1548
+ polygonStrokes.removeValue(forKey: ObjectIdentifier(polygon))
1549
+ polygonWidths.removeValue(forKey: ObjectIdentifier(polygon))
1550
+ }
1551
+ polygonCoordinatesByID.removeValue(forKey: id)
1552
+ }
1553
+ for (id, value) in nextPolygons {
1554
+ if let polygon = polygonsByID[id],
1555
+ let coordinates = polygonCoordinatesByID[id],
1556
+ Self.coordinatesEqual(coordinates, value.coordinates),
1557
+ polygonFills[ObjectIdentifier(polygon)]?.isEqual(value.fill) == true,
1558
+ polygonStrokes[ObjectIdentifier(polygon)]?.isEqual(value.stroke) == true,
1559
+ polygonWidths[ObjectIdentifier(polygon)] == value.width {
1560
+ continue
1561
+ }
1562
+ if let previous = polygonsByID.removeValue(forKey: id) {
1563
+ removeOverlay(previous)
1564
+ polygonFills.removeValue(forKey: ObjectIdentifier(previous))
1565
+ polygonStrokes.removeValue(forKey: ObjectIdentifier(previous))
1566
+ polygonWidths.removeValue(forKey: ObjectIdentifier(previous))
1567
+ }
1568
+ let polygon = MKPolygon(coordinates: value.coordinates, count: value.coordinates.count)
1569
+ polygonsByID[id] = polygon
1570
+ polygonCoordinatesByID[id] = value.coordinates
1571
+ polygonFills[ObjectIdentifier(polygon)] = value.fill
1572
+ polygonStrokes[ObjectIdentifier(polygon)] = value.stroke
1573
+ polygonWidths[ObjectIdentifier(polygon)] = value.width
1574
+ addOverlay(polygon)
1575
+ }
1317
1576
  // Keep authored casing/foreground order when an earlier line changes
1318
1577
  // or retained children move. Exchanging native overlays preserves the
1319
1578
  // unchanged objects, tile levels and current camera.
@@ -1349,7 +1608,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1349
1608
  initialCameraKey = key
1350
1609
  }
1351
1610
  } else if props["fitToBounds"] as? Bool == true {
1352
- let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates })):\(edgePadding)"
1611
+ let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }, polygons: nextPolygons.mapValues { $0.coordinates })):\(edgePadding)"
1353
1612
  if initialCameraKey != key {
1354
1613
  // A native screen can reconcile before UIKit assigns the map
1355
1614
  // its final safe-area-constrained bounds. Keep the fit
@@ -1368,6 +1627,39 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1368
1627
  applyCameraOrientation()
1369
1628
  }
1370
1629
 
1630
+ /// Release callbacks and native overlays before a map leaves a screen or
1631
+ /// is recycled for another row. `configure` restores the delegate when
1632
+ /// the view is adopted again.
1633
+ func destroy() {
1634
+ delegate = nil
1635
+ onMarkerTap = nil
1636
+ onMapPress = nil
1637
+ onRegionChangeComplete = nil
1638
+ markerHandlers.removeAll()
1639
+ markerBearings.removeAll()
1640
+ markerPinColors.removeAll()
1641
+ removeAnnotations(Array(annotationsByID.values))
1642
+ annotationsByID.removeAll()
1643
+ removeOverlays(Array(overlaysByID.values))
1644
+ overlaysByID.removeAll()
1645
+ overlayCoordinatesByID.removeAll()
1646
+ overlayColors.removeAll()
1647
+ overlayWidths.removeAll()
1648
+ removeOverlays(Array(polygonsByID.values))
1649
+ polygonsByID.removeAll()
1650
+ polygonCoordinatesByID.removeAll()
1651
+ polygonFills.removeAll()
1652
+ polygonStrokes.removeAll()
1653
+ polygonWidths.removeAll()
1654
+ if let tileOverlay {
1655
+ removeOverlay(tileOverlay)
1656
+ self.tileOverlay = nil
1657
+ }
1658
+ tileTemplate = nil
1659
+ tileAttribution.text = nil
1660
+ tileAttribution.isHidden = true
1661
+ }
1662
+
1371
1663
  private func configureTiles(_ options: [String: Any]?) {
1372
1664
  let template = options?["urlTemplate"] as? String
1373
1665
  let credit = options?["attribution"] as? String ?? ""
@@ -1503,6 +1795,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1503
1795
  guard bounds.width > 0, bounds.height > 0 else { return false }
1504
1796
  var rect = MKMapRect.null
1505
1797
  for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
1798
+ for polygon in polygonsByID.values { rect = rect.union(polygon.boundingMapRect) }
1506
1799
  for annotation in annotationsByID.values {
1507
1800
  let point = MKMapPoint(annotation.coordinate)
1508
1801
  rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
@@ -1543,6 +1836,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1543
1836
 
1544
1837
  func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
1545
1838
  if let tiles = overlay as? MKTileOverlay { return MKTileOverlayRenderer(tileOverlay: tiles) }
1839
+ if let polygon = overlay as? MKPolygon {
1840
+ let renderer = MKPolygonRenderer(polygon: polygon)
1841
+ renderer.fillColor = polygonFills[ObjectIdentifier(polygon)] ?? UIColor.systemBlue.withAlphaComponent(0.28)
1842
+ renderer.strokeColor = polygonStrokes[ObjectIdentifier(polygon)] ?? .systemBlue
1843
+ renderer.lineWidth = polygonWidths[ObjectIdentifier(polygon)] ?? 2
1844
+ return renderer
1845
+ }
1546
1846
  let renderer = MKPolylineRenderer(overlay: overlay)
1547
1847
  renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
1548
1848
  renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
@@ -1575,7 +1875,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1575
1875
 
1576
1876
  private static func overlayKey(
1577
1877
  markers: [String: CLLocationCoordinate2D],
1578
- polylines: [String: [CLLocationCoordinate2D]]
1878
+ polylines: [String: [CLLocationCoordinate2D]],
1879
+ polygons: [String: [CLLocationCoordinate2D]]
1579
1880
  ) -> String {
1580
1881
  let markerKey = markers.keys.sorted().compactMap { id in
1581
1882
  guard let point = markers[id] else { return nil }
@@ -1585,7 +1886,11 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1585
1886
  guard let points = polylines[id] else { return nil }
1586
1887
  return "\(id):" + points.map { "\($0.latitude),\($0.longitude)" }.joined(separator: ";")
1587
1888
  }.joined(separator: "|")
1588
- return "m[\(markerKey)]p[\(polylineKey)]"
1889
+ let polygonKey = polygons.keys.sorted().compactMap { id in
1890
+ guard let points = polygons[id] else { return nil }
1891
+ return "\(id):" + points.map { "\($0.latitude),\($0.longitude)" }.joined(separator: ";")
1892
+ }.joined(separator: "|")
1893
+ return "m[\(markerKey)]p[\(polylineKey)]g[\(polygonKey)]"
1589
1894
  }
1590
1895
 
1591
1896
  private static func region(_ value: Any?) -> MKCoordinateRegion? {
@@ -1615,6 +1920,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1615
1920
 
1616
1921
  private static func color(_ value: String?) -> UIColor {
1617
1922
  switch value?.lowercased() {
1923
+ case "label": return .label
1924
+ case "secondarylabel", "secondary-label": return .secondaryLabel
1618
1925
  case "red": return .systemRed
1619
1926
  case "green": return .systemGreen
1620
1927
  case "orange": return .systemOrange
@@ -1726,6 +2033,7 @@ private struct CraftNativeLayoutStyle {
1726
2033
  let marginLeft: CGFloat
1727
2034
  let display: String
1728
2035
  let gridColumns: Int
2036
+ let gridRows: Int?
1729
2037
  let gridAutoRows: CraftNativeLength?
1730
2038
 
1731
2039
  static let empty = CraftNativeLayoutStyle([:])
@@ -1767,6 +2075,7 @@ private struct CraftNativeLayoutStyle {
1767
2075
  marginLeft = number(raw["marginLeft"]) ?? horizontalMargin
1768
2076
  display = raw["display"] as? String ?? "flex"
1769
2077
  gridColumns = craftNativeGridColumnCount(raw["gridTemplateColumns"] ?? raw["gridColumns"])
2078
+ gridRows = craftNativeGridRowCount(raw["gridTemplateRows"])
1770
2079
  gridAutoRows = CraftNativeLength(raw["gridAutoRows"])
1771
2080
  }
1772
2081
  }
@@ -2276,6 +2585,7 @@ private class CraftNativeFlowView: UIStackView {
2276
2585
  var columnGap: CGFloat? { didSet { if columnGap != oldValue { invalidateMeasurements() } } }
2277
2586
  var grid = false { didSet { if grid != oldValue { invalidateMeasurements() } } }
2278
2587
  var gridColumns = 1 { didSet { if gridColumns != oldValue { invalidateMeasurements() } } }
2588
+ var gridRows: Int? { didSet { if gridRows != oldValue { invalidateMeasurements() } } }
2279
2589
  var gridAutoRows: CraftNativeLength? { didSet { if gridAutoRows != oldValue { invalidateMeasurements() } } }
2280
2590
  var reverse = false { didSet { if reverse != oldValue { invalidateMeasurements() } } }
2281
2591
  var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
@@ -2450,6 +2760,11 @@ private class CraftNativeFlowView: UIStackView {
2450
2760
 
2451
2761
  func removeLayoutStyle(for view: UIView) {
2452
2762
  view.craftLayoutStyle = .empty
2763
+ // Detaching a retained child must invalidate the parent's cached
2764
+ // intrinsic measurements too. The normal arranged-subview path does
2765
+ // this after removal, but callers that clear a style before deciding
2766
+ // whether to detach must not leave the old dimensions memoized.
2767
+ invalidateMeasurements()
2453
2768
  }
2454
2769
 
2455
2770
  /// Forgets measured sizes and lays out again on the next pass.
@@ -2622,6 +2937,7 @@ private class CraftNativeFlowView: UIStackView {
2622
2937
  padding: nested?.effectivePadding ?? .zero,
2623
2938
  grid: nested?.grid ?? false,
2624
2939
  gridColumns: nested?.gridColumns ?? 1,
2940
+ gridRows: nested?.gridRows,
2625
2941
  gridAutoRows: nested?.gridAutoRows,
2626
2942
  reverse: nested?.reverse ?? false,
2627
2943
  wrapReverse: nested?.wrapReverse ?? false,
@@ -2645,6 +2961,7 @@ private class CraftNativeFlowView: UIStackView {
2645
2961
  padding: effectivePadding,
2646
2962
  grid: grid,
2647
2963
  gridColumns: gridColumns,
2964
+ gridRows: gridRows,
2648
2965
  gridAutoRows: gridAutoRows,
2649
2966
  reverse: reverse,
2650
2967
  wrapReverse: wrapReverse,
@@ -2677,6 +2994,7 @@ private class CraftNativeFlowView: UIStackView {
2677
2994
  padding: nested?.effectivePadding ?? .zero,
2678
2995
  grid: nested?.grid ?? false,
2679
2996
  gridColumns: nested?.gridColumns ?? 1,
2997
+ gridRows: nested?.gridRows,
2680
2998
  gridAutoRows: nested?.gridAutoRows,
2681
2999
  reverse: nested?.reverse ?? false,
2682
3000
  wrapReverse: nested?.wrapReverse ?? false,
@@ -2700,6 +3018,7 @@ private class CraftNativeFlowView: UIStackView {
2700
3018
  padding: effectivePadding,
2701
3019
  grid: grid,
2702
3020
  gridColumns: gridColumns,
3021
+ gridRows: gridRows,
2703
3022
  gridAutoRows: gridAutoRows,
2704
3023
  reverse: reverse,
2705
3024
  wrapReverse: wrapReverse,
@@ -3249,10 +3568,17 @@ private class CraftNativeFlowView: UIStackView {
3249
3568
  rowHeights[row] = max(rowHeights[row], size.height + style.marginTop + style.marginBottom)
3250
3569
  }
3251
3570
  let width = innerWidth ?? (CGFloat(columns) * cellWidth + gaps)
3571
+ if let explicitRows = gridRows, let innerHeight {
3572
+ let count = max(explicitRows, rowHeights.count)
3573
+ while rowHeights.count < count { rowHeights.append(0) }
3574
+ let available = max(0, innerHeight - CGFloat(max(0, count - 1)) * verticalGap)
3575
+ let equal = available / CGFloat(max(1, count))
3576
+ rowHeights = rowHeights.map { max($0, equal) }
3577
+ }
3252
3578
  let height = rowHeights.reduce(0, +) + CGFloat(max(0, rowHeights.count - 1)) * verticalGap
3253
3579
  // Older runtimes return nil for the optional symbol; the local layout
3254
3580
  // below remains the compatibility path in that case.
3255
- let sharedFrames = CraftNativeGridBridge.frames(
3581
+ let sharedFrames = gridRows == nil ? CraftNativeGridBridge.frames(
3256
3582
  availableWidth: innerWidth ?? width,
3257
3583
  availableHeight: innerHeight ?? height,
3258
3584
  columns: columns,
@@ -3280,7 +3606,7 @@ private class CraftNativeFlowView: UIStackView {
3280
3606
  },
3281
3607
  autoRowHeight: gridAutoRows.map { $0.resolve(innerHeight) ?? 0 },
3282
3608
  alignContent: craftNativeAlignContentCode(alignContent)
3283
- )
3609
+ ) : nil
3284
3610
  if place {
3285
3611
  if let sharedFrames {
3286
3612
  for (index, child) in children.enumerated() {
@@ -3457,13 +3783,14 @@ private final class CraftNativeImageBackgroundView: UIImageView {
3457
3783
  }
3458
3784
  }
3459
3785
 
3460
- private final class CraftNativeModalView: UIView {
3786
+ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3461
3787
  let contentStack = CraftNativeFlowView()
3462
3788
  private let blocker = UIView()
3789
+ private let backdropTap = UITapGestureRecognizer()
3463
3790
  private var renderedVisible = false
3464
3791
  private var dismissSuppressedByRequest = false
3465
3792
  private var animationGeneration = 0
3466
- var onRequestClose: (() -> Void)?
3793
+ var onRequestClose: ((String) -> Void)?
3467
3794
 
3468
3795
  var transparent = false {
3469
3796
  didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
@@ -3474,7 +3801,10 @@ private final class CraftNativeModalView: UIView {
3474
3801
  isUserInteractionEnabled = true
3475
3802
  blocker.isUserInteractionEnabled = true
3476
3803
  blocker.backgroundColor = UIColor.black.withAlphaComponent(0.32)
3477
- blocker.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(blockerTapped)))
3804
+ backdropTap.addTarget(self, action: #selector(backdropTapped))
3805
+ backdropTap.cancelsTouchesInView = false
3806
+ backdropTap.delegate = self
3807
+ addGestureRecognizer(backdropTap)
3478
3808
  addSubview(blocker)
3479
3809
  contentStack.backgroundColor = .clear
3480
3810
  addSubview(contentStack)
@@ -3551,11 +3881,11 @@ private final class CraftNativeModalView: UIView {
3551
3881
  onRequestClose = nil
3552
3882
  }
3553
3883
 
3554
- func requestClose() {
3884
+ func requestClose(reason: String = "backdrop") {
3555
3885
  guard let onRequestClose else { return }
3556
3886
  dismissSuppressedByRequest = true
3557
3887
  isUserInteractionEnabled = false
3558
- onRequestClose()
3888
+ onRequestClose(reason)
3559
3889
  }
3560
3890
 
3561
3891
  func consumeDismissSuppression() -> Bool {
@@ -3566,7 +3896,35 @@ private final class CraftNativeModalView: UIView {
3566
3896
 
3567
3897
  func clearDismissSuppression() { dismissSuppressedByRequest = false }
3568
3898
 
3569
- @objc private func blockerTapped() { requestClose() }
3899
+ @objc private func backdropTapped() { requestClose(reason: "backdrop") }
3900
+
3901
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
3902
+ guard gestureRecognizer === backdropTap else { return true }
3903
+ var candidate = touch.view
3904
+ while let view = candidate, view !== self {
3905
+ if view === blocker { return true }
3906
+ if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return false }
3907
+ candidate = view.superview
3908
+ }
3909
+ return true
3910
+ }
3911
+
3912
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
3913
+ guard !isHidden, alpha > 0.01, isUserInteractionEnabled else { return nil }
3914
+ let contentPoint = contentStack.convert(point, from: self)
3915
+ if contentStack.bounds.contains(contentPoint), let hit = contentStack.hitTest(contentPoint, with: event), hit !== contentStack {
3916
+ // Flow/layout views occupy the modal's entire content rect, but a
3917
+ // blank part of one must still be a scrim tap. Preserve touches
3918
+ // only when the hit view (or one of its ancestors) actually owns
3919
+ // a control or gesture recognizer.
3920
+ var candidate: UIView? = hit
3921
+ while let view = candidate, view !== contentStack {
3922
+ if view is UIControl || !(view.gestureRecognizers?.isEmpty ?? true) { return hit }
3923
+ candidate = view.superview
3924
+ }
3925
+ }
3926
+ return blocker.hitTest(blocker.convert(point, from: self), with: event)
3927
+ }
3570
3928
 
3571
3929
  override func layoutSubviews() {
3572
3930
  super.layoutSubviews()
@@ -3618,6 +3976,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
3618
3976
 
3619
3977
  override init(frame: CGRect) {
3620
3978
  super.init(frame: frame)
3979
+ // Let native controls finish tracking taps before the scroll view decides
3980
+ // whether the gesture is a drag. This matches UIKit's table-view
3981
+ // behavior and prevents switches, sliders, and date pickers from
3982
+ // losing most taps when they live inside a ScrollView.
3983
+ delaysContentTouches = false
3621
3984
  keyboardTapGesture.cancelsTouchesInView = false
3622
3985
  keyboardTapGesture.delegate = self
3623
3986
  keyboardTapGesture.addTarget(self, action: #selector(keyboardTap))
@@ -3629,6 +3992,11 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
3629
3992
 
3630
3993
  required init?(coder: NSCoder) { nil }
3631
3994
 
3995
+ override func touchesShouldCancel(in view: UIView) -> Bool {
3996
+ if view is UIControl { return false }
3997
+ return super.touchesShouldCancel(in: view)
3998
+ }
3999
+
3632
4000
  var isVertical: Bool { contentStack.axis == .vertical }
3633
4001
 
3634
4002
  /// Lays the content out again on the next pass. Scrolling alone does not:
@@ -4266,6 +4634,35 @@ private final class CraftNativeLabel: UILabel {
4266
4634
  }
4267
4635
  }
4268
4636
 
4637
+ /// A non-editable text view used when a native Text explicitly opts into
4638
+ /// selection. UILabel has no selection model; UITextView supplies UIKit's
4639
+ /// standard loupe, menu and copy behavior while remaining a plain text leaf.
4640
+ private final class CraftNativeSelectableTextView: UITextView {
4641
+ convenience init() {
4642
+ self.init(frame: .zero, textContainer: nil)
4643
+ }
4644
+
4645
+ override init(frame: CGRect, textContainer: NSTextContainer?) {
4646
+ super.init(frame: frame, textContainer: textContainer)
4647
+ isEditable = false
4648
+ isSelectable = true
4649
+ isScrollEnabled = false
4650
+ backgroundColor = .clear
4651
+ textContainerInset = .zero
4652
+ self.textContainer.lineFragmentPadding = 0
4653
+ }
4654
+
4655
+ required init?(coder: NSCoder) {
4656
+ super.init(coder: coder)
4657
+ isEditable = false
4658
+ isSelectable = true
4659
+ isScrollEnabled = false
4660
+ backgroundColor = .clear
4661
+ textContainerInset = .zero
4662
+ self.textContainer.lineFragmentPadding = 0
4663
+ }
4664
+ }
4665
+
4269
4666
  /// A one-line TextInput. Its style's padding is where the text and the
4270
4667
  /// placeholder start, as in a web input; UITextField has no padding of its own.
4271
4668
  final class CraftNativeTextField: UITextField {
@@ -4302,6 +4699,22 @@ private final class CraftNativeFlexSpacer: UIView {}
4302
4699
  private final class CraftNativeIconView: UIImageView {
4303
4700
  var defaultBox: CGFloat? { didSet { if defaultBox != oldValue { invalidateIntrinsicContentSize() } } }
4304
4701
 
4702
+ /// An icon says only what the screen labels it. iOS gives a symbol image
4703
+ /// a description of its own ("Circle", "Chevron"), which VoiceOver read
4704
+ /// as an extra stop and a row read as part of its text; an unlabelled
4705
+ /// icon is decoration, as UIKit's own glyph accessories are.
4706
+ private var authoredLabel: String?
4707
+
4708
+ override var accessibilityLabel: String? {
4709
+ get { authoredLabel }
4710
+ set { authoredLabel = newValue }
4711
+ }
4712
+
4713
+ override var isAccessibilityElement: Bool {
4714
+ get { authoredLabel != nil && super.isAccessibilityElement }
4715
+ set { super.isAccessibilityElement = newValue }
4716
+ }
4717
+
4305
4718
  override var intrinsicContentSize: CGSize {
4306
4719
  if let defaultBox { return CGSize(width: defaultBox, height: defaultBox) }
4307
4720
  return super.intrinsicContentSize
@@ -4511,9 +4924,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4511
4924
  }
4512
4925
  }
4513
4926
 
4927
+ // A reparented popup must keep the live native view that owns its draft,
4928
+ // focus and selection. The mutation protocol identifies nodes globally;
4929
+ // retain the previous render by that ID when the new parent has no local
4930
+ // identity entry for it.
4931
+ private var previousNodesByProtocolID: [String: RenderedNode] = [:]
4932
+ private var adoptedPreviousProtocolIDs = Set<String>()
4933
+
4514
4934
  private let config: CraftConfig
4515
4935
  private let routeName: String?
4516
4936
  private(set) var routeParams: [String: Any]
4937
+ private(set) var routeProps: [String: Any]
4517
4938
  private let jsContext = JSContext(virtualMachine: CraftNativeBundle.virtualMachine)!
4518
4939
  private let rootStack = CraftNativeFlowView()
4519
4940
  private var handlers: [ObjectIdentifier: String] = [:]
@@ -4538,6 +4959,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4538
4959
  private var sliderSteps: [ObjectIdentifier: Float] = [:]
4539
4960
  private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
4540
4961
  private var modalVisibility: [ObjectIdentifier: Bool] = [:]
4962
+ private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
4541
4963
  private var layoutHandlers: [ObjectIdentifier: String] = [:]
4542
4964
  private var lastLayoutFrames: [ObjectIdentifier: CGRect] = [:]
4543
4965
  private var tapRecognizers: [ObjectIdentifier: UITapGestureRecognizer] = [:]
@@ -4595,10 +5017,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4595
5017
  /// A request a screen-owned module answers, by request token, and how to
4596
5018
  /// call it off when it times out or is cancelled.
4597
5019
  private var screenRequestCancellers: [String: () -> Void] = [:]
4598
- /// Shown at least once: a later appearance is the screen coming back.
4599
- private var hasAppeared = false
4600
- /// Whether the script was last told the screen is on screen.
4601
- private var reportedVisible = false
5020
+ /// Whether the script was last told the screen is on screen; nil until the
5021
+ /// first report. A screen first reported hidden - an adjacent tab the
5022
+ /// shell prepares ahead of a swipe - must hear that, or its script takes
5023
+ /// itself to be in front and acts on it: a sign-in guard on a prepared
5024
+ /// Record tab pushed Login over the Feed at launch.
5025
+ private var reportedVisible: Bool?
4602
5026
  private let capabilityScope = UUID().uuidString
4603
5027
  private var pendingCapabilityRequests = Set<String>()
4604
5028
  private var pendingCapabilityDeadlines: [String: DispatchWorkItem] = [:]
@@ -4624,10 +5048,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4624
5048
  /// `DEEP_LINK`; it can open paths but not push, pop or style a bar.
4625
5049
  let isAppScript: Bool
4626
5050
 
4627
- init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], isAppScript: Bool = false) {
5051
+ init(config: CraftConfig, routeName: String? = nil, routeParams: [String: Any] = [:], routeProps: [String: Any] = [:], isAppScript: Bool = false) {
4628
5052
  self.config = config
4629
5053
  self.routeName = routeName
4630
5054
  self.routeParams = routeParams
5055
+ self.routeProps = routeProps
4631
5056
  self.isAppScript = isAppScript
4632
5057
  super.init(nibName: nil, bundle: nil)
4633
5058
  }
@@ -4783,8 +5208,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4783
5208
  /// The screen came on screen or went off it: a screen pushed over it was
4784
5209
  /// popped, or the hybrid shell switched to or away from its tab (which
4785
5210
  /// hides a stack rather than moving it, so UIKit says nothing). The first
4786
- /// appearance is the screen's mount, not news; every later one is
4787
- /// `SCREEN_FOCUS { focused: true }`, and going away is `focused: false`.
5211
+ /// appearance of a screen shown as it mounts is the mount, not news; every
5212
+ /// later one is `SCREEN_FOCUS { focused: true }`, and going away is
5213
+ /// `focused: false`. A screen that mounts hidden is told `focused: false`
5214
+ /// first, so its first showing is news too.
4788
5215
  func screenVisibilityChanged(_ visible: Bool) {
4789
5216
  guard !runtimeDisposed else { return }
4790
5217
  if !visible, isViewLoaded { stopCelebrations(in: view) }
@@ -4793,31 +5220,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4793
5220
  // chrome even when no new SCREEN_FOCUS event is needed.
4794
5221
  if visible { restoreShellChrome() }
4795
5222
  guard visible != reportedVisible else { return }
5223
+ let first = reportedVisible == nil
4796
5224
  reportedVisible = visible
4797
5225
  if visible { DeepLinkManager.shared.replayNativeLinks() }
4798
- if visible, !hasAppeared {
4799
- hasAppeared = true
4800
- return
4801
- }
5226
+ if first, visible { return }
4802
5227
  send(type: "SCREEN_FOCUS", payload: ["focused": visible])
4803
5228
  }
4804
5229
 
4805
- /// New params for the screen already showing a path: opened again with
4806
- /// another query (`/m/messages?person=8`), or a tab's root opened with
4807
- /// one (`/m/store?coach=5`). The script's `craft.route.params` changes
4808
- /// and it hears `ROUTE_PARAMS`; before the script has run, it simply
4809
- /// starts with them.
4810
- func receiveRouteParams(_ params: [String: Any]) {
5230
+ /// New params or props for the screen already showing a path: opened
5231
+ /// again with another query or a structured handoff. The two namespaces
5232
+ /// stay separate so props cannot collide with path/query params.
5233
+ func receiveRouteParams(_ params: [String: Any], props: [String: Any] = [:]) {
4811
5234
  routeParams = params
5235
+ routeProps = props
4812
5236
  guard isViewLoaded, !runtimeDisposed else { return }
4813
5237
  let context = jsContext
4814
- CraftNativeScriptThread.run { context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString) }
4815
- send(type: "ROUTE_PARAMS", payload: ["params": params])
5238
+ CraftNativeScriptThread.run {
5239
+ context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
5240
+ context.setObject(props as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
5241
+ }
5242
+ send(type: "ROUTE_PARAMS", payload: ["params": params, "props": props])
4816
5243
  }
4817
5244
 
4818
5245
  private func disposeRuntime() {
4819
5246
  guard !runtimeDisposed else { return }
4820
5247
  runtimeDisposed = true
5248
+ disposeRenderedTree()
4821
5249
  let context = jsContext
4822
5250
  CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
4823
5251
  audio.close()
@@ -4827,6 +5255,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4827
5255
  screenRequestCancellers.removeAll()
4828
5256
  }
4829
5257
 
5258
+ /// Runtime disposal happens before UIKit necessarily deallocates the
5259
+ /// controller. Release the rendered tree now so recycled rows, native
5260
+ /// delegates, media, and map callbacks cannot outlive their screen.
5261
+ private func disposeRenderedTree() {
5262
+ guard let root = renderedRoot else { return }
5263
+ forgetHandlers(root)
5264
+ rootStack.removeArrangedSubview(root.view)
5265
+ root.view.removeFromSuperview()
5266
+ renderedRoot = nil
5267
+ flatListRows.removeAll()
5268
+ flatListRecycled.removeAll()
5269
+ flatListOwners.removeAll()
5270
+ svgOwners.removeAll()
5271
+ }
5272
+
4830
5273
  private var appliedTraitDefaults: String?
4831
5274
 
4832
5275
  private func refreshTraitDefaults() {
@@ -4847,6 +5290,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4847
5290
 
4848
5291
  private func refreshTraitDefaults(in node: RenderedNode?) {
4849
5292
  guard let node else { return }
5293
+ (node.view as? CraftNativeMapView)?.refreshThemeDefaults()
4850
5294
  (node.view as? CraftNativeFlatList)?.refreshThemeDefaults()
4851
5295
  node.children.forEach { refreshTraitDefaults(in: $0) }
4852
5296
  }
@@ -4908,6 +5352,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4908
5352
  jsContext.setObject(routeName, forKeyedSubscript: "__stxNativeRoute" as NSString)
4909
5353
  }
4910
5354
  jsContext.setObject(routeParams as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
5355
+ jsContext.setObject(routeProps as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
5356
+ // Hidden before its script ran: the SCREEN_FOCUS saying so reached no
5357
+ // callback, so the script reads it as it starts instead.
5358
+ if reportedVisible == false {
5359
+ jsContext.setObject(false, forKeyedSubscript: "__stxNativeFocused" as NSString)
5360
+ }
4911
5361
  jsContext.exceptionHandler = { [weak self] context, exception in
4912
5362
  // Never stringify an arbitrary JS object here. JavaScriptCore can
4913
5363
  // re-enter this handler while coercing an exception whose
@@ -4998,6 +5448,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4998
5448
  onMessage: function(callback) { globalThis.__stxNativeCallback = callback; }
4999
5449
  };
5000
5450
  """)
5451
+ jsContext.evaluateScript(Self.nativeWebAPICompatibilityPrelude)
5001
5452
  installHostAPIs()
5002
5453
  jsContext.evaluateScript("""
5003
5454
  if (typeof globalThis.setTimeout !== 'function') {
@@ -5054,6 +5505,25 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5054
5505
  """)
5055
5506
  }
5056
5507
 
5508
+ /// JavaScriptCore has the language core but not browser URL/encoding
5509
+ /// globals. Keep their absence actionable for shared modules instead of
5510
+ /// surfacing an opaque ReferenceError from an unrelated load path. Full
5511
+ /// implementations belong to the shared STX runtime.
5512
+ private static let nativeWebAPICompatibilityPrelude = """
5513
+ (function() {
5514
+ function unavailable(name, hint) {
5515
+ var failure = function() {
5516
+ throw new ReferenceError(name + ' is not available on a native screen — ' + hint);
5517
+ };
5518
+ failure.prototype.constructor = failure;
5519
+ return failure;
5520
+ }
5521
+ if (typeof globalThis.URL === 'undefined') globalThis.URL = unavailable('URL', 'use an absolute URL or build the path with encodeURIComponent');
5522
+ if (typeof globalThis.URLSearchParams === 'undefined') globalThis.URLSearchParams = unavailable('URLSearchParams', 'build query strings with encodeURIComponent');
5523
+ if (typeof globalThis.TextEncoder === 'undefined') globalThis.TextEncoder = unavailable('TextEncoder', 'use a native-supported encoding helper');
5524
+ })();
5525
+ """
5526
+
5057
5527
  /// `value` as a JavaScript string literal (JSON), quotes and all.
5058
5528
  static func jsonString(_ value: String) -> String {
5059
5529
  guard let data = try? JSONSerialization.data(withJSONObject: [value]),
@@ -5222,14 +5692,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5222
5692
  }));
5223
5693
  });
5224
5694
  }
5225
- var craft = globalThis.craft = globalThis.craft || {};
5226
- if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0 && !craft.dialog) {
5227
- craft.dialog = {
5228
- alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
5229
- confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
5230
- actionSheet: function(options) { return request('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); }
5231
- };
5695
+ // Native capability ownership wins over any browser-shaped
5696
+ // dialog object the bundle installed before the bridge ran.
5697
+ // Leaving the old guard here sends actionSheet through the
5698
+ // DOM fallback even though this screen can present UIKit.
5699
+ function installNativeDialog() {
5700
+ var craft = globalThis.craft = globalThis.craft || {};
5701
+ if (Array.isArray(bridge.capabilities) && bridge.capabilities.indexOf('dialog') >= 0) {
5702
+ craft.dialog = {
5703
+ alert: function(options) { return request('alert', [options || {}]).then(function() {}); },
5704
+ confirm: function(options) { return request('confirm', [options || {}]).then(Boolean); },
5705
+ actionSheet: function(options) { return request('actionSheet', [options || {}]).then(function(id) { return typeof id === 'string' ? id : null; }); }
5706
+ };
5707
+ }
5232
5708
  }
5709
+ globalThis.__craftInstallNativeDialog = installNativeDialog;
5710
+ installNativeDialog();
5233
5711
  })();
5234
5712
  """)
5235
5713
  // `AudioContext` and the rest of Web Audio, rendered natively.
@@ -5443,6 +5921,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5443
5921
  jsContext.evaluateScript(runtime.source, withSourceURL: runtime.url)
5444
5922
  }
5445
5923
  jsContext.evaluateScript(bundle.source, withSourceURL: bundle.url)
5924
+ // The STX runtime may install its browser-shaped craft.dialog after
5925
+ // the host prelude. Re-assert the native capability once the bundle
5926
+ // has finished so native screens cannot fall through to a DOM sheet.
5927
+ jsContext.evaluateScript("if (typeof globalThis.__craftInstallNativeDialog === 'function') globalThis.__craftInstallNativeDialog();")
5446
5928
  // A bundle that names no route leaves `__stxNativeRoute` undefined;
5447
5929
  // its title is the app's, not the word "undefined". A title the
5448
5930
  // script set with `navigation.setOptions` wins over both.
@@ -5943,7 +6425,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5943
6425
  guard !isAppScript else { return }
5944
6426
  deepLinkListener = DeepLinkManager.shared.addNativeListener(canReceive: { [weak self] in
5945
6427
  guard let self = self else { return false }
5946
- return self.reportedVisible && !self.runtimeDisposed && self.navigationController?.topViewController === self
6428
+ return self.reportedVisible == true && !self.runtimeDisposed && self.navigationController?.topViewController === self
5947
6429
  }) { [weak self] url, initial in
5948
6430
  guard let self = self else { return }
5949
6431
  var payload = CraftNativeActions.deepLinkData(url)
@@ -6155,7 +6637,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6155
6637
  // A path rather than a screen: the hybrid shell opens it, natively
6156
6638
  // or in the web view.
6157
6639
  guard let path = payload["path"] as? String, !path.isEmpty else { return }
6158
- hybridEvents?.nativeScreen(self, open: path)
6640
+ hybridEvents?.nativeScreen(self, open: path, props: payload["props"] as? [String: Any] ?? [:])
6159
6641
  case "NAVIGATE_BACK":
6160
6642
  if !isAppScript, navigationController?.topViewController === self {
6161
6643
  navigationController?.popViewController(animated: true)
@@ -6255,6 +6737,124 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6255
6737
  }
6256
6738
  let id = args.first as? String ?? ""
6257
6739
  switch (module, method) {
6740
+ case ("View", "focus"):
6741
+ let options = args.first as? [String: Any]
6742
+ let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
6743
+ let mode = options?["mode"] as? String ?? (args.count > 1 ? args[1] as? String : nil) ?? "keyboard"
6744
+ guard !nodeId.isEmpty else {
6745
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.focus needs a nodeId")))
6746
+ return true
6747
+ }
6748
+ guard let node = renderedNode(nodeId) else {
6749
+ reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.focus could not find mounted node \(nodeId)")))
6750
+ return true
6751
+ }
6752
+ guard node.view.window != nil else {
6753
+ reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.focus requires a mounted node")))
6754
+ return true
6755
+ }
6756
+ switch mode {
6757
+ case "keyboard":
6758
+ guard node.view is UITextField || node.view is UITextView else {
6759
+ reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "Keyboard focus is supported only for text inputs")))
6760
+ return true
6761
+ }
6762
+ guard node.view.becomeFirstResponder() else {
6763
+ reply(.failure(CraftNativeActionError(code: "FOCUS_FAILED", message: "The mounted view refused keyboard focus")))
6764
+ return true
6765
+ }
6766
+ lastFocusedInput = node.view
6767
+ reply(.success(true))
6768
+ case "accessibility":
6769
+ UIAccessibility.post(notification: .layoutChanged, argument: node.view)
6770
+ reply(.success(true))
6771
+ default:
6772
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.focus mode must be keyboard or accessibility")))
6773
+ }
6774
+ return true
6775
+ case ("View", "measure"):
6776
+ let options = args.first as? [String: Any]
6777
+ let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
6778
+ guard !nodeId.isEmpty else {
6779
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.measure needs a nodeId")))
6780
+ return true
6781
+ }
6782
+ guard let node = renderedNode(nodeId) else {
6783
+ reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.measure could not find mounted node \(nodeId)")))
6784
+ return true
6785
+ }
6786
+ guard node.view.window != nil else {
6787
+ reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.measure requires a mounted node")))
6788
+ return true
6789
+ }
6790
+ view.layoutIfNeeded()
6791
+ let bounds = node.view.convert(node.view.bounds, to: view)
6792
+ guard bounds.origin.x.isFinite, bounds.origin.y.isFinite, bounds.width.isFinite, bounds.height.isFinite else {
6793
+ reply(.failure(CraftNativeActionError(code: "MEASURE_FAILED", message: "The mounted view has no finite layout")))
6794
+ return true
6795
+ }
6796
+ reply(.success([
6797
+ "x": bounds.origin.x,
6798
+ "y": bounds.origin.y,
6799
+ "width": bounds.width,
6800
+ "height": bounds.height,
6801
+ "viewportWidth": view.bounds.width,
6802
+ "viewportHeight": view.bounds.height,
6803
+ "unit": "points",
6804
+ "coordinateSpace": "screenViewport",
6805
+ ]))
6806
+ return true
6807
+ case ("View", "scrollTo"):
6808
+ let options = args.first as? [String: Any]
6809
+ let nodeId = options?["nodeId"] as? String ?? (args.first as? String) ?? ""
6810
+ guard !nodeId.isEmpty else {
6811
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo needs a nodeId")))
6812
+ return true
6813
+ }
6814
+ guard let node = renderedNode(nodeId) else {
6815
+ reply(.failure(CraftNativeActionError(code: "VIEW_NOT_FOUND", message: "View.scrollTo could not find mounted node \(nodeId)")))
6816
+ return true
6817
+ }
6818
+ guard node.view.window != nil else {
6819
+ reply(.failure(CraftNativeActionError(code: "VIEW_NOT_MOUNTED", message: "View.scrollTo requires a mounted node")))
6820
+ return true
6821
+ }
6822
+ guard let scrollView = node.view as? UIScrollView else {
6823
+ reply(.failure(CraftNativeActionError(code: "UNSUPPORTED_TARGET", message: "View.scrollTo is supported only for scroll views")))
6824
+ return true
6825
+ }
6826
+ let x: Double
6827
+ if let raw = options?["x"] {
6828
+ guard let number = craftNativeNumber(raw), number.doubleValue.isFinite else {
6829
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
6830
+ return true
6831
+ }
6832
+ x = number.doubleValue
6833
+ } else {
6834
+ x = scrollView.contentOffset.x
6835
+ }
6836
+ let y: Double
6837
+ if let raw = options?["y"] {
6838
+ guard let number = craftNativeNumber(raw), number.doubleValue.isFinite else {
6839
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
6840
+ return true
6841
+ }
6842
+ y = number.doubleValue
6843
+ } else {
6844
+ y = scrollView.contentOffset.y
6845
+ }
6846
+ guard x.isFinite, y.isFinite else {
6847
+ reply(.failure(CraftNativeActionError(code: "INVALID_ARGUMENT", message: "View.scrollTo coordinates must be finite numbers")))
6848
+ return true
6849
+ }
6850
+ let minX = -scrollView.adjustedContentInset.left
6851
+ let minY = -scrollView.adjustedContentInset.top
6852
+ let maxX = max(minX, scrollView.contentSize.width - scrollView.bounds.width + scrollView.adjustedContentInset.right)
6853
+ let maxY = max(minY, scrollView.contentSize.height - scrollView.bounds.height + scrollView.adjustedContentInset.bottom)
6854
+ let target = CGPoint(x: min(max(x, minX), maxX), y: min(max(y, minY), maxY))
6855
+ scrollView.setContentOffset(target, animated: options?["animated"] as? Bool ?? false)
6856
+ reply(.success(["x": target.x, "y": target.y, "unit": "points"]))
6857
+ return true
6258
6858
  case ("Network", "stream"):
6259
6859
  let options = args.first as? [String: Any] ?? [:]
6260
6860
  let streamId = options["id"] as? String ?? ""
@@ -6398,7 +6998,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6398
6998
  statusBarStyleOverride = nil
6399
6999
  statusBarHiddenOverride = nil
6400
7000
  let previous = renderedRoot
7001
+ previousNodesByProtocolID.removeAll(keepingCapacity: true)
7002
+ adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
7003
+ indexPreviousNodes(previous)
6401
7004
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
7005
+ previousNodesByProtocolID.removeAll(keepingCapacity: true)
7006
+ adoptedPreviousProtocolIDs.removeAll(keepingCapacity: true)
6402
7007
  if previous?.view !== next.view {
6403
7008
  if let old = previous { detach(old, from: rootStack) }
6404
7009
  }
@@ -6460,6 +7065,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6460
7065
  private func canReuseView(_ node: [String: Any], previous: RenderedNode) -> Bool {
6461
7066
  let type = canonicalType(node["type"] as? String ?? "View")
6462
7067
  guard previous.type == type else { return false }
7068
+ if type == "Text" {
7069
+ let props = node["props"] as? [String: Any] ?? [:]
7070
+ return (previous.view is CraftNativeSelectableTextView) == (props["selectable"] as? Bool == true)
7071
+ }
6463
7072
  guard type == "TextInput" else { return true }
6464
7073
  let props = node["props"] as? [String: Any] ?? [:]
6465
7074
  return (previous.view is UITextView) == (props["multiline"] as? Bool == true)
@@ -6486,16 +7095,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6486
7095
  let style = node["style"] as? [String: Any] ?? [:]
6487
7096
  let events = node["events"] as? [String: String] ?? [:]
6488
7097
  let children = node["children"] as? [Any] ?? []
7098
+ let protocolId = node["id"] as? String
7099
+ let retained = previous ?? protocolId.flatMap { id in
7100
+ guard !adoptedPreviousProtocolIDs.contains(id) else { return nil }
7101
+ return previousNodesByProtocolID[id]
7102
+ }
7103
+ let adopted = previous == nil && retained != nil
6489
7104
  let current: RenderedNode
6490
- if let previous = previous, previous.identity == identity || adopting, canReuseView(node, previous: previous) {
6491
- current = previous
7105
+ if let retained, (retained.identity == identity || adopting || adopted), canReuseView(node, previous: retained) {
7106
+ current = retained
7107
+ if let protocolId { adoptedPreviousProtocolIDs.insert(protocolId) }
6492
7108
  current.identity = identity
6493
7109
  } else {
6494
7110
  current = RenderedNode(identity: identity, type: type, view: makeView(type, props: props))
6495
7111
  }
6496
7112
 
6497
7113
  let result = current.view
6498
- current.protocolId = node["id"] as? String
7114
+ current.protocolId = protocolId
6499
7115
  current.style = style
6500
7116
  let linkTarget = ["href", "to"].lazy.compactMap { props[$0] as? String }
6501
7117
  .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
@@ -6529,11 +7145,18 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6529
7145
  result.isHidden = true
6530
7146
  result.isUserInteractionEnabled = false
6531
7147
  case "Text":
6532
- let label = result as! UILabel
6533
- label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
6534
- label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
6535
- (label as? CraftNativeLabel)?.insets = paddingInsets(style)
6536
- configureText(label, text: children.compactMap { $0 as? String }.joined(), style: style)
7148
+ let text = children.compactMap { $0 as? String }.joined()
7149
+ if let selectable = result as? CraftNativeSelectableTextView {
7150
+ selectable.textContainer.maximumNumberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
7151
+ selectable.textContainer.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
7152
+ configureSelectableText(selectable, text: text, style: style)
7153
+ } else {
7154
+ let label = result as! UILabel
7155
+ label.numberOfLines = max(0, Int(number(props["numberOfLines"]) ?? 0))
7156
+ label.lineBreakMode = textLineBreakMode(props["ellipsizeMode"])
7157
+ (label as? CraftNativeLabel)?.insets = paddingInsets(style)
7158
+ configureText(label, text: text, style: style)
7159
+ }
6537
7160
  case "Button", "Link":
6538
7161
  let button = result as! UIButton
6539
7162
  let title = props["title"] as? String ?? children.compactMap { $0 as? String }.joined()
@@ -6704,6 +7327,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6704
7327
  updateHandler(nonEmptyHandler(events["onValueChange"]) ?? nonEmptyHandler(events["onChange"]), for: picker)
6705
7328
  case "Modal":
6706
7329
  let modal = result as! CraftNativeModalView
7330
+ let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
6707
7331
  modal.transparent = props["transparent"] as? Bool == true
6708
7332
  let visible: Bool
6709
7333
  if let propVisible = props["visible"] as? Bool {
@@ -6718,10 +7342,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6718
7342
  let modalID = ObjectIdentifier(modal)
6719
7343
  let wasVisible = modalVisibility[modalID]
6720
7344
  modalVisibility[modalID] = visible
7345
+ if visible, wasVisible != true, let focusBeforeContent,
7346
+ !focusBeforeContent.isDescendant(of: modal) {
7347
+ modalFocusRestore[modalID] = focusBeforeContent
7348
+ }
6721
7349
  if visible, wasVisible != true { positionModals() }
6722
7350
  modal.setVisible(visible, opacity: number(style["opacity"]) ?? 1, animationType: props["animationType"] as? String)
7351
+ if !visible, wasVisible == true {
7352
+ let restore = modalFocusRestore.removeValue(forKey: modalID)
7353
+ DispatchQueue.main.async { [weak self, weak restore] in
7354
+ guard let self, let restore, restore.window != nil, restore.isDescendant(of: self.view) else { return }
7355
+ self.lastFocusedInput = restore
7356
+ _ = restore.becomeFirstResponder()
7357
+ }
7358
+ }
6723
7359
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
6724
- { [weak self] in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
7360
+ { [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
6725
7361
  }
6726
7362
  if visible { modal.clearDismissSuppression() }
6727
7363
  let suppressDismiss = !visible && modal.consumeDismissSuppression()
@@ -6744,10 +7380,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6744
7380
  pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
6745
7381
  case "Switch":
6746
7382
  let toggle = result as! UISwitch
6747
- let value = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
6748
- // UIKit has already changed the value before emitting the event.
6749
- // Echoing it through the script must not restart its native animation.
6750
- if toggle.isOn != value { toggle.isOn = value }
7383
+ // A supplied value controls the switch; without one, UIKit owns
7384
+ // the state and a reconciliation must not erase a user's tap.
7385
+ // `value: null` deliberately falls through to `checked`, matching
7386
+ // the host-neutral precedence used by the other renderer.
7387
+ if let value = props["value"] as? Bool ?? props["checked"] as? Bool {
7388
+ // UIKit has already changed the value before emitting the
7389
+ // event. Echoing it through the script must not restart its
7390
+ // native animation.
7391
+ if toggle.isOn != value { toggle.isOn = value }
7392
+ }
6751
7393
  toggle.isEnabled = props["disabled"] as? Bool != true
6752
7394
  if let track = props["trackColor"] as? [String: Any] {
6753
7395
  toggle.onTintColor = color(track["true"])
@@ -6795,8 +7437,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6795
7437
  indicator.hidesWhenStopped = props["hidesWhenStopped"] as? Bool ?? true
6796
7438
  if props["animating"] as? Bool == false {
6797
7439
  indicator.stopAnimating()
7440
+ // UIKit does not retroactively reveal an indicator when an
7441
+ // already-stopped view changes hidesWhenStopped to false.
7442
+ // Android applies the requested visibility on every render;
7443
+ // keep the two hosts in lockstep for dynamic prop changes.
7444
+ indicator.isHidden = indicator.hidesWhenStopped
6798
7445
  } else {
6799
7446
  indicator.startAnimating()
7447
+ indicator.isHidden = false
6800
7448
  }
6801
7449
  case "Svg":
6802
7450
  let drawing = result as! CraftNativeSvgView
@@ -6822,7 +7470,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6822
7470
  if let current = image.image { image.image = imageForDisplay(current, view: image) }
6823
7471
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
6824
7472
  image.contentMode = imageContentMode(requestedResizeMode)
6825
- image.clipsToBounds = image.clipsToBounds || image.contentMode == .scaleAspectFill
7473
+ applyImageClipping(image, contentMode: image.contentMode, style: style)
6826
7474
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: image)
6827
7475
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
6828
7476
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
@@ -6841,7 +7489,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6841
7489
  if let current = background.image { background.image = imageForDisplay(current, view: background) }
6842
7490
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
6843
7491
  background.contentMode = imageContentMode(requestedResizeMode)
6844
- background.clipsToBounds = background.clipsToBounds || background.contentMode == .scaleAspectFill
7492
+ applyImageClipping(background, contentMode: background.contentMode, style: style)
6845
7493
  updateAuxiliaryHandler(events["onLoadStart"], in: &imageLoadStartHandlers, for: background)
6846
7494
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
6847
7495
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
@@ -6985,6 +7633,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6985
7633
  private func makeView(_ type: String, props: [String: Any]) -> UIView {
6986
7634
  switch type {
6987
7635
  case "Text":
7636
+ if props["selectable"] as? Bool == true {
7637
+ return CraftNativeSelectableTextView()
7638
+ }
6988
7639
  let label = CraftNativeLabel()
6989
7640
  label.numberOfLines = 0
6990
7641
  return label
@@ -7169,7 +7820,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7169
7820
  let key = explicitKey(node, props: node["props"] as? [String: Any] ?? [:])
7170
7821
  // Keys are local to a parent. Ambiguous sibling keys fall back to
7171
7822
  // position so a move never attaches the wrong live control.
7172
- let identity = key.flatMap { counts[$0] == 1 ? "key:\($0)" : nil } ?? "index:\(index)"
7823
+ let localIdentity = key.flatMap { counts[$0] == 1 ? "key:\($0)" : nil } ?? "index:\(index)"
7824
+ // A keyed row can live in either FlatList or an ordinary
7825
+ // ScrollView. Scope its descendants to the row so two rows cannot
7826
+ // share an uncontrolled input draft or retained native handler.
7827
+ let identity = parent.identity.hasPrefix("list:") || parent.identity.hasPrefix("key:")
7828
+ ? "\(parent.identity)/\(localIdentity)"
7829
+ : localIdentity
7173
7830
  let child = reconcile(node, identity: identity, path: "\(path).\(index)", previous: old[identity])
7174
7831
  next.append(child)
7175
7832
  }
@@ -7302,6 +7959,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7302
7959
  recycled[ObjectIdentifier(row.view)] = row
7303
7960
  self.flatListRecycled[listKey] = recycled
7304
7961
  },
7962
+ removeItem: { [weak self] identity in
7963
+ guard let self else { return }
7964
+ let scopedIdentity = "list:\(identity)"
7965
+ if let row = self.flatListRows[listKey]?.removeValue(forKey: identity) {
7966
+ self.forgetHandlers(row)
7967
+ }
7968
+ guard var recycled = self.flatListRecycled[listKey] else { return }
7969
+ let staleViewIDs = recycled.compactMap { viewID, row in
7970
+ row.identity == scopedIdentity ? viewID : nil
7971
+ }
7972
+ for viewID in staleViewIDs {
7973
+ if let row = recycled.removeValue(forKey: viewID) { self.forgetHandlers(row) }
7974
+ }
7975
+ self.flatListRecycled[listKey] = recycled
7976
+ },
7305
7977
  endReached: nonEmptyHandler(events["onEndReached"]).map { [weak self] handler in
7306
7978
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
7307
7979
  }
@@ -7333,24 +8005,52 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7333
8005
  }
7334
8006
 
7335
8007
  private func detach(_ node: RenderedNode, from stack: CraftNativeFlowView) {
7336
- forgetHandlers(node)
8008
+ let retainedByReparent = node.protocolId.map { adoptedPreviousProtocolIDs.contains($0) } == true
8009
+ if !retainedByReparent { forgetHandlers(node) }
7337
8010
  if node.type == "Modal" {
7338
- node.view.removeFromSuperview()
8011
+ if node.view.superview === stack { node.view.removeFromSuperview() }
7339
8012
  return
7340
8013
  }
7341
- stack.removeLayoutStyle(for: node.view)
7342
- stack.removeArrangedSubview(node.view)
7343
- node.view.removeFromSuperview()
8014
+ if node.view.superview === stack {
8015
+ stack.removeLayoutStyle(for: node.view)
8016
+ stack.removeArrangedSubview(node.view)
8017
+ node.view.removeFromSuperview()
8018
+ }
8019
+ }
8020
+
8021
+ private func indexPreviousNodes(_ node: RenderedNode?) {
8022
+ guard let node else { return }
8023
+ if let id = node.protocolId, !id.isEmpty { previousNodesByProtocolID[id] = node }
8024
+ node.children.forEach { indexPreviousNodes($0) }
7344
8025
  }
7345
8026
 
7346
8027
  private func forgetHandlers(_ node: RenderedNode, preservingInputDrafts: Bool = false) {
7347
8028
  (node.view as? CraftNativeScrollView)?.setRefreshHandler(nil, refreshing: false)
8029
+ // Recycled list rows keep their native delegates and targets until
8030
+ // they are rebound. A permanently detached node must release those
8031
+ // UIKit hooks as well as the handler maps below, otherwise a picker,
8032
+ // text view, or control can still call back into this screen after
8033
+ // its subtree is gone.
8034
+ if !preservingInputDrafts {
8035
+ if let textView = node.view as? UITextView { textView.delegate = nil }
8036
+ if let picker = node.view as? UIPickerView {
8037
+ picker.delegate = nil
8038
+ picker.dataSource = nil
8039
+ }
8040
+ if let scroll = node.view as? UIScrollView { scroll.delegate = nil }
8041
+ (node.view as? UIControl)?.removeTarget(self, action: nil, for: .allEvents)
8042
+ }
7348
8043
  if let list = node.view as? CraftNativeFlatList {
7349
8044
  let listKey = ObjectIdentifier(list)
7350
8045
  list.setRefreshHandler(nil, refreshing: false)
7351
8046
  list.discardAll()
8047
+ if !preservingInputDrafts { list.resetForTeardown() }
7352
8048
  flatListRows.removeValue(forKey: listKey)
7353
- flatListRecycled.removeValue(forKey: listKey)
8049
+ if !preservingInputDrafts {
8050
+ flatListRecycled.removeValue(forKey: listKey)?.values.forEach { forgetHandlers($0) }
8051
+ } else {
8052
+ flatListRecycled.removeValue(forKey: listKey)
8053
+ }
7354
8054
  if let owner = node.protocolId { flatListOwners = flatListOwners.filter { $0.value != owner } }
7355
8055
  }
7356
8056
  if node.view is CraftNativeSvgView, let owner = node.protocolId { svgOwners = svgOwners.filter { $0.value != owner } }
@@ -7408,6 +8108,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7408
8108
  imageErrorHandlers.removeValue(forKey: id)
7409
8109
  checkboxIndeterminate.removeValue(forKey: id)
7410
8110
  if let video = node.view as? CraftNativeVideoView { video.stop() }
8111
+ if let map = node.view as? CraftNativeMapView { map.destroy() }
7411
8112
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
7412
8113
  }
7413
8114
 
@@ -7773,6 +8474,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7773
8474
  stack.columnGap = max(0, number(style["columnGap"]) ?? gap)
7774
8475
  stack.grid = style["display"] as? String == "grid"
7775
8476
  stack.gridColumns = craftNativeGridColumnCount(style["gridTemplateColumns"] ?? style["gridColumns"])
8477
+ stack.gridRows = craftNativeGridRowCount(style["gridTemplateRows"])
7776
8478
  stack.gridAutoRows = CraftNativeLength(style["gridAutoRows"])
7777
8479
  stack.padding = paddingInsets(style)
7778
8480
  stack.invalidateMeasurements()
@@ -7939,6 +8641,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7939
8641
  label.lineBreakMode = lineBreakMode
7940
8642
  }
7941
8643
 
8644
+ private func configureSelectableText(_ textView: UITextView, text: String, style: [String: Any]) {
8645
+ let label = CraftNativeLabel()
8646
+ label.numberOfLines = 0
8647
+ configureText(label, text: text, style: style)
8648
+ textView.attributedText = label.attributedText
8649
+ if label.attributedText == nil { textView.text = label.text }
8650
+ textView.font = label.font
8651
+ textView.textColor = label.textColor
8652
+ textView.textAlignment = label.textAlignment
8653
+ textView.textContainerInset = paddingInsets(style)
8654
+ textView.textContainer.lineFragmentPadding = 0
8655
+ textView.isEditable = false
8656
+ textView.isSelectable = true
8657
+ textView.isScrollEnabled = false
8658
+ textView.backgroundColor = .clear
8659
+ }
8660
+
7942
8661
  private func textLineBreakMode(_ value: Any?) -> NSLineBreakMode {
7943
8662
  switch value as? String {
7944
8663
  case "head": return .byTruncatingHead
@@ -8186,6 +8905,30 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8186
8905
  return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
8187
8906
  }
8188
8907
 
8908
+ /// Native screens have no document URL to resolve a path against. A
8909
+ /// leading slash is an app URL (for example `/api/avatar`), while a bare
8910
+ /// name remains a bundled asset for backwards compatibility.
8911
+ private func imageURL(for uri: String) -> URL? {
8912
+ let candidate: URL?
8913
+ if uri.hasPrefix("/") {
8914
+ guard let base = config.devServerURL.flatMap(URL.init(string:)) else { return nil }
8915
+ candidate = URL(string: uri, relativeTo: base)?.absoluteURL
8916
+ } else {
8917
+ candidate = URL(string: uri)
8918
+ }
8919
+ guard let candidate, Self.loadsImage(from: candidate) else { return nil }
8920
+ return candidate
8921
+ }
8922
+
8923
+ private func applyImageClipping(_ view: UIImageView, contentMode: UIView.ContentMode, style: [String: Any]) {
8924
+ let authored = (style["overflow"] as? String) == "hidden"
8925
+ || (view.craftRequestedCornerRadius ?? 0) > 0
8926
+ || view.craftRequestedCornerRadii != nil
8927
+ let clips = authored || contentMode == .scaleAspectFill
8928
+ view.clipsToBounds = clips
8929
+ view.layer.masksToBounds = clips
8930
+ }
8931
+
8189
8932
  private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
8190
8933
  let sourceObject = source as? [String: Any]
8191
8934
  let uri = (source as? String) ?? sourceObject?["uri"] as? String
@@ -8225,7 +8968,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8225
8968
  emitImageEvent(view, handler: imageLoadEndHandlers[id])
8226
8969
  return
8227
8970
  }
8228
- if let url = URL(string: uri), Self.loadsImage(from: url) {
8971
+ if let url = imageURL(for: uri) {
8229
8972
  // Decoded at the size it is drawn, when the style fixes that size.
8230
8973
  let pixels = Self.displayPixels(of: view)
8231
8974
  let fill = view.contentMode == .scaleAspectFill
@@ -8267,6 +9010,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8267
9010
  task.resume()
8268
9011
  return
8269
9012
  }
9013
+ if uri.hasPrefix("/") {
9014
+ imageFailure(view, uri: uri, message: "Relative image source requires devServerURL")
9015
+ return
9016
+ }
8270
9017
  if !uri.contains(":") {
8271
9018
  guard let image = UIImage(named: uri) else {
8272
9019
  imageFailure(view, uri: uri, message: "Bundled image was not found")
@@ -8341,7 +9088,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8341
9088
  }
8342
9089
  let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
8343
9090
  let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
8344
- let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : nil)
9091
+ let touchable = ["TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type)
9092
+ let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : touchable ? "button" : nil)
8345
9093
  // A scroll view, a list or a group of controls holds what VoiceOver
8346
9094
  // reads: its label names the group, as a tab strip or a pager's
8347
9095
  // name, and never stands in for its content. As an element of its
@@ -8428,7 +9176,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8428
9176
  @objc private func buttonPressed(_ sender: UIButton) {
8429
9177
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
8430
9178
  if let target = linkTargets[ObjectIdentifier(sender)] {
8431
- hybridEvents?.nativeScreen(self, open: target)
9179
+ hybridEvents?.nativeScreen(self, open: target, props: [:])
8432
9180
  return
8433
9181
  }
8434
9182
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
@@ -8719,7 +9467,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8719
9467
  guard let view = sender.view else { return }
8720
9468
  let id = ObjectIdentifier(view)
8721
9469
  if let target = linkTargets[id] {
8722
- hybridEvents?.nativeScreen(self, open: target)
9470
+ hybridEvents?.nativeScreen(self, open: target, props: [:])
8723
9471
  return
8724
9472
  }
8725
9473
  guard let handler = handlers[id] else { return }