craft-native 0.0.173 → 0.0.174

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.
@@ -4,6 +4,7 @@ import AVKit
4
4
  import CryptoKit
5
5
  import Darwin
6
6
  import JavaScriptCore
7
+ import MapKit
7
8
  import OSLog
8
9
  import Security
9
10
  import SwiftUI
@@ -426,6 +427,166 @@ private final class CraftNativeVideoView: UIView {
426
427
  deinit { stop() }
427
428
  }
428
429
 
430
+ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
431
+ private var annotationsByID: [String: MKPointAnnotation] = [:]
432
+ private var overlaysByID: [String: MKPolyline] = [:]
433
+ private var overlayColors: [ObjectIdentifier: UIColor] = [:]
434
+ private(set) var markerHandlers: [String: String] = [:]
435
+ var onMarkerTap: ((String) -> Void)?
436
+
437
+ override init(frame: CGRect) {
438
+ super.init(frame: frame)
439
+ delegate = self
440
+ showsCompass = false
441
+ showsScale = false
442
+ isRotateEnabled = false
443
+ }
444
+
445
+ required init?(coder: NSCoder) {
446
+ super.init(coder: coder)
447
+ delegate = self
448
+ }
449
+
450
+ func configure(props: [String: Any], children: [Any], markerHandler: @escaping (String) -> Void) {
451
+ onMarkerTap = markerHandler
452
+ isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
453
+ isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
454
+ isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
455
+ mapType = Self.mapType(props["mapType"] as? String)
456
+
457
+ var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
458
+ var nextHandlers: [String: String] = [:]
459
+ var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
460
+ for (index, child) in children.enumerated() {
461
+ guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
462
+ let type = rawType.lowercased()
463
+ let childProps = node["props"] as? [String: Any] ?? [:]
464
+ let childEvents = node["events"] as? [String: String] ?? [:]
465
+ let id = (childProps["id"] as? CustomStringConvertible)?.description
466
+ ?? (node["id"] as? String)
467
+ ?? "overlay-\(index)"
468
+ let points = Self.coordinates(childProps["geometry"])
469
+ if type == "mapmarker" || type == "marker" {
470
+ guard let point = points.first else { continue }
471
+ nextMarkers[id] = (point, childProps["title"] as? String)
472
+ if let handler = childEvents["onPress"] ?? childEvents["onMarkerPress"], !handler.isEmpty {
473
+ nextHandlers[id] = handler
474
+ }
475
+ } else if type == "mappolyline" || type == "polyline" {
476
+ guard points.count > 1 else { continue }
477
+ nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), CGFloat((childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3))
478
+ }
479
+ }
480
+
481
+ let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
482
+ removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
483
+ for (id, value) in nextMarkers {
484
+ if let annotation = annotationsByID[id] {
485
+ annotation.coordinate = value.coordinate
486
+ annotation.title = value.title
487
+ } else {
488
+ let annotation = MKPointAnnotation()
489
+ annotation.coordinate = value.coordinate
490
+ annotation.title = value.title
491
+ annotationsByID[id] = annotation
492
+ addAnnotation(annotation)
493
+ }
494
+ }
495
+ markerHandlers = nextHandlers
496
+
497
+ removeOverlays(Array(overlaysByID.values))
498
+ overlaysByID.removeAll(keepingCapacity: true)
499
+ overlayColors.removeAll(keepingCapacity: true)
500
+ for (id, value) in nextPolylines {
501
+ let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
502
+ overlaysByID[id] = overlay
503
+ overlayColors[ObjectIdentifier(overlay)] = value.color
504
+ addOverlay(overlay)
505
+ }
506
+
507
+ if let region = Self.region(props["region"] ?? props["initialRegion"]) {
508
+ setRegion(region, animated: false)
509
+ } else if props["fitToBounds"] as? Bool == true {
510
+ fitToOverlays()
511
+ }
512
+ }
513
+
514
+ private func fitToOverlays() {
515
+ var rect = MKMapRect.null
516
+ for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
517
+ for annotation in annotationsByID.values {
518
+ let point = MKMapPoint(annotation.coordinate)
519
+ rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
520
+ }
521
+ guard !rect.isNull else { return }
522
+ let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
523
+ setVisibleMapRect(rect, edgePadding: inset, animated: false)
524
+ }
525
+
526
+ func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
527
+ guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
528
+ let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
529
+ ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
530
+ view.annotation = annotation
531
+ view.markerTintColor = .systemRed
532
+ view.accessibilityIdentifier = id
533
+ return view
534
+ }
535
+
536
+ func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
537
+ guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return }
538
+ onMarkerTap?(id)
539
+ deselectAnnotation(annotation, animated: false)
540
+ }
541
+
542
+ func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
543
+ let renderer = MKPolylineRenderer(overlay: overlay)
544
+ renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
545
+ renderer.lineWidth = 3
546
+ return renderer
547
+ }
548
+
549
+ private static func coordinates(_ value: Any?) -> [CLLocationCoordinate2D] {
550
+ let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
551
+ ?? (value as? [NSNumber])?.map(\.doubleValue)
552
+ ?? []
553
+ return stride(from: 0, to: numbers.count - 1, by: 2).map {
554
+ CLLocationCoordinate2D(latitude: numbers[$0], longitude: numbers[$0 + 1])
555
+ }
556
+ }
557
+
558
+ private static func region(_ value: Any?) -> MKCoordinateRegion? {
559
+ guard let value = value as? [String: Any],
560
+ let center = value["center"] as? [String: Any],
561
+ let span = value["span"] as? [String: Any],
562
+ let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
563
+ let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
564
+ let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
565
+ let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue else { return nil }
566
+ return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
567
+ }
568
+
569
+ private static func color(_ value: String?) -> UIColor {
570
+ switch value?.lowercased() {
571
+ case "red": return .systemRed
572
+ case "green": return .systemGreen
573
+ case "orange": return .systemOrange
574
+ case "purple": return .systemPurple
575
+ case "yellow": return .systemYellow
576
+ default: return .systemBlue
577
+ }
578
+ }
579
+
580
+ private static func mapType(_ value: String?) -> MKMapType {
581
+ switch value?.lowercased() {
582
+ case "satellite": return .satellite
583
+ case "hybrid": return .hybrid
584
+ case "muted_standard": return .mutedStandard
585
+ default: return .standard
586
+ }
587
+ }
588
+ }
589
+
429
590
  /// A WebView-free host for the first stx native vertical slice. The bundled
430
591
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
431
592
  /// events and Craft API replies through JavaScriptCore.
@@ -781,11 +942,28 @@ private class CraftNativeFlowView: UIStackView {
781
942
  var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
782
943
  var padding = UIEdgeInsets.zero {
783
944
  didSet {
784
- layoutMargins = padding
945
+ layoutMargins = effectivePadding
785
946
  isLayoutMarginsRelativeArrangement = true
786
947
  if padding != oldValue { invalidateMeasurements() }
787
948
  }
788
949
  }
950
+ /// Insets contributed by a native SafeAreaView root. Authored padding
951
+ /// remains separate so a reconciliation can remove either source without
952
+ /// leaving stale safe-area space behind.
953
+ var safeAreaPadding = UIEdgeInsets.zero {
954
+ didSet {
955
+ layoutMargins = effectivePadding
956
+ if safeAreaPadding != oldValue { invalidateMeasurements() }
957
+ }
958
+ }
959
+ var effectivePadding: UIEdgeInsets {
960
+ UIEdgeInsets(
961
+ top: padding.top + safeAreaPadding.top,
962
+ left: padding.left + safeAreaPadding.left,
963
+ bottom: padding.bottom + safeAreaPadding.bottom,
964
+ right: padding.right + safeAreaPadding.right
965
+ )
966
+ }
789
967
  override var axis: NSLayoutConstraint.Axis {
790
968
  didSet { if axis != oldValue { invalidateMeasurements() } }
791
969
  }
@@ -1020,8 +1198,9 @@ private class CraftNativeFlowView: UIStackView {
1020
1198
  let children = visibleChildren
1021
1199
  let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
1022
1200
  let horizontal = axis == .horizontal
1023
- let paddingWidth = padding.left + padding.right
1024
- let paddingHeight = padding.top + padding.bottom
1201
+ let contentPadding = effectivePadding
1202
+ let paddingWidth = contentPadding.left + contentPadding.right
1203
+ let paddingHeight = contentPadding.top + contentPadding.bottom
1025
1204
  let innerWidth = width.map { max(0, $0 - paddingWidth) }
1026
1205
  let innerHeight = height.map { max(0, $0 - paddingHeight) }
1027
1206
  let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
@@ -1046,7 +1225,7 @@ private class CraftNativeFlowView: UIStackView {
1046
1225
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
1047
1226
 
1048
1227
  if place {
1049
- let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
1228
+ let content = CGRect(x: contentPadding.left, y: contentPadding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
1050
1229
  for child in children where child.craftLayoutStyle.position == "absolute" {
1051
1230
  let style = child.craftLayoutStyle
1052
1231
  var fixedWidth = style.left != nil && style.right != nil
@@ -1432,8 +1611,8 @@ private class CraftNativeFlowView: UIStackView {
1432
1611
  ? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
1433
1612
  : mainOffset + item.mainLeading
1434
1613
  let frame = horizontal
1435
- ? CGRect(x: padding.left + mainPosition, y: padding.top + physicalLineOffset + physicalCrossPosition, width: item.main, height: item.cross)
1436
- : CGRect(x: padding.left + physicalLineOffset + physicalCrossPosition, y: padding.top + mainPosition, width: item.cross, height: item.main)
1614
+ ? CGRect(x: contentPadding.left + mainPosition, y: contentPadding.top + physicalLineOffset + physicalCrossPosition, width: item.main, height: item.cross)
1615
+ : CGRect(x: contentPadding.left + physicalLineOffset + physicalCrossPosition, y: contentPadding.top + mainPosition, width: item.cross, height: item.main)
1437
1616
  frames.append((item.view, frame))
1438
1617
  if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
1439
1618
  }
@@ -1475,7 +1654,10 @@ private class CraftNativeFlowView: UIStackView {
1475
1654
  let align = style.alignSelf ?? alignItems
1476
1655
  var fixedWidth = style.width?.resolve(cellWidth)
1477
1656
  var fixedHeight = style.height?.resolve(nil)
1478
- if fixedWidth == nil && fixedHeight == nil && align == "stretch" { fixedWidth = available }
1657
+ // A cell's item is as wide as its column unless it sets a width
1658
+ // (or an aspect ratio derives one from its height), whatever its
1659
+ // height: a fixed-height swatch with no content was drawn 0 wide.
1660
+ if fixedWidth == nil && align == "stretch" && (fixedHeight == nil || style.aspectRatio == nil) { fixedWidth = available }
1479
1661
  (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
1480
1662
  let size = CraftNativeFlexLayout.measure(
1481
1663
  child,
@@ -1506,7 +1688,7 @@ private class CraftNativeFlowView: UIStackView {
1506
1688
  let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1507
1689
  var explicitWidth = style.width?.resolve(cellWidth)
1508
1690
  var explicitHeight = style.height?.resolve(nil)
1509
- if explicitWidth == nil && explicitHeight == nil && align == "stretch" {
1691
+ if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
1510
1692
  explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1511
1693
  }
1512
1694
  (explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
@@ -1526,7 +1708,7 @@ private class CraftNativeFlowView: UIStackView {
1526
1708
  if let sharedFrames {
1527
1709
  for (index, child) in children.enumerated() {
1528
1710
  let frame = sharedFrames[index]
1529
- frames.append((child, CGRect(x: padding.left + frame.x, y: padding.top + frame.y, width: frame.width, height: frame.height)))
1711
+ frames.append((child, CGRect(x: contentPadding.left + frame.x, y: contentPadding.top + frame.y, width: frame.width, height: frame.height)))
1530
1712
  }
1531
1713
  return CGSize(width: width, height: innerHeight ?? height)
1532
1714
  }
@@ -1550,7 +1732,7 @@ private class CraftNativeFlowView: UIStackView {
1550
1732
  case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
1551
1733
  default: break
1552
1734
  }
1553
- var rowTop = padding.top + rowLeading
1735
+ var rowTop = contentPadding.top + rowLeading
1554
1736
  for (index, child) in children.enumerated() {
1555
1737
  let style = child.craftLayoutStyle
1556
1738
  let row = index / columns
@@ -1562,7 +1744,7 @@ private class CraftNativeFlowView: UIStackView {
1562
1744
  let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
1563
1745
  let childWidth = min(availableWidth, size.width)
1564
1746
  let childHeight = align == "stretch" && style.height == nil && style.aspectRatio == nil ? availableHeight : min(availableHeight, size.height)
1565
- let x = padding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
1747
+ let x = contentPadding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
1566
1748
  + (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
1567
1749
  let y = rowTop + style.marginTop
1568
1750
  + (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
@@ -1574,6 +1756,41 @@ private class CraftNativeFlowView: UIStackView {
1574
1756
  }
1575
1757
  }
1576
1758
 
1759
+ /// A container that applies only the requested system safe-area edges. The
1760
+ /// host keeps the root edge-to-edge when this node is the screen root so the
1761
+ /// node, rather than the controller's outer stack, owns the insets.
1762
+ private final class CraftNativeSafeAreaView: CraftNativeFlowView {
1763
+ private var edges: Set<String> = ["top", "right", "bottom", "left"]
1764
+
1765
+ func setSafeAreaEdges(_ raw: Any?) {
1766
+ guard let values = raw as? [Any] else {
1767
+ edges = ["top", "right", "bottom", "left"]
1768
+ updateSafeAreaPadding()
1769
+ return
1770
+ }
1771
+ edges = Set(values.compactMap { value in
1772
+ guard let edge = value as? String, ["top", "right", "bottom", "left"].contains(edge) else { return nil }
1773
+ return edge
1774
+ })
1775
+ updateSafeAreaPadding()
1776
+ }
1777
+
1778
+ override func safeAreaInsetsDidChange() {
1779
+ super.safeAreaInsetsDidChange()
1780
+ updateSafeAreaPadding()
1781
+ }
1782
+
1783
+ private func updateSafeAreaPadding() {
1784
+ let insets = safeAreaInsets
1785
+ safeAreaPadding = UIEdgeInsets(
1786
+ top: edges.contains("top") ? insets.top : 0,
1787
+ left: edges.contains("left") ? insets.left : 0,
1788
+ bottom: edges.contains("bottom") ? insets.bottom : 0,
1789
+ right: edges.contains("right") ? insets.right : 0
1790
+ )
1791
+ }
1792
+ }
1793
+
1577
1794
  /// A flex container that responds to the controller's keyboard overlap.
1578
1795
  /// `padding` keeps the content visible without moving the field; `position`
1579
1796
  /// lifts the whole container; `height` reduces its viewport.
@@ -1773,6 +1990,18 @@ private final class CraftNativeModalView: UIView {
1773
1990
  }
1774
1991
  }
1775
1992
 
1993
+ /// Whether a scrolling view's gesture runs together with `other`. A scroll
1994
+ /// view is the delegate of its own pan as well as of its keyboard tap, so this
1995
+ /// answers for both. Everything runs together (the keyboard tap watches
1996
+ /// alongside a control's own gestures; the back-swipe and the tab swipe decide
1997
+ /// for themselves) except two scroll views' pans: run together, a drag on a
1998
+ /// nested scroll view scrolled its parent at the same time.
1999
+ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
2000
+ guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer,
2001
+ let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer else { return true }
2002
+ return false
2003
+ }
2004
+
1776
2005
  private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
1777
2006
  let contentStack = CraftNativeFlowView()
1778
2007
  private let keyboardTapGesture = UITapGestureRecognizer()
@@ -1994,6 +2223,22 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
1994
2223
  }
1995
2224
  }
1996
2225
 
2226
+ /// Applies the shared RefreshControl appearance props. UIKit's refresh
2227
+ /// control owns the spinner and optional title; keep the authored values
2228
+ /// on the control so a later reconciliation can clear them as well.
2229
+ func setRefreshAppearance(_ props: [String: Any]) {
2230
+ pullToRefresh.tintColor = (props["tintColor"] as? String).flatMap { UIColor(hex: $0) }
2231
+ guard let title = props["title"] as? String, !title.isEmpty else {
2232
+ pullToRefresh.attributedTitle = nil
2233
+ return
2234
+ }
2235
+ var attributes: [NSAttributedString.Key: Any] = [:]
2236
+ if let titleColor = (props["titleColor"] as? String).flatMap({ UIColor(hex: $0) }) {
2237
+ attributes[.foregroundColor] = titleColor
2238
+ }
2239
+ pullToRefresh.attributedTitle = NSAttributedString(string: title, attributes: attributes)
2240
+ }
2241
+
1997
2242
  var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
1998
2243
 
1999
2244
  @objc private func refreshPulled() {
@@ -2025,7 +2270,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
2025
2270
  }
2026
2271
 
2027
2272
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2028
- true
2273
+ craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
2029
2274
  }
2030
2275
 
2031
2276
  func setAxis(_ axis: NSLayoutConstraint.Axis) {
@@ -2510,6 +2755,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2510
2755
  private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
2511
2756
  private var imageLoadEndHandlers: [ObjectIdentifier: String] = [:]
2512
2757
  private var imageErrorHandlers: [ObjectIdentifier: String] = [:]
2758
+ /// Native links keep their target separate from optional press handlers.
2759
+ /// The compiler can emit an anchor as `TouchableOpacity`, so this map is
2760
+ /// intentionally shared by Link and the generic pressable path.
2761
+ private var linkTargets: [ObjectIdentifier: String] = [:]
2513
2762
  private var renderedRoot: RenderedNode?
2514
2763
  private weak var lastFocusedInput: UIView?
2515
2764
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
@@ -2582,11 +2831,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2582
2831
  view.addSubview(rootStack)
2583
2832
  rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
2584
2833
  rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
2834
+ rootBottomToSafeArea = rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
2835
+ rootBottomToView = rootStack.bottomAnchor.constraint(equalTo: view.bottomAnchor)
2836
+ rootLeadingToSafeArea = rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor)
2837
+ rootLeadingToView = rootStack.leadingAnchor.constraint(equalTo: view.leadingAnchor)
2838
+ rootTrailingToSafeArea = rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
2839
+ rootTrailingToView = rootStack.trailingAnchor.constraint(equalTo: view.trailingAnchor)
2585
2840
  NSLayoutConstraint.activate([
2586
2841
  rootTopToSafeArea!,
2587
- rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor),
2588
- rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor),
2589
- rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
2842
+ rootBottomToSafeArea!,
2843
+ rootLeadingToSafeArea!,
2844
+ rootTrailingToSafeArea!
2590
2845
  ])
2591
2846
  setupJavaScript()
2592
2847
  loadBundle()
@@ -3137,7 +3392,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3137
3392
  private var titleFromOptions = false
3138
3393
  private var rootTopToSafeArea: NSLayoutConstraint?
3139
3394
  private var rootTopToView: NSLayoutConstraint?
3395
+ private var rootBottomToSafeArea: NSLayoutConstraint?
3396
+ private var rootBottomToView: NSLayoutConstraint?
3397
+ private var rootLeadingToSafeArea: NSLayoutConstraint?
3398
+ private var rootLeadingToView: NSLayoutConstraint?
3399
+ private var rootTrailingToSafeArea: NSLayoutConstraint?
3400
+ private var rootTrailingToView: NSLayoutConstraint?
3401
+ private var rootIsSafeAreaView = false
3140
3402
  private var navigationBarHidden: Bool?
3403
+ /// A native `<StatusBar>` node overrides the app default for this screen.
3404
+ private var statusBarStyleOverride: UIStatusBarStyle?
3405
+ private var statusBarHiddenOverride: Bool?
3141
3406
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
3142
3407
  private var tabBarHidden = false
3143
3408
  /// Whether a back-swipe may leave this screen at all, the edge's
@@ -3158,6 +3423,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3158
3423
  /// full-screen timer is black edge to edge. nil is the app's.
3159
3424
  private var screenBackgroundColor: UIColor?
3160
3425
 
3426
+ override var preferredStatusBarStyle: UIStatusBarStyle {
3427
+ statusBarStyleOverride ?? .default
3428
+ }
3429
+
3430
+ override var prefersStatusBarHidden: Bool {
3431
+ statusBarHiddenOverride ?? false
3432
+ }
3433
+
3161
3434
  /// Whether this screen asked for a bar of its own through `setOptions`: a
3162
3435
  /// title, a large title or buttons, and not `hidden`. A tab's root screen
3163
3436
  /// in a hybrid app is shown bare unless it did.
@@ -3221,6 +3494,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3221
3494
  }
3222
3495
  }
3223
3496
 
3497
+ private func applyStatusBar(_ props: [String: Any]) {
3498
+ let style: UIStatusBarStyle?
3499
+ switch (props["barStyle"] as? String)?.lowercased() {
3500
+ case "light-content", "light": style = .lightContent
3501
+ case "dark-content", "dark": style = .darkContent
3502
+ case "default": style = .default
3503
+ default: style = nil
3504
+ }
3505
+ let hidden = props["hidden"] as? Bool
3506
+ if statusBarStyleOverride != style || statusBarHiddenOverride != hidden {
3507
+ statusBarStyleOverride = style
3508
+ statusBarHiddenOverride = hidden
3509
+ setNeedsStatusBarAppearanceUpdate()
3510
+ }
3511
+ }
3512
+
3224
3513
  /// A bar button from `{ id, symbol | title }`. Its tap is the event
3225
3514
  /// `navButton` with `{ id }`.
3226
3515
  private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
@@ -3417,10 +3706,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3417
3706
  // effect, rather than over the bar's back button. Other screens stay
3418
3707
  // inside the safe area.
3419
3708
  let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
3420
- if rootTopToView?.isActive != edgeToEdge {
3421
- rootTopToSafeArea?.isActive = !edgeToEdge
3422
- rootTopToView?.isActive = edgeToEdge
3423
- }
3709
+ let fullEdgeToEdge = rootIsSafeAreaView
3710
+ let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
3711
+ rootTopToSafeArea?.isActive = !topEdgeToEdge
3712
+ rootTopToView?.isActive = topEdgeToEdge
3713
+ rootBottomToSafeArea?.isActive = !fullEdgeToEdge
3714
+ rootBottomToView?.isActive = fullEdgeToEdge
3715
+ rootLeadingToSafeArea?.isActive = !fullEdgeToEdge
3716
+ rootLeadingToView?.isActive = fullEdgeToEdge
3717
+ rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
3718
+ rootTrailingToView?.isActive = fullEdgeToEdge
3424
3719
  scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
3425
3720
  guard scroll !== trackedScrollView else { return }
3426
3721
  trackedScrollView = scroll
@@ -3898,6 +4193,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3898
4193
 
3899
4194
  private func renderCommitted(_ document: [String: Any]) {
3900
4195
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
4196
+ statusBarStyleOverride = nil
4197
+ statusBarHiddenOverride = nil
3901
4198
  let previous = renderedRoot
3902
4199
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
3903
4200
  if previous?.view !== next.view {
@@ -3920,15 +4217,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3920
4217
  rootStack.setLayoutStyle(rootStyle, for: next.view)
3921
4218
  rootStack.invalidateMeasurements()
3922
4219
  renderedRoot = next
4220
+ rootIsSafeAreaView = next.type == "SafeAreaView"
3923
4221
  updateKeyboardAvoidingViews()
3924
4222
  syncGlobalKeyboardSafeArea()
3925
4223
  trackContentScrollView()
4224
+ setNeedsStatusBarAppearanceUpdate()
3926
4225
  restoreFocus(focused)
3927
4226
  }
3928
4227
 
3929
4228
  private func clearRenderedTree() {
3930
4229
  if let root = renderedRoot { detach(root, from: rootStack) }
3931
4230
  renderedRoot = nil
4231
+ rootIsSafeAreaView = false
4232
+ rootTopToSafeArea?.isActive = true
4233
+ rootTopToView?.isActive = false
4234
+ rootBottomToSafeArea?.isActive = true
4235
+ rootBottomToView?.isActive = false
4236
+ rootLeadingToSafeArea?.isActive = true
4237
+ rootLeadingToView?.isActive = false
4238
+ rootTrailingToSafeArea?.isActive = true
4239
+ rootTrailingToView?.isActive = false
4240
+ statusBarStyleOverride = nil
4241
+ statusBarHiddenOverride = nil
4242
+ setNeedsStatusBarAppearanceUpdate()
3932
4243
  syncGlobalKeyboardSafeArea()
3933
4244
  }
3934
4245
 
@@ -3963,14 +4274,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3963
4274
  let result = current.view
3964
4275
  current.protocolId = node["id"] as? String
3965
4276
  current.style = style
4277
+ let linkTarget = ["href", "to"].lazy.compactMap { props[$0] as? String }
4278
+ .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
4279
+ .trimmingCharacters(in: .whitespacesAndNewlines)
4280
+ if ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type),
4281
+ props["disabled"] as? Bool != true, let linkTarget {
4282
+ linkTargets[ObjectIdentifier(result)] = linkTarget
4283
+ } else {
4284
+ linkTargets.removeValue(forKey: ObjectIdentifier(result))
4285
+ }
3966
4286
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
3967
4287
  if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
3968
4288
  result.setContentHuggingPriority(.required, for: .vertical)
3969
4289
  }
3970
4290
  applyViewStyle(style, to: result, node: current)
4291
+ if type == "SafeAreaView", let safeArea = result as? CraftNativeSafeAreaView {
4292
+ safeArea.setSafeAreaEdges(props["edges"])
4293
+ }
3971
4294
  updateLayoutHandler(events["onLayout"], for: result)
4295
+ if type == "StatusBar" { applyStatusBar(props) }
3972
4296
 
3973
4297
  switch type {
4298
+ case "StatusBar", "RefreshControl":
4299
+ // StatusBar is a screen trait, and RefreshControl is consumed by
4300
+ // its nearest ScrollView. Neither contributes a visible child.
4301
+ result.isHidden = true
4302
+ result.isUserInteractionEnabled = false
3974
4303
  case "Text":
3975
4304
  let label = result as! UILabel
3976
4305
  label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
@@ -4108,11 +4437,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4108
4437
  case "DatePicker":
4109
4438
  let picker = result as! UIDatePicker
4110
4439
  let mode = Self.datePickerMode(props["mode"])
4111
- if picker.datePickerMode != mode { picker.datePickerMode = mode }
4112
- let display: UIDatePickerStyle = props["display"] as? String == "inline" ? .inline
4440
+ // A countdown is wheels only: UIKit asserts (and the app aborts)
4441
+ // on a countdown in the compact or inline style, so the style is
4442
+ // settled before the mode becomes a countdown, and after it
4443
+ // stops being one.
4444
+ let display: UIDatePickerStyle = mode == .countDownTimer ? .wheels
4445
+ : props["display"] as? String == "inline" ? .inline
4113
4446
  : props["display"] as? String == "wheels" || props["display"] as? String == "spinner" ? .wheels
4114
4447
  : .compact
4115
- if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
4448
+ if mode == .countDownTimer {
4449
+ if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
4450
+ if picker.datePickerMode != mode { picker.datePickerMode = mode }
4451
+ } else {
4452
+ if picker.datePickerMode != mode { picker.datePickerMode = mode }
4453
+ if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
4454
+ }
4116
4455
  // UIKit takes an interval that divides an hour, and ignores others.
4117
4456
  let interval = (props["minuteInterval"] as? NSNumber)?.intValue ?? 1
4118
4457
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
@@ -4242,7 +4581,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4242
4581
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
4243
4582
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
4244
4583
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
4245
- updateImage(image, source: props["source"])
4584
+ updateImage(image, source: props["source"], defaultSource: props["defaultSource"])
4246
4585
  case "ImageBackground":
4247
4586
  let background = result as! CraftNativeImageBackgroundView
4248
4587
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
@@ -4252,7 +4591,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4252
4591
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
4253
4592
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
4254
4593
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
4255
- updateImage(background, source: props["source"])
4594
+ updateImage(background, source: props["source"], defaultSource: props["defaultSource"])
4256
4595
  configureStack(background.contentStack, style: style)
4257
4596
  reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
4258
4597
  case "KeyboardAvoidingView":
@@ -4276,6 +4615,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4276
4615
  controls: (props["controls"] as? Bool) == true,
4277
4616
  resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
4278
4617
  )
4618
+ case "Map":
4619
+ let map = result as! CraftNativeMapView
4620
+ map.configure(props: props, children: children) { [weak self, weak map] markerID in
4621
+ guard let self, let map, let handler = map.markerHandlers[markerID], !handler.isEmpty else { return }
4622
+ self.send(type: "EVENT", payload: [
4623
+ "handlerName": handler,
4624
+ "nativeEvent": ["id": markerID],
4625
+ ])
4626
+ }
4279
4627
  case "ScrollView", "SectionList":
4280
4628
  let scroll = result as! CraftNativeScrollView
4281
4629
  let flexDirection = style["flexDirection"] as? String
@@ -4301,15 +4649,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4301
4649
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
4302
4650
  updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
4303
4651
  updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
4304
- scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
4652
+ let refreshNode = children.compactMap { $0 as? [String: Any] }.first {
4653
+ canonicalType($0["type"] as? String ?? "") == "RefreshControl"
4654
+ }
4655
+ let refreshProps = refreshNode?["props"] as? [String: Any] ?? [:]
4656
+ let refreshEvents = refreshNode?["events"] as? [String: String] ?? [:]
4657
+ let refreshHandlerName = nonEmptyHandler(refreshEvents["onRefresh"])
4658
+ ?? nonEmptyHandler(events["onRefresh"])
4659
+ scroll.setRefreshHandler(refreshHandlerName.map { [weak self] handler in
4305
4660
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
4306
- }, refreshing: props["refreshing"] as? Bool == true)
4661
+ }, refreshing: (refreshNode == nil ? props["refreshing"] : refreshProps["refreshing"]) as? Bool == true)
4662
+ scroll.setRefreshAppearance(refreshProps)
4307
4663
  var contentStyle = style
4308
4664
  if let override = props["contentContainerStyle"] as? [String: Any] {
4309
4665
  contentStyle.merge(override) { _, next in next }
4310
4666
  }
4311
4667
  configureStack(scroll.contentStack, style: contentStyle)
4312
- reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
4668
+ reconcileChildren(children.filter {
4669
+ guard let child = $0 as? [String: Any] else { return true }
4670
+ return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
4671
+ }, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
4313
4672
  case "FlatList":
4314
4673
  let list = result as! CraftNativeFlatList
4315
4674
  reconcileFlatList(
@@ -4326,7 +4685,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4326
4685
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
4327
4686
  }
4328
4687
  updateLongPressHandler(props["disabled"] as? Bool == true ? nil : events["onLongPress"], for: result)
4329
- if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" {
4688
+ if type != "Button" && type != "Link" && type != "Checkbox" && type != "TextInput" && type != "Picker" && type != "DatePicker" && type != "Modal" && type != "Switch" && type != "Slider" && type != "ActivityIndicator" && type != "StatusBar" && type != "RefreshControl" && type != "Map" {
4330
4689
  let disabled = props["disabled"] as? Bool == true
4331
4690
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
4332
4691
  updatePressFeedback(
@@ -4391,6 +4750,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4391
4750
  return slider
4392
4751
  case "ActivityIndicator":
4393
4752
  return UIActivityIndicatorView(style: .medium)
4753
+ case "StatusBar", "RefreshControl":
4754
+ return CraftNativeFlowView()
4394
4755
  case "Image":
4395
4756
  return UIImageView()
4396
4757
  case "ImageBackground":
@@ -4399,6 +4760,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4399
4760
  return CraftNativeKeyboardAvoidingView()
4400
4761
  case "Video":
4401
4762
  return CraftNativeVideoView(host: self)
4763
+ case "Map":
4764
+ return CraftNativeMapView()
4402
4765
  case "Icon":
4403
4766
  let icon = CraftNativeIconView()
4404
4767
  icon.contentMode = .scaleAspectFit
@@ -4409,8 +4772,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4409
4772
  return CraftNativeFlatList()
4410
4773
  case "Svg":
4411
4774
  return CraftNativeSvgView()
4412
- case "View", "SafeAreaView":
4775
+ case "View":
4413
4776
  return CraftNativeFlowView()
4777
+ case "SafeAreaView":
4778
+ return CraftNativeSafeAreaView()
4414
4779
  case "TouchableOpacity", "TouchableHighlight", "Pressable":
4415
4780
  return CraftNativeFlowView()
4416
4781
  default:
@@ -4433,9 +4798,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4433
4798
  case "switch": return "Switch"
4434
4799
  case "slider": return "Slider"
4435
4800
  case "activityindicator": return "ActivityIndicator"
4801
+ case "statusbar": return "StatusBar"
4802
+ case "refreshcontrol": return "RefreshControl"
4436
4803
  case "image": return "Image"
4437
4804
  case "imagebackground": return "ImageBackground"
4438
4805
  case "video": return "Video"
4806
+ case "map": return "Map"
4439
4807
  case "icon": return "Icon"
4440
4808
  case "scrollview": return "ScrollView"
4441
4809
  case "sectionlist": return "SectionList"
@@ -4640,6 +5008,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4640
5008
  let id = ObjectIdentifier(node.view)
4641
5009
  if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
4642
5010
  handlers.removeValue(forKey: id)
5011
+ linkTargets.removeValue(forKey: id)
4643
5012
  pressInHandlers.removeValue(forKey: id)
4644
5013
  pressOutHandlers.removeValue(forKey: id)
4645
5014
  layoutHandlers.removeValue(forKey: id)
@@ -4754,7 +5123,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4754
5123
  let id = ObjectIdentifier(view)
4755
5124
  let handler = handler?.isEmpty == false ? handler : nil
4756
5125
  updateHandler(handler, for: view)
4757
- if handler != nil, tapRecognizers[id] == nil {
5126
+ let actionable = handler != nil || linkTargets[id] != nil
5127
+ if actionable, tapRecognizers[id] == nil {
4758
5128
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
4759
5129
  tapRecognizers[id] = recognizer
4760
5130
  view.addGestureRecognizer(recognizer)
@@ -4763,7 +5133,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4763
5133
  }
4764
5134
  // A container keeps receiving touches without a handler of its own,
4765
5135
  // or every control inside it (and the screen root) would be dead.
4766
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handler != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5136
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
4767
5137
  }
4768
5138
 
4769
5139
  private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
@@ -4772,7 +5142,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4772
5142
  if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
4773
5143
  guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
4774
5144
  if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
4775
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
5145
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
4776
5146
  return
4777
5147
  }
4778
5148
  if pressFeedbackRecognizers[id] == nil {
@@ -4801,7 +5171,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4801
5171
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
4802
5172
  view.removeGestureRecognizer(recognizer)
4803
5173
  }
4804
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
5174
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
4805
5175
  }
4806
5176
  }
4807
5177
 
@@ -5270,7 +5640,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5270
5640
  return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
5271
5641
  }
5272
5642
 
5273
- private func updateImage(_ view: UIImageView, source: Any?) {
5643
+ private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
5274
5644
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
5275
5645
  let id = ObjectIdentifier(view)
5276
5646
  guard let uri, !uri.isEmpty else {
@@ -5284,7 +5654,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5284
5654
  imageTasks.removeValue(forKey: id)?.cancel()
5285
5655
  imageSources[id] = uri
5286
5656
  imageErrors.removeValue(forKey: id)
5287
- view.image = nil
5657
+ view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
5288
5658
  view.accessibilityValue = nil
5289
5659
  emitImageEvent(view, handler: imageLoadStartHandlers[id])
5290
5660
 
@@ -5332,6 +5702,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5332
5702
  imageFailure(view, uri: uri, message: "Unsupported image source")
5333
5703
  }
5334
5704
 
5705
+ /// `defaultSource` is intentionally local-only: data and bundled assets
5706
+ /// can be shown before a network image arrives without starting another
5707
+ /// request or bypassing the source's URL policy.
5708
+ private func immediateImage(_ source: Any?) -> UIImage? {
5709
+ let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
5710
+ guard let uri, !uri.isEmpty else { return nil }
5711
+ if uri.hasPrefix("data:image/"),
5712
+ let comma = uri.firstIndex(of: ","),
5713
+ let data = Data(base64Encoded: String(uri[uri.index(after: comma)...])) {
5714
+ return UIImage(data: data)
5715
+ }
5716
+ return !uri.contains(":") ? UIImage(named: uri) : nil
5717
+ }
5718
+
5335
5719
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
5336
5720
  imageErrors[ObjectIdentifier(view)] = message
5337
5721
  view.image = nil
@@ -5425,6 +5809,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5425
5809
 
5426
5810
  @objc private func buttonPressed(_ sender: UIButton) {
5427
5811
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
5812
+ if let target = linkTargets[ObjectIdentifier(sender)] {
5813
+ hybridEvents?.nativeScreen(self, open: target)
5814
+ return
5815
+ }
5428
5816
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
5429
5817
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
5430
5818
  restoreFocus(focused)
@@ -5534,7 +5922,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5534
5922
  let value = snappedSliderValue(sender.value, for: sender)
5535
5923
  if sender.value != value { sender.value = value }
5536
5924
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
5537
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
5925
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
5926
+ }
5927
+
5928
+ /// A slider's value as the script reads it: the shortest decimal that is
5929
+ /// the same Float, so a step of 0.1 gives 0.8 and not 0.800000011920929.
5930
+ static func sliderEventValue(_ value: Float) -> Double {
5931
+ Double(value.description) ?? Double(value)
5538
5932
  }
5539
5933
 
5540
5934
  /// `@change` on a DatePicker: the value as `<input type="date|time|
@@ -5631,7 +6025,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5631
6025
 
5632
6026
  @objc private func sliderFinished(_ sender: UISlider) {
5633
6027
  guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
5634
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
6028
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
5635
6029
  }
5636
6030
 
5637
6031
  func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
@@ -5681,8 +6075,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5681
6075
  }
5682
6076
 
5683
6077
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
5684
- guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
6078
+ guard let view = sender.view else { return }
5685
6079
  let id = ObjectIdentifier(view)
6080
+ if let target = linkTargets[id] {
6081
+ hybridEvents?.nativeScreen(self, open: target)
6082
+ return
6083
+ }
6084
+ guard let handler = handlers[id] else { return }
5686
6085
  if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
5687
6086
  let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
5688
6087
  UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in