craft-native 0.0.173 → 0.0.175

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,183 @@ 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 var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
435
+ private(set) var markerHandlers: [String: String] = [:]
436
+ var onMarkerTap: ((String) -> Void)?
437
+
438
+ override init(frame: CGRect) {
439
+ super.init(frame: frame)
440
+ delegate = self
441
+ showsCompass = false
442
+ showsScale = false
443
+ isRotateEnabled = false
444
+ }
445
+
446
+ required init?(coder: NSCoder) {
447
+ super.init(coder: coder)
448
+ delegate = self
449
+ }
450
+
451
+ func configure(props: [String: Any], children: [Any], markerHandler: @escaping (String) -> Void) {
452
+ onMarkerTap = markerHandler
453
+ isScrollEnabled = props["scrollEnabled"] as? Bool ?? true
454
+ isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
455
+ isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
456
+ mapType = Self.mapType(props["mapType"] as? String)
457
+ // Showing the user dot must not trigger a permission prompt from a
458
+ // renderer pass. The app's existing location capability owns that
459
+ // prompt; a map only opts in after authorization is already granted.
460
+ showsUserLocation = props["showsUserLocation"] as? Bool == true && Self.locationPermissionGranted()
461
+
462
+ var nextMarkers: [String: (coordinate: CLLocationCoordinate2D, title: String?)] = [:]
463
+ var nextHandlers: [String: String] = [:]
464
+ var nextPolylines: [String: (coordinates: [CLLocationCoordinate2D], color: UIColor, width: CGFloat)] = [:]
465
+ for (index, child) in children.enumerated() {
466
+ guard let node = child as? [String: Any], let rawType = node["type"] as? String else { continue }
467
+ let type = rawType.lowercased()
468
+ let childProps = node["props"] as? [String: Any] ?? [:]
469
+ let childEvents = node["events"] as? [String: String] ?? [:]
470
+ let id = (childProps["id"] as? CustomStringConvertible)?.description
471
+ ?? (node["id"] as? String)
472
+ ?? "overlay-\(index)"
473
+ let points = Self.coordinates(childProps["geometry"])
474
+ if type == "mapmarker" || type == "marker" {
475
+ guard let point = points.first else { continue }
476
+ nextMarkers[id] = (point, childProps["title"] as? String)
477
+ if let handler = childEvents["onPress"] ?? childEvents["onMarkerPress"], !handler.isEmpty {
478
+ nextHandlers[id] = handler
479
+ }
480
+ } else if type == "mappolyline" || type == "polyline" {
481
+ guard points.count > 1 else { continue }
482
+ nextPolylines[id] = (points, Self.color(childProps["strokeColor"] as? String), CGFloat((childProps["strokeWidth"] as? NSNumber)?.doubleValue ?? 3))
483
+ }
484
+ }
485
+
486
+ let staleMarkers = annotationsByID.keys.filter { nextMarkers[$0] == nil }
487
+ removeAnnotations(staleMarkers.compactMap { annotationsByID.removeValue(forKey: $0) })
488
+ for (id, value) in nextMarkers {
489
+ if let annotation = annotationsByID[id] {
490
+ annotation.coordinate = value.coordinate
491
+ annotation.title = value.title
492
+ } else {
493
+ let annotation = MKPointAnnotation()
494
+ annotation.coordinate = value.coordinate
495
+ annotation.title = value.title
496
+ annotationsByID[id] = annotation
497
+ addAnnotation(annotation)
498
+ }
499
+ }
500
+ markerHandlers = nextHandlers
501
+
502
+ removeOverlays(Array(overlaysByID.values))
503
+ overlaysByID.removeAll(keepingCapacity: true)
504
+ overlayColors.removeAll(keepingCapacity: true)
505
+ overlayWidths.removeAll(keepingCapacity: true)
506
+ for (id, value) in nextPolylines {
507
+ let overlay = MKPolyline(coordinates: value.coordinates, count: value.coordinates.count)
508
+ overlaysByID[id] = overlay
509
+ overlayColors[ObjectIdentifier(overlay)] = value.color
510
+ overlayWidths[ObjectIdentifier(overlay)] = value.width
511
+ addOverlay(overlay)
512
+ }
513
+
514
+ if let region = Self.region(props["region"] ?? props["initialRegion"]) {
515
+ setRegion(region, animated: false)
516
+ } else if props["fitToBounds"] as? Bool == true {
517
+ fitToOverlays()
518
+ }
519
+ }
520
+
521
+ private func fitToOverlays() {
522
+ var rect = MKMapRect.null
523
+ for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
524
+ for annotation in annotationsByID.values {
525
+ let point = MKMapPoint(annotation.coordinate)
526
+ rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
527
+ }
528
+ guard !rect.isNull else { return }
529
+ let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
530
+ setVisibleMapRect(rect, edgePadding: inset, animated: false)
531
+ }
532
+
533
+ func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
534
+ guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
535
+ let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? MKMarkerAnnotationView)
536
+ ?? MKMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
537
+ view.annotation = annotation
538
+ view.markerTintColor = .systemRed
539
+ view.accessibilityIdentifier = id
540
+ return view
541
+ }
542
+
543
+ func mapView(_ mapView: MKMapView, didSelect annotation: MKAnnotation) {
544
+ guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return }
545
+ onMarkerTap?(id)
546
+ deselectAnnotation(annotation, animated: false)
547
+ }
548
+
549
+ func mapView(_ mapView: MKMapView, rendererFor overlay: MKOverlay) -> MKOverlayRenderer {
550
+ let renderer = MKPolylineRenderer(overlay: overlay)
551
+ renderer.strokeColor = overlayColors[ObjectIdentifier(overlay)] ?? .systemBlue
552
+ renderer.lineWidth = overlayWidths[ObjectIdentifier(overlay)] ?? 3
553
+ return renderer
554
+ }
555
+
556
+ private static func coordinates(_ value: Any?) -> [CLLocationCoordinate2D] {
557
+ let numbers = (value as? [Any])?.compactMap { ($0 as? NSNumber)?.doubleValue }
558
+ ?? (value as? [NSNumber])?.map(\.doubleValue)
559
+ ?? []
560
+ return stride(from: 0, to: numbers.count - 1, by: 2).map {
561
+ CLLocationCoordinate2D(latitude: numbers[$0], longitude: numbers[$0 + 1])
562
+ }
563
+ }
564
+
565
+ private static func region(_ value: Any?) -> MKCoordinateRegion? {
566
+ guard let value = value as? [String: Any],
567
+ let center = value["center"] as? [String: Any],
568
+ let span = value["span"] as? [String: Any],
569
+ let latitude = (center["latitude"] as? NSNumber)?.doubleValue,
570
+ let longitude = (center["longitude"] as? NSNumber)?.doubleValue,
571
+ let latitudeDelta = (span["latitudeDelta"] as? NSNumber)?.doubleValue ?? (span["latitude_delta"] as? NSNumber)?.doubleValue,
572
+ let longitudeDelta = (span["longitudeDelta"] as? NSNumber)?.doubleValue ?? (span["longitude_delta"] as? NSNumber)?.doubleValue else { return nil }
573
+ return MKCoordinateRegion(center: CLLocationCoordinate2D(latitude: latitude, longitude: longitude), span: MKCoordinateSpan(latitudeDelta: latitudeDelta, longitudeDelta: longitudeDelta))
574
+ }
575
+
576
+ private static func color(_ value: String?) -> UIColor {
577
+ switch value?.lowercased() {
578
+ case "red": return .systemRed
579
+ case "green": return .systemGreen
580
+ case "orange": return .systemOrange
581
+ case "purple": return .systemPurple
582
+ case "yellow": return .systemYellow
583
+ default: return .systemBlue
584
+ }
585
+ }
586
+
587
+ private static func mapType(_ value: String?) -> MKMapType {
588
+ switch value?.lowercased() {
589
+ case "satellite": return .satellite
590
+ case "hybrid": return .hybrid
591
+ case "muted_standard": return .mutedStandard
592
+ default: return .standard
593
+ }
594
+ }
595
+
596
+ private static func locationPermissionGranted() -> Bool {
597
+ let status: CLAuthorizationStatus
598
+ if #available(iOS 14.0, *) {
599
+ status = CLLocationManager().authorizationStatus
600
+ } else {
601
+ status = CLLocationManager.authorizationStatus()
602
+ }
603
+ return status == .authorizedAlways || status == .authorizedWhenInUse
604
+ }
605
+ }
606
+
429
607
  /// A WebView-free host for the first stx native vertical slice. The bundled
430
608
  /// JavaScript sends whole, compiled view trees to UIKit and receives control
431
609
  /// events and Craft API replies through JavaScriptCore.
@@ -781,11 +959,28 @@ private class CraftNativeFlowView: UIStackView {
781
959
  var wrapReverse = false { didSet { if wrapReverse != oldValue { invalidateMeasurements() } } }
782
960
  var padding = UIEdgeInsets.zero {
783
961
  didSet {
784
- layoutMargins = padding
962
+ layoutMargins = effectivePadding
785
963
  isLayoutMarginsRelativeArrangement = true
786
964
  if padding != oldValue { invalidateMeasurements() }
787
965
  }
788
966
  }
967
+ /// Insets contributed by a native SafeAreaView root. Authored padding
968
+ /// remains separate so a reconciliation can remove either source without
969
+ /// leaving stale safe-area space behind.
970
+ var safeAreaPadding = UIEdgeInsets.zero {
971
+ didSet {
972
+ layoutMargins = effectivePadding
973
+ if safeAreaPadding != oldValue { invalidateMeasurements() }
974
+ }
975
+ }
976
+ var effectivePadding: UIEdgeInsets {
977
+ UIEdgeInsets(
978
+ top: padding.top + safeAreaPadding.top,
979
+ left: padding.left + safeAreaPadding.left,
980
+ bottom: padding.bottom + safeAreaPadding.bottom,
981
+ right: padding.right + safeAreaPadding.right
982
+ )
983
+ }
789
984
  override var axis: NSLayoutConstraint.Axis {
790
985
  didSet { if axis != oldValue { invalidateMeasurements() } }
791
986
  }
@@ -1020,8 +1215,9 @@ private class CraftNativeFlowView: UIStackView {
1020
1215
  let children = visibleChildren
1021
1216
  let flow = children.filter { $0.craftLayoutStyle.position != "absolute" }
1022
1217
  let horizontal = axis == .horizontal
1023
- let paddingWidth = padding.left + padding.right
1024
- let paddingHeight = padding.top + padding.bottom
1218
+ let contentPadding = effectivePadding
1219
+ let paddingWidth = contentPadding.left + contentPadding.right
1220
+ let paddingHeight = contentPadding.top + contentPadding.bottom
1025
1221
  let innerWidth = width.map { max(0, $0 - paddingWidth) }
1026
1222
  let innerHeight = height.map { max(0, $0 - paddingHeight) }
1027
1223
  let roomWidth = max(0, (width ?? maxWidth) - paddingWidth)
@@ -1046,7 +1242,7 @@ private class CraftNativeFlowView: UIStackView {
1046
1242
  let size = CGSize(width: width ?? contentSize.width + paddingWidth, height: height ?? contentSize.height + paddingHeight)
1047
1243
 
1048
1244
  if place {
1049
- let content = CGRect(x: padding.left, y: padding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
1245
+ let content = CGRect(x: contentPadding.left, y: contentPadding.top, width: max(0, size.width - paddingWidth), height: max(0, size.height - paddingHeight))
1050
1246
  for child in children where child.craftLayoutStyle.position == "absolute" {
1051
1247
  let style = child.craftLayoutStyle
1052
1248
  var fixedWidth = style.left != nil && style.right != nil
@@ -1432,8 +1628,8 @@ private class CraftNativeFlowView: UIStackView {
1432
1628
  ? (sharedLine?[offset].position ?? (mainOffset + item.mainLeading))
1433
1629
  : mainOffset + item.mainLeading
1434
1630
  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)
1631
+ ? CGRect(x: contentPadding.left + mainPosition, y: contentPadding.top + physicalLineOffset + physicalCrossPosition, width: item.main, height: item.cross)
1632
+ : CGRect(x: contentPadding.left + physicalLineOffset + physicalCrossPosition, y: contentPadding.top + mainPosition, width: item.cross, height: item.main)
1437
1633
  frames.append((item.view, frame))
1438
1634
  if !useSharedPositions { mainOffset += item.mainMargins + item.main + mainGap + between }
1439
1635
  }
@@ -1475,7 +1671,10 @@ private class CraftNativeFlowView: UIStackView {
1475
1671
  let align = style.alignSelf ?? alignItems
1476
1672
  var fixedWidth = style.width?.resolve(cellWidth)
1477
1673
  var fixedHeight = style.height?.resolve(nil)
1478
- if fixedWidth == nil && fixedHeight == nil && align == "stretch" { fixedWidth = available }
1674
+ // A cell's item is as wide as its column unless it sets a width
1675
+ // (or an aspect ratio derives one from its height), whatever its
1676
+ // height: a fixed-height swatch with no content was drawn 0 wide.
1677
+ if fixedWidth == nil && align == "stretch" && (fixedHeight == nil || style.aspectRatio == nil) { fixedWidth = available }
1479
1678
  (fixedWidth, fixedHeight) = aspectDimensions(style, width: fixedWidth, height: fixedHeight)
1480
1679
  let size = CraftNativeFlexLayout.measure(
1481
1680
  child,
@@ -1506,7 +1705,7 @@ private class CraftNativeFlowView: UIStackView {
1506
1705
  let alignment: UInt32 = align == "center" ? 1 : align == "flex-end" ? 2 : 0
1507
1706
  var explicitWidth = style.width?.resolve(cellWidth)
1508
1707
  var explicitHeight = style.height?.resolve(nil)
1509
- if explicitWidth == nil && explicitHeight == nil && align == "stretch" {
1708
+ if explicitWidth == nil && align == "stretch" && (explicitHeight == nil || style.aspectRatio == nil) {
1510
1709
  explicitWidth = max(0, cellWidth - style.marginLeft - style.marginRight)
1511
1710
  }
1512
1711
  (explicitWidth, explicitHeight) = aspectDimensions(style, width: explicitWidth, height: explicitHeight)
@@ -1526,7 +1725,7 @@ private class CraftNativeFlowView: UIStackView {
1526
1725
  if let sharedFrames {
1527
1726
  for (index, child) in children.enumerated() {
1528
1727
  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)))
1728
+ frames.append((child, CGRect(x: contentPadding.left + frame.x, y: contentPadding.top + frame.y, width: frame.width, height: frame.height)))
1530
1729
  }
1531
1730
  return CGSize(width: width, height: innerHeight ?? height)
1532
1731
  }
@@ -1550,7 +1749,7 @@ private class CraftNativeFlowView: UIStackView {
1550
1749
  case "stretch": rowExtra = gridFree / CGFloat(max(1, rowHeights.count))
1551
1750
  default: break
1552
1751
  }
1553
- var rowTop = padding.top + rowLeading
1752
+ var rowTop = contentPadding.top + rowLeading
1554
1753
  for (index, child) in children.enumerated() {
1555
1754
  let style = child.craftLayoutStyle
1556
1755
  let row = index / columns
@@ -1562,7 +1761,7 @@ private class CraftNativeFlowView: UIStackView {
1562
1761
  let availableHeight = max(0, rowHeight - style.marginTop - style.marginBottom)
1563
1762
  let childWidth = min(availableWidth, size.width)
1564
1763
  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
1764
+ let x = contentPadding.left + CGFloat(column) * (cellWidth + horizontalGap) + style.marginLeft
1566
1765
  + (align == "center" ? (availableWidth - childWidth) / 2 : align == "flex-end" ? availableWidth - childWidth : 0)
1567
1766
  let y = rowTop + style.marginTop
1568
1767
  + (align == "center" ? (availableHeight - childHeight) / 2 : align == "flex-end" ? availableHeight - childHeight : 0)
@@ -1574,6 +1773,41 @@ private class CraftNativeFlowView: UIStackView {
1574
1773
  }
1575
1774
  }
1576
1775
 
1776
+ /// A container that applies only the requested system safe-area edges. The
1777
+ /// host keeps the root edge-to-edge when this node is the screen root so the
1778
+ /// node, rather than the controller's outer stack, owns the insets.
1779
+ private final class CraftNativeSafeAreaView: CraftNativeFlowView {
1780
+ private var edges: Set<String> = ["top", "right", "bottom", "left"]
1781
+
1782
+ func setSafeAreaEdges(_ raw: Any?) {
1783
+ guard let values = raw as? [Any] else {
1784
+ edges = ["top", "right", "bottom", "left"]
1785
+ updateSafeAreaPadding()
1786
+ return
1787
+ }
1788
+ edges = Set(values.compactMap { value in
1789
+ guard let edge = value as? String, ["top", "right", "bottom", "left"].contains(edge) else { return nil }
1790
+ return edge
1791
+ })
1792
+ updateSafeAreaPadding()
1793
+ }
1794
+
1795
+ override func safeAreaInsetsDidChange() {
1796
+ super.safeAreaInsetsDidChange()
1797
+ updateSafeAreaPadding()
1798
+ }
1799
+
1800
+ private func updateSafeAreaPadding() {
1801
+ let insets = safeAreaInsets
1802
+ safeAreaPadding = UIEdgeInsets(
1803
+ top: edges.contains("top") ? insets.top : 0,
1804
+ left: edges.contains("left") ? insets.left : 0,
1805
+ bottom: edges.contains("bottom") ? insets.bottom : 0,
1806
+ right: edges.contains("right") ? insets.right : 0
1807
+ )
1808
+ }
1809
+ }
1810
+
1577
1811
  /// A flex container that responds to the controller's keyboard overlap.
1578
1812
  /// `padding` keeps the content visible without moving the field; `position`
1579
1813
  /// lifts the whole container; `height` reduces its viewport.
@@ -1773,6 +2007,18 @@ private final class CraftNativeModalView: UIView {
1773
2007
  }
1774
2008
  }
1775
2009
 
2010
+ /// Whether a scrolling view's gesture runs together with `other`. A scroll
2011
+ /// view is the delegate of its own pan as well as of its keyboard tap, so this
2012
+ /// answers for both. Everything runs together (the keyboard tap watches
2013
+ /// alongside a control's own gestures; the back-swipe and the tab swipe decide
2014
+ /// for themselves) except two scroll views' pans: run together, a drag on a
2015
+ /// nested scroll view scrolled its parent at the same time.
2016
+ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestureRecognizer) -> Bool {
2017
+ guard let scroll = gesture.view as? UIScrollView, gesture === scroll.panGestureRecognizer,
2018
+ let otherScroll = other.view as? UIScrollView, other === otherScroll.panGestureRecognizer else { return true }
2019
+ return false
2020
+ }
2021
+
1776
2022
  private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
1777
2023
  let contentStack = CraftNativeFlowView()
1778
2024
  private let keyboardTapGesture = UITapGestureRecognizer()
@@ -1994,6 +2240,22 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
1994
2240
  }
1995
2241
  }
1996
2242
 
2243
+ /// Applies the shared RefreshControl appearance props. UIKit's refresh
2244
+ /// control owns the spinner and optional title; keep the authored values
2245
+ /// on the control so a later reconciliation can clear them as well.
2246
+ func setRefreshAppearance(_ props: [String: Any]) {
2247
+ pullToRefresh.tintColor = (props["tintColor"] as? String).flatMap { UIColor(hex: $0) }
2248
+ guard let title = props["title"] as? String, !title.isEmpty else {
2249
+ pullToRefresh.attributedTitle = nil
2250
+ return
2251
+ }
2252
+ var attributes: [NSAttributedString.Key: Any] = [:]
2253
+ if let titleColor = (props["titleColor"] as? String).flatMap({ UIColor(hex: $0) }) {
2254
+ attributes[.foregroundColor] = titleColor
2255
+ }
2256
+ pullToRefresh.attributedTitle = NSAttributedString(string: title, attributes: attributes)
2257
+ }
2258
+
1997
2259
  var isRefreshingNow: Bool { pullToRefresh.isRefreshing }
1998
2260
 
1999
2261
  @objc private func refreshPulled() {
@@ -2025,7 +2287,7 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
2025
2287
  }
2026
2288
 
2027
2289
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2028
- true
2290
+ craftScrollGesture(gestureRecognizer, runsWith: otherGestureRecognizer)
2029
2291
  }
2030
2292
 
2031
2293
  func setAxis(_ axis: NSLayoutConstraint.Axis) {
@@ -2510,6 +2772,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2510
2772
  private var imageLoadHandlers: [ObjectIdentifier: String] = [:]
2511
2773
  private var imageLoadEndHandlers: [ObjectIdentifier: String] = [:]
2512
2774
  private var imageErrorHandlers: [ObjectIdentifier: String] = [:]
2775
+ /// Native links keep their target separate from optional press handlers.
2776
+ /// The compiler can emit an anchor as `TouchableOpacity`, so this map is
2777
+ /// intentionally shared by Link and the generic pressable path.
2778
+ private var linkTargets: [ObjectIdentifier: String] = [:]
2513
2779
  private var renderedRoot: RenderedNode?
2514
2780
  private weak var lastFocusedInput: UIView?
2515
2781
  private var flatListRows: [ObjectIdentifier: [String: RenderedNode]] = [:]
@@ -2582,11 +2848,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
2582
2848
  view.addSubview(rootStack)
2583
2849
  rootTopToSafeArea = rootStack.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor)
2584
2850
  rootTopToView = rootStack.topAnchor.constraint(equalTo: view.topAnchor)
2851
+ rootBottomToSafeArea = rootStack.bottomAnchor.constraint(equalTo: view.safeAreaLayoutGuide.bottomAnchor)
2852
+ rootBottomToView = rootStack.bottomAnchor.constraint(equalTo: view.bottomAnchor)
2853
+ rootLeadingToSafeArea = rootStack.leadingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.leadingAnchor)
2854
+ rootLeadingToView = rootStack.leadingAnchor.constraint(equalTo: view.leadingAnchor)
2855
+ rootTrailingToSafeArea = rootStack.trailingAnchor.constraint(equalTo: view.safeAreaLayoutGuide.trailingAnchor)
2856
+ rootTrailingToView = rootStack.trailingAnchor.constraint(equalTo: view.trailingAnchor)
2585
2857
  NSLayoutConstraint.activate([
2586
2858
  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)
2859
+ rootBottomToSafeArea!,
2860
+ rootLeadingToSafeArea!,
2861
+ rootTrailingToSafeArea!
2590
2862
  ])
2591
2863
  setupJavaScript()
2592
2864
  loadBundle()
@@ -3137,7 +3409,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3137
3409
  private var titleFromOptions = false
3138
3410
  private var rootTopToSafeArea: NSLayoutConstraint?
3139
3411
  private var rootTopToView: NSLayoutConstraint?
3412
+ private var rootBottomToSafeArea: NSLayoutConstraint?
3413
+ private var rootBottomToView: NSLayoutConstraint?
3414
+ private var rootLeadingToSafeArea: NSLayoutConstraint?
3415
+ private var rootLeadingToView: NSLayoutConstraint?
3416
+ private var rootTrailingToSafeArea: NSLayoutConstraint?
3417
+ private var rootTrailingToView: NSLayoutConstraint?
3418
+ private var rootIsSafeAreaView = false
3140
3419
  private var navigationBarHidden: Bool?
3420
+ /// A native `<StatusBar>` node overrides the app default for this screen.
3421
+ private var statusBarStyleOverride: UIStatusBarStyle?
3422
+ private var statusBarHiddenOverride: Bool?
3141
3423
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
3142
3424
  private var tabBarHidden = false
3143
3425
  /// Whether a back-swipe may leave this screen at all, the edge's
@@ -3158,6 +3440,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3158
3440
  /// full-screen timer is black edge to edge. nil is the app's.
3159
3441
  private var screenBackgroundColor: UIColor?
3160
3442
 
3443
+ override var preferredStatusBarStyle: UIStatusBarStyle {
3444
+ statusBarStyleOverride ?? .default
3445
+ }
3446
+
3447
+ override var prefersStatusBarHidden: Bool {
3448
+ statusBarHiddenOverride ?? false
3449
+ }
3450
+
3161
3451
  /// Whether this screen asked for a bar of its own through `setOptions`: a
3162
3452
  /// title, a large title or buttons, and not `hidden`. A tab's root screen
3163
3453
  /// in a hybrid app is shown bare unless it did.
@@ -3221,6 +3511,22 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3221
3511
  }
3222
3512
  }
3223
3513
 
3514
+ private func applyStatusBar(_ props: [String: Any]) {
3515
+ let style: UIStatusBarStyle?
3516
+ switch (props["barStyle"] as? String)?.lowercased() {
3517
+ case "light-content", "light": style = .lightContent
3518
+ case "dark-content", "dark": style = .darkContent
3519
+ case "default": style = .default
3520
+ default: style = nil
3521
+ }
3522
+ let hidden = props["hidden"] as? Bool
3523
+ if statusBarStyleOverride != style || statusBarHiddenOverride != hidden {
3524
+ statusBarStyleOverride = style
3525
+ statusBarHiddenOverride = hidden
3526
+ setNeedsStatusBarAppearanceUpdate()
3527
+ }
3528
+ }
3529
+
3224
3530
  /// A bar button from `{ id, symbol | title }`. Its tap is the event
3225
3531
  /// `navButton` with `{ id }`.
3226
3532
  private func navigationButton(_ spec: [String: Any]) -> UIBarButtonItem? {
@@ -3417,10 +3723,16 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3417
3723
  // effect, rather than over the bar's back button. Other screens stay
3418
3724
  // inside the safe area.
3419
3725
  let edgeToEdge = scroll != nil && (prefersLargeTitle == true || scroll === leading(renderedRoot))
3420
- if rootTopToView?.isActive != edgeToEdge {
3421
- rootTopToSafeArea?.isActive = !edgeToEdge
3422
- rootTopToView?.isActive = edgeToEdge
3423
- }
3726
+ let fullEdgeToEdge = rootIsSafeAreaView
3727
+ let topEdgeToEdge = fullEdgeToEdge || edgeToEdge
3728
+ rootTopToSafeArea?.isActive = !topEdgeToEdge
3729
+ rootTopToView?.isActive = topEdgeToEdge
3730
+ rootBottomToSafeArea?.isActive = !fullEdgeToEdge
3731
+ rootBottomToView?.isActive = fullEdgeToEdge
3732
+ rootLeadingToSafeArea?.isActive = !fullEdgeToEdge
3733
+ rootLeadingToView?.isActive = fullEdgeToEdge
3734
+ rootTrailingToSafeArea?.isActive = !fullEdgeToEdge
3735
+ rootTrailingToView?.isActive = fullEdgeToEdge
3424
3736
  scroll?.contentInsetAdjustmentBehavior = edgeToEdge ? .automatic : .never
3425
3737
  guard scroll !== trackedScrollView else { return }
3426
3738
  trackedScrollView = scroll
@@ -3898,6 +4210,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3898
4210
 
3899
4211
  private func renderCommitted(_ document: [String: Any]) {
3900
4212
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
4213
+ statusBarStyleOverride = nil
4214
+ statusBarHiddenOverride = nil
3901
4215
  let previous = renderedRoot
3902
4216
  let next = reconcile(document, identity: "root", path: "root", previous: previous)
3903
4217
  if previous?.view !== next.view {
@@ -3920,15 +4234,29 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3920
4234
  rootStack.setLayoutStyle(rootStyle, for: next.view)
3921
4235
  rootStack.invalidateMeasurements()
3922
4236
  renderedRoot = next
4237
+ rootIsSafeAreaView = next.type == "SafeAreaView"
3923
4238
  updateKeyboardAvoidingViews()
3924
4239
  syncGlobalKeyboardSafeArea()
3925
4240
  trackContentScrollView()
4241
+ setNeedsStatusBarAppearanceUpdate()
3926
4242
  restoreFocus(focused)
3927
4243
  }
3928
4244
 
3929
4245
  private func clearRenderedTree() {
3930
4246
  if let root = renderedRoot { detach(root, from: rootStack) }
3931
4247
  renderedRoot = nil
4248
+ rootIsSafeAreaView = false
4249
+ rootTopToSafeArea?.isActive = true
4250
+ rootTopToView?.isActive = false
4251
+ rootBottomToSafeArea?.isActive = true
4252
+ rootBottomToView?.isActive = false
4253
+ rootLeadingToSafeArea?.isActive = true
4254
+ rootLeadingToView?.isActive = false
4255
+ rootTrailingToSafeArea?.isActive = true
4256
+ rootTrailingToView?.isActive = false
4257
+ statusBarStyleOverride = nil
4258
+ statusBarHiddenOverride = nil
4259
+ setNeedsStatusBarAppearanceUpdate()
3932
4260
  syncGlobalKeyboardSafeArea()
3933
4261
  }
3934
4262
 
@@ -3963,14 +4291,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
3963
4291
  let result = current.view
3964
4292
  current.protocolId = node["id"] as? String
3965
4293
  current.style = style
4294
+ let linkTarget = ["href", "to"].lazy.compactMap { props[$0] as? String }
4295
+ .first(where: { !$0.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty })?
4296
+ .trimmingCharacters(in: .whitespacesAndNewlines)
4297
+ if ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type),
4298
+ props["disabled"] as? Bool != true, let linkTarget {
4299
+ linkTargets[ObjectIdentifier(result)] = linkTarget
4300
+ } else {
4301
+ linkTargets.removeValue(forKey: ObjectIdentifier(result))
4302
+ }
3966
4303
  result.accessibilityIdentifier = accessibilityIdentifier(node, props: props) ?? path
3967
4304
  if type != "View" && type != "SafeAreaView" && type != "KeyboardAvoidingView" && type != "ScrollView" && type != "SectionList" && type != "FlatList" && type != "Modal" {
3968
4305
  result.setContentHuggingPriority(.required, for: .vertical)
3969
4306
  }
3970
4307
  applyViewStyle(style, to: result, node: current)
4308
+ if type == "SafeAreaView", let safeArea = result as? CraftNativeSafeAreaView {
4309
+ safeArea.setSafeAreaEdges(props["edges"])
4310
+ }
3971
4311
  updateLayoutHandler(events["onLayout"], for: result)
4312
+ if type == "StatusBar" { applyStatusBar(props) }
3972
4313
 
3973
4314
  switch type {
4315
+ case "StatusBar", "RefreshControl":
4316
+ // StatusBar is a screen trait, and RefreshControl is consumed by
4317
+ // its nearest ScrollView. Neither contributes a visible child.
4318
+ result.isHidden = true
4319
+ result.isUserInteractionEnabled = false
3974
4320
  case "Text":
3975
4321
  let label = result as! UILabel
3976
4322
  label.numberOfLines = max(0, (props["numberOfLines"] as? NSNumber)?.intValue ?? 0)
@@ -4108,11 +4454,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4108
4454
  case "DatePicker":
4109
4455
  let picker = result as! UIDatePicker
4110
4456
  let mode = Self.datePickerMode(props["mode"])
4111
- if picker.datePickerMode != mode { picker.datePickerMode = mode }
4112
- let display: UIDatePickerStyle = props["display"] as? String == "inline" ? .inline
4457
+ // A countdown is wheels only: UIKit asserts (and the app aborts)
4458
+ // on a countdown in the compact or inline style, so the style is
4459
+ // settled before the mode becomes a countdown, and after it
4460
+ // stops being one.
4461
+ let display: UIDatePickerStyle = mode == .countDownTimer ? .wheels
4462
+ : props["display"] as? String == "inline" ? .inline
4113
4463
  : props["display"] as? String == "wheels" || props["display"] as? String == "spinner" ? .wheels
4114
4464
  : .compact
4115
- if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
4465
+ if mode == .countDownTimer {
4466
+ if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
4467
+ if picker.datePickerMode != mode { picker.datePickerMode = mode }
4468
+ } else {
4469
+ if picker.datePickerMode != mode { picker.datePickerMode = mode }
4470
+ if picker.preferredDatePickerStyle != display { picker.preferredDatePickerStyle = display }
4471
+ }
4116
4472
  // UIKit takes an interval that divides an hour, and ignores others.
4117
4473
  let interval = (props["minuteInterval"] as? NSNumber)?.intValue ?? 1
4118
4474
  picker.minuteInterval = (1...30).contains(interval) && 60 % interval == 0 ? interval : 1
@@ -4242,7 +4598,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4242
4598
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: image)
4243
4599
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: image)
4244
4600
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: image)
4245
- updateImage(image, source: props["source"])
4601
+ updateImage(image, source: props["source"], defaultSource: props["defaultSource"])
4246
4602
  case "ImageBackground":
4247
4603
  let background = result as! CraftNativeImageBackgroundView
4248
4604
  let requestedResizeMode = style["resizeMode"] as? String ?? props["resizeMode"] as? String
@@ -4252,7 +4608,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4252
4608
  updateAuxiliaryHandler(events["onLoad"], in: &imageLoadHandlers, for: background)
4253
4609
  updateAuxiliaryHandler(events["onLoadEnd"], in: &imageLoadEndHandlers, for: background)
4254
4610
  updateAuxiliaryHandler(events["onError"], in: &imageErrorHandlers, for: background)
4255
- updateImage(background, source: props["source"])
4611
+ updateImage(background, source: props["source"], defaultSource: props["defaultSource"])
4256
4612
  configureStack(background.contentStack, style: style)
4257
4613
  reconcileChildren(children, in: background.contentStack, parent: current, path: path, style: style)
4258
4614
  case "KeyboardAvoidingView":
@@ -4276,6 +4632,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4276
4632
  controls: (props["controls"] as? Bool) == true,
4277
4633
  resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String
4278
4634
  )
4635
+ case "Map":
4636
+ let map = result as! CraftNativeMapView
4637
+ map.configure(props: props, children: children) { [weak self, weak map] markerID in
4638
+ guard let self, let map, let handler = map.markerHandlers[markerID], !handler.isEmpty else { return }
4639
+ self.send(type: "EVENT", payload: [
4640
+ "handlerName": handler,
4641
+ "nativeEvent": ["id": markerID],
4642
+ ])
4643
+ }
4279
4644
  case "ScrollView", "SectionList":
4280
4645
  let scroll = result as! CraftNativeScrollView
4281
4646
  let flexDirection = style["flexDirection"] as? String
@@ -4301,15 +4666,26 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4301
4666
  updateAuxiliaryHandler(events["onScrollEndDrag"], in: &scrollEndHandlers, for: scroll)
4302
4667
  updateAuxiliaryHandler(events["onMomentumScrollBegin"], in: &scrollMomentumBeginHandlers, for: scroll)
4303
4668
  updateAuxiliaryHandler(events["onMomentumScrollEnd"], in: &scrollMomentumEndHandlers, for: scroll)
4304
- scroll.setRefreshHandler(nonEmptyHandler(events["onRefresh"]).map { [weak self] handler in
4669
+ let refreshNode = children.compactMap { $0 as? [String: Any] }.first {
4670
+ canonicalType($0["type"] as? String ?? "") == "RefreshControl"
4671
+ }
4672
+ let refreshProps = refreshNode?["props"] as? [String: Any] ?? [:]
4673
+ let refreshEvents = refreshNode?["events"] as? [String: String] ?? [:]
4674
+ let refreshHandlerName = nonEmptyHandler(refreshEvents["onRefresh"])
4675
+ ?? nonEmptyHandler(events["onRefresh"])
4676
+ scroll.setRefreshHandler(refreshHandlerName.map { [weak self] handler in
4305
4677
  { self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
4306
- }, refreshing: props["refreshing"] as? Bool == true)
4678
+ }, refreshing: (refreshNode == nil ? props["refreshing"] : refreshProps["refreshing"]) as? Bool == true)
4679
+ scroll.setRefreshAppearance(refreshProps)
4307
4680
  var contentStyle = style
4308
4681
  if let override = props["contentContainerStyle"] as? [String: Any] {
4309
4682
  contentStyle.merge(override) { _, next in next }
4310
4683
  }
4311
4684
  configureStack(scroll.contentStack, style: contentStyle)
4312
- reconcileChildren(children, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
4685
+ reconcileChildren(children.filter {
4686
+ guard let child = $0 as? [String: Any] else { return true }
4687
+ return canonicalType(child["type"] as? String ?? "") != "RefreshControl"
4688
+ }, in: scroll.contentStack, parent: current, path: path, style: contentStyle)
4313
4689
  case "FlatList":
4314
4690
  let list = result as! CraftNativeFlatList
4315
4691
  reconcileFlatList(
@@ -4326,7 +4702,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4326
4702
  reconcileChildren(children, in: stack, parent: current, path: path, style: style)
4327
4703
  }
4328
4704
  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" {
4705
+ 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
4706
  let disabled = props["disabled"] as? Bool == true
4331
4707
  updatePressHandler(disabled ? nil : nonEmptyHandler(events["onPress"]) ?? nonEmptyHandler(events["onClick"]), for: result)
4332
4708
  updatePressFeedback(
@@ -4391,6 +4767,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4391
4767
  return slider
4392
4768
  case "ActivityIndicator":
4393
4769
  return UIActivityIndicatorView(style: .medium)
4770
+ case "StatusBar", "RefreshControl":
4771
+ return CraftNativeFlowView()
4394
4772
  case "Image":
4395
4773
  return UIImageView()
4396
4774
  case "ImageBackground":
@@ -4399,6 +4777,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4399
4777
  return CraftNativeKeyboardAvoidingView()
4400
4778
  case "Video":
4401
4779
  return CraftNativeVideoView(host: self)
4780
+ case "Map":
4781
+ return CraftNativeMapView()
4402
4782
  case "Icon":
4403
4783
  let icon = CraftNativeIconView()
4404
4784
  icon.contentMode = .scaleAspectFit
@@ -4409,8 +4789,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4409
4789
  return CraftNativeFlatList()
4410
4790
  case "Svg":
4411
4791
  return CraftNativeSvgView()
4412
- case "View", "SafeAreaView":
4792
+ case "View":
4413
4793
  return CraftNativeFlowView()
4794
+ case "SafeAreaView":
4795
+ return CraftNativeSafeAreaView()
4414
4796
  case "TouchableOpacity", "TouchableHighlight", "Pressable":
4415
4797
  return CraftNativeFlowView()
4416
4798
  default:
@@ -4433,9 +4815,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4433
4815
  case "switch": return "Switch"
4434
4816
  case "slider": return "Slider"
4435
4817
  case "activityindicator": return "ActivityIndicator"
4818
+ case "statusbar": return "StatusBar"
4819
+ case "refreshcontrol": return "RefreshControl"
4436
4820
  case "image": return "Image"
4437
4821
  case "imagebackground": return "ImageBackground"
4438
4822
  case "video": return "Video"
4823
+ case "map": return "Map"
4439
4824
  case "icon": return "Icon"
4440
4825
  case "scrollview": return "ScrollView"
4441
4826
  case "sectionlist": return "SectionList"
@@ -4640,6 +5025,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4640
5025
  let id = ObjectIdentifier(node.view)
4641
5026
  if let modal = node.view as? CraftNativeModalView { modal.resetForTeardown() }
4642
5027
  handlers.removeValue(forKey: id)
5028
+ linkTargets.removeValue(forKey: id)
4643
5029
  pressInHandlers.removeValue(forKey: id)
4644
5030
  pressOutHandlers.removeValue(forKey: id)
4645
5031
  layoutHandlers.removeValue(forKey: id)
@@ -4754,7 +5140,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4754
5140
  let id = ObjectIdentifier(view)
4755
5141
  let handler = handler?.isEmpty == false ? handler : nil
4756
5142
  updateHandler(handler, for: view)
4757
- if handler != nil, tapRecognizers[id] == nil {
5143
+ let actionable = handler != nil || linkTargets[id] != nil
5144
+ if actionable, tapRecognizers[id] == nil {
4758
5145
  let recognizer = UITapGestureRecognizer(target: self, action: #selector(viewPressed(_:)))
4759
5146
  tapRecognizers[id] = recognizer
4760
5147
  view.addGestureRecognizer(recognizer)
@@ -4763,7 +5150,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4763
5150
  }
4764
5151
  // A container keeps receiving touches without a handler of its own,
4765
5152
  // 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 }
5153
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = actionable || longPressHandlers[id] != nil || view is CraftNativeFlowView }
4767
5154
  }
4768
5155
 
4769
5156
  private func updatePressFeedback(_ pressIn: String?, _ pressOut: String?, for view: UIView) {
@@ -4772,7 +5159,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4772
5159
  if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
4773
5160
  guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil else {
4774
5161
  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 }
5162
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
4776
5163
  return
4777
5164
  }
4778
5165
  if pressFeedbackRecognizers[id] == nil {
@@ -4801,7 +5188,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4801
5188
  if let recognizer = longPressRecognizers.removeValue(forKey: id) {
4802
5189
  view.removeGestureRecognizer(recognizer)
4803
5190
  }
4804
- if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || view is CraftNativeFlowView }
5191
+ if !(view is UIScrollView) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || view is CraftNativeFlowView }
4805
5192
  }
4806
5193
  }
4807
5194
 
@@ -5270,7 +5657,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5270
5657
  return ["localhost", "127.0.0.1", "::1"].contains(bare) || bare.hasSuffix(".localhost")
5271
5658
  }
5272
5659
 
5273
- private func updateImage(_ view: UIImageView, source: Any?) {
5660
+ private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
5274
5661
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
5275
5662
  let id = ObjectIdentifier(view)
5276
5663
  guard let uri, !uri.isEmpty else {
@@ -5284,7 +5671,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5284
5671
  imageTasks.removeValue(forKey: id)?.cancel()
5285
5672
  imageSources[id] = uri
5286
5673
  imageErrors.removeValue(forKey: id)
5287
- view.image = nil
5674
+ view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
5288
5675
  view.accessibilityValue = nil
5289
5676
  emitImageEvent(view, handler: imageLoadStartHandlers[id])
5290
5677
 
@@ -5332,6 +5719,20 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5332
5719
  imageFailure(view, uri: uri, message: "Unsupported image source")
5333
5720
  }
5334
5721
 
5722
+ /// `defaultSource` is intentionally local-only: data and bundled assets
5723
+ /// can be shown before a network image arrives without starting another
5724
+ /// request or bypassing the source's URL policy.
5725
+ private func immediateImage(_ source: Any?) -> UIImage? {
5726
+ let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String
5727
+ guard let uri, !uri.isEmpty else { return nil }
5728
+ if uri.hasPrefix("data:image/"),
5729
+ let comma = uri.firstIndex(of: ","),
5730
+ let data = Data(base64Encoded: String(uri[uri.index(after: comma)...])) {
5731
+ return UIImage(data: data)
5732
+ }
5733
+ return !uri.contains(":") ? UIImage(named: uri) : nil
5734
+ }
5735
+
5335
5736
  private func imageFailure(_ view: UIImageView, uri: String?, message: String) {
5336
5737
  imageErrors[ObjectIdentifier(view)] = message
5337
5738
  view.image = nil
@@ -5425,6 +5826,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5425
5826
 
5426
5827
  @objc private func buttonPressed(_ sender: UIButton) {
5427
5828
  let focused = firstResponder(in: rootStack) ?? lastFocusedInput
5829
+ if let target = linkTargets[ObjectIdentifier(sender)] {
5830
+ hybridEvents?.nativeScreen(self, open: target)
5831
+ return
5832
+ }
5428
5833
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
5429
5834
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]])
5430
5835
  restoreFocus(focused)
@@ -5534,7 +5939,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5534
5939
  let value = snappedSliderValue(sender.value, for: sender)
5535
5940
  if sender.value != value { sender.value = value }
5536
5941
  guard let handler = handlers[ObjectIdentifier(sender)] else { return }
5537
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": value]])
5942
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(value)]])
5943
+ }
5944
+
5945
+ /// A slider's value as the script reads it: the shortest decimal that is
5946
+ /// the same Float, so a step of 0.1 gives 0.8 and not 0.800000011920929.
5947
+ static func sliderEventValue(_ value: Float) -> Double {
5948
+ Double(value.description) ?? Double(value)
5538
5949
  }
5539
5950
 
5540
5951
  /// `@change` on a DatePicker: the value as `<input type="date|time|
@@ -5631,7 +6042,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5631
6042
 
5632
6043
  @objc private func sliderFinished(_ sender: UISlider) {
5633
6044
  guard let handler = sliderCompleteHandlers[ObjectIdentifier(sender)] else { return }
5634
- send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": snappedSliderValue(sender.value, for: sender)]])
6045
+ send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": Self.sliderEventValue(snappedSliderValue(sender.value, for: sender))]])
5635
6046
  }
5636
6047
 
5637
6048
  func numberOfComponents(in pickerView: UIPickerView) -> Int { 1 }
@@ -5681,8 +6092,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5681
6092
  }
5682
6093
 
5683
6094
  @objc private func viewPressed(_ sender: UITapGestureRecognizer) {
5684
- guard let view = sender.view, let handler = handlers[ObjectIdentifier(view)] else { return }
6095
+ guard let view = sender.view else { return }
5685
6096
  let id = ObjectIdentifier(view)
6097
+ if let target = linkTargets[id] {
6098
+ hybridEvents?.nativeScreen(self, open: target)
6099
+ return
6100
+ }
6101
+ guard let handler = handlers[id] else { return }
5686
6102
  if pressInHandlers[id] == nil, pressOutHandlers[id] == nil,
5687
6103
  let pressable = view as? CraftNativeFlowView, let activeOpacity = pressable.pressActiveOpacity {
5688
6104
  UIView.animate(withDuration: 0.1, animations: { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }) { _ in