@craft-native/ios 0.0.251 → 0.0.262

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.
package/README.md CHANGED
@@ -77,11 +77,19 @@ decoration. Resuming does not replay it. Available on both native renderers.
77
77
  The iOS support is built into the main `craft` CLI:
78
78
 
79
79
  `Pressable`, `TouchableOpacity`, and `TouchableHighlight` render as native
80
- pressable flow containers. `Modal` renders a parent-scoped blocking layer in native mode, with `visible`,
81
- `transparent`, `animationType`, `onShow`, `onDismiss`, and `onRequestClose`
82
- support. `FlatList` item roots receive the same `onLayout` callback as ordinary native
80
+ pressable flow containers. `Modal` renders a parent-scoped native layer in native mode, with `visible`,
81
+ `transparent`, `nonModal`, `dismissOnOutsideTap`, `animationType`, `onShow`,
82
+ `onDismiss`, and `onRequestClose` support. Non-modal popups remain pass-through
83
+ unless outside-tap dismissal is explicitly enabled. `FlatList` item roots receive the same `onLayout` callback as ordinary native
83
84
  views when rows are materialized or recycled.
84
85
 
86
+ `View.measure` and non-modal `left`/`top` placement use the same `screenViewport`
87
+ coordinate space. Safe-area and navigation-bar offsets are not added implicitly;
88
+ anchored callers must clamp against the measured viewport when needed. Re-run
89
+ measurement after the anchor or screen root emits `onLayout`, and after every
90
+ `onScroll` from an ancestor; those are the host-neutral invalidation signals for
91
+ repositioning an anchored popup.
92
+
85
93
  ```bash
86
94
  bun add craft-native
87
95
  ```
@@ -151,6 +159,9 @@ bounds and corners through layout changes.
151
159
 
152
160
  The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `KeyboardAvoidingView`, `StatusBar`, `RefreshControl`, `Text`, `Button`, `Link`, `Checkbox`, `TextInput`, `Picker`, `DatePicker`, `Switch`, `Slider`, `ActivityIndicator`, `Icon`, `Image`, `ImageBackground`, `Video`, `Map`, `Svg`, `G`, `SvgText`, `ScrollView`, `SectionList`, and recycling `FlatList` nodes as UIKit controls. `StatusBar` and `RefreshControl` are screen/scroll utility nodes rather than visible children. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays, `strokeWidth` is expressed in logical points, and the camera supports controlled `region`, one-time `initialRegion`, and `fitToBounds`. `Svg` is a DOM-free drawing surface; grouped `G` and `SvgText` nodes are painted inside it. `Icon` uses SF Symbols with the shared symbol/name contract and accessibility metadata. `SectionList` uses the same native scroll host for ordinary `.stx` slot children; grouped section data should use `FlatList` until the section-data protocol is available. `FlatList` uses `UICollectionView` with stable keyed rows, incremental diffs, grids, horizontal and inverted layouts, list chrome, and end-reached pagination. Native views support `onLayout` callbacks with point-based frame measurements that refresh after safe-area or orientation changes. Flex items support `flex`, `flexGrow`, `flexShrink`, and numeric `flexBasis` for main-axis space distribution. Native screens also expose the versioned capability bridge: JSON storage and SQLite survive relaunch, lifecycle and deep-link events are scoped to the top route, and local notifications use `UNUserNotificationCenter`. Enable each capability in `craft.config.json`; disabled calls preserve Craft's `CAPABILITY_DISABLED` rejection code and every request has deterministic timeout/cancellation behavior. The default remains `web`; existing iOS apps are unchanged. `Checkbox` supports controlled `checked`/`value`, indeterminate state, and boolean `onValueChange`; `Picker` renders `<select>`/`<option>` values with `onValueChange` or `onChange`; `DatePicker` emits local date/time values plus ISO and millisecond `timestamp` fields; `Switch` supports state-aware `trackColor`, `thumbColor`, and iOS `ios_backgroundColor`; `Slider` supports track/thumb tint props and `step` snapping; `Image` reports load lifecycle events and structured failures; `Video` uses an `AVPlayerViewController` attached to the native screen for URI playback, native controls, and route-safe teardown. `craft.navigation.setOptions` also accepts a `tabAccessory` destination beside the tab pill; it uses an app-local `path`, `title`, `symbol`, optional `badge`, and `accessibilityLabel`, and `null` clears it. The portable component, capability, list, style, image-source, event, accessibility, and reconciliation behavior is specified in the [Native STX guide](../../docs/guides/native-stx.md).
153
161
 
162
+ `FlatList` rows are authored as keyed `:for` child rows in native STX; the
163
+ maintained protocol does not serialize `data`/`renderItem` callbacks.
164
+
154
165
  JavaScriptCore has no DOM `window`; Craft aliases `globalThis.window` to
155
166
  `globalThis` before installing the native bridge so browser-safe SDK calls such
156
167
  as `window.craft.dialog.actionSheet()` reach the native capability host. This
@@ -220,9 +231,9 @@ Follow it with `selected` and content checks, then a full `swipe` to verify the
220
231
  next gesture still works. Synchronized XCTest gestures do not replace separate
221
232
  direct-input rapid-tap checks.
222
233
 
223
- The iOS host uses the shared tree-layout ABI for eligible definite-size,
224
- non-grid, non-wrapped containers and retains a local fallback for older
225
- runtimes or unsupported styles. Both paths implement wrapped-line and grid
234
+ The iOS host uses the shared tree-layout ABI for eligible definite-size
235
+ containers, including reverse and wrap-reverse direct flows, and retains a
236
+ local fallback for older runtimes or unsupported styles. Both paths implement wrapped-line and grid
226
237
  `alignContent`, including `space-evenly`, so a missing optional shared symbol
227
238
  does not change placement semantics.
228
239
  Reverse flex directions also retain wrapped line order on the cross axis while
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.251",
3
+ "version": "0.0.262",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -686,6 +686,9 @@ final class CraftChrome: ObservableObject {
686
686
  /// Whether a hybrid page lets the shell claim horizontal swipes for tabs.
687
687
  /// Native screens use their own per-screen `tabSwipeEnabled` option.
688
688
  private(set) var tabSwipeEnabled = true
689
+ /// The selected tab highlight's corner radius. Nil preserves the default
690
+ /// capsule, while a finite non-negative value opts into a rounded rect.
691
+ private(set) var tabSelectionCornerRadius: CGFloat?
689
692
 
690
693
  weak var webView: WKWebView?
691
694
  /// What the tab bar covers at the bottom of the screen, in points.
@@ -823,6 +826,7 @@ final class CraftChrome: ObservableObject {
823
826
  switch body["type"] as? String {
824
827
  case "tabBar":
825
828
  pendingHide?.cancel()
829
+ tabSelectionCornerRadius = Self.selectionCornerRadius(body["selection"])
826
830
  tabs = (body["tabs"] as? [[String: Any]] ?? []).compactMap { item in
827
831
  guard let id = item["id"] as? String, let title = item["title"] as? String else { return nil }
828
832
  let badge = (item["badge"] as? String).flatMap { $0.isEmpty ? nil : $0 }
@@ -872,6 +876,15 @@ final class CraftChrome: ObservableObject {
872
876
  }
873
877
  }
874
878
 
879
+ private static func selectionCornerRadius(_ value: Any?) -> CGFloat? {
880
+ guard let selection = value as? [String: Any],
881
+ let raw = selection["cornerRadius"] as? NSNumber,
882
+ raw.doubleValue.isFinite,
883
+ raw.doubleValue >= 0,
884
+ raw.doubleValue <= Double(CGFloat.greatestFiniteMagnitude) else { return nil }
885
+ return CGFloat(raw.doubleValue)
886
+ }
887
+
875
888
  /// A tap on a tab: the page navigates, and says which tab is current.
876
889
  ///
877
890
  /// No haptic. UITabBar plays none, and a tab bar that ticks on every tap is
@@ -1175,7 +1188,7 @@ struct CraftTabBarView: View {
1175
1188
  }
1176
1189
  .foregroundStyle(isSelected ? selectedColor : Color.primary)
1177
1190
  .frame(maxWidth: .infinity, minHeight: 54)
1178
- .contentShape(Capsule())
1191
+ .contentShape(CraftTabSelectionShape(cornerRadius: chrome.tabSelectionCornerRadius))
1179
1192
  }
1180
1193
  .buttonStyle(.plain)
1181
1194
  .accessibilityShowsLargeContentViewer {
@@ -1188,7 +1201,11 @@ struct CraftTabBarView: View {
1188
1201
  }
1189
1202
  }
1190
1203
  .background {
1191
- CraftTabSelectionIndicator(selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }), count: chrome.tabs.count)
1204
+ CraftTabSelectionIndicator(
1205
+ selected: chrome.tabs.firstIndex(where: { $0.id == chrome.selected }),
1206
+ count: chrome.tabs.count,
1207
+ cornerRadius: chrome.tabSelectionCornerRadius
1208
+ )
1192
1209
  }
1193
1210
  }
1194
1211
 
@@ -1230,18 +1247,33 @@ struct CraftTabBarView: View {
1230
1247
  }
1231
1248
  }
1232
1249
 
1250
+ private struct CraftTabSelectionShape: Shape {
1251
+ let cornerRadius: CGFloat?
1252
+
1253
+ func path(in rect: CGRect) -> Path {
1254
+ if let cornerRadius {
1255
+ return RoundedRectangle(
1256
+ cornerRadius: min(cornerRadius, min(rect.width, rect.height) / 2),
1257
+ style: .continuous
1258
+ ).path(in: rect)
1259
+ }
1260
+ return Capsule().path(in: rect)
1261
+ }
1262
+ }
1263
+
1233
1264
  private struct CraftTabSelectionIndicator: View {
1234
1265
  @ObservedObject private var progress = CraftChrome.shared.tabSwipeProgress
1235
1266
  let selected: Int?
1236
1267
  let count: Int
1268
+ let cornerRadius: CGFloat?
1237
1269
 
1238
1270
  var body: some View {
1239
1271
  GeometryReader { geometry in
1240
1272
  if let position = progress.position ?? selected.map({ CGFloat($0) }) {
1241
1273
  let width = geometry.size.width / CGFloat(max(1, count))
1242
- Capsule()
1243
- .fill(Color.primary.opacity(0.09))
1274
+ CraftTabSelectionShape(cornerRadius: cornerRadius)
1244
1275
  .frame(width: width, height: geometry.size.height)
1276
+ .foregroundStyle(Color.primary.opacity(0.09))
1245
1277
  .offset(x: position * width)
1246
1278
  }
1247
1279
  }
@@ -43,6 +43,19 @@ private final class CraftHealthWorkoutDelegate: NSObject, HKWorkoutSessionDelega
43
43
  }
44
44
  }
45
45
 
46
+ @available(iOS 26.0, *)
47
+ private final class CraftHealthWorkoutBuilderDelegate: NSObject, HKLiveWorkoutBuilderDelegate {
48
+ weak var owner: CraftHealth?
49
+
50
+ init(owner: CraftHealth) { self.owner = owner }
51
+
52
+ func workoutBuilder(_ workoutBuilder: HKLiveWorkoutBuilder, didCollectDataOf collectedTypes: Set<HKSampleType>) {
53
+ owner?.emitLiveWorkoutMetrics(workoutBuilder)
54
+ }
55
+
56
+ func workoutBuilderDidCollectEvent(_ workoutBuilder: HKLiveWorkoutBuilder) {}
57
+ }
58
+
46
59
  /// Apple Health for the whole app: the web page's bridge (`craft.health` in
47
60
  /// the hybrid's web view, CraftApp.swift) and native screens alike
48
61
  /// (`Health` through CraftNativeActions), so a screen drawn natively reads
@@ -837,12 +850,15 @@ final class CraftHealth {
837
850
  let session = try HKWorkoutSession(healthStore: healthStore, configuration: configuration)
838
851
  let builder = session.associatedWorkoutBuilder()
839
852
  let delegate = CraftHealthWorkoutDelegate(id: { [weak self] in self?.liveWorkoutID ?? "" }) { [weak self] detail in self?.sendWorkout(detail) }
853
+ let metricsDelegate = CraftHealthWorkoutBuilderDelegate(owner: self)
840
854
  session.delegate = delegate
855
+ builder.delegate = metricsDelegate
841
856
  builder.shouldCollectWorkoutEvents = true
842
857
  builder.dataSource = HKLiveWorkoutDataSource(healthStore: healthStore, workoutConfiguration: configuration)
843
858
  liveWorkoutSession = session
844
859
  liveWorkoutBuilder = builder
845
860
  liveWorkoutDelegate = delegate
861
+ liveWorkoutBuilderDelegate = metricsDelegate
846
862
  liveWorkoutID = UUID().uuidString.lowercased()
847
863
  liveWorkoutType = type
848
864
  liveWorkoutStart = Date()
@@ -923,11 +939,32 @@ final class CraftHealth {
923
939
  liveWorkoutSession = nil
924
940
  liveWorkoutBuilder = nil
925
941
  liveWorkoutDelegate = nil
942
+ liveWorkoutBuilderDelegate = nil
926
943
  liveWorkoutID = nil
927
944
  liveWorkoutType = nil
928
945
  liveWorkoutStart = nil
929
946
  }
930
947
 
948
+ @available(iOS 26.0, *)
949
+ fileprivate func emitLiveWorkoutMetrics(_ builder: HKLiveWorkoutBuilder) {
950
+ guard let id = liveWorkoutID else { return }
951
+ var detail: [String: Any] = ["id": id, "event": "metrics", "date": Date().timeIntervalSince1970 * 1000]
952
+ let bpm = HKUnit.count().unitDivided(by: .minute())
953
+ if let type = HKQuantityType.quantityType(forIdentifier: .heartRate),
954
+ let value = builder.statistics(for: type)?.mostRecentQuantity()?.doubleValue(for: bpm), value.isFinite {
955
+ detail["heartRateBpm"] = value
956
+ }
957
+ if let type = HKQuantityType.quantityType(forIdentifier: .activeEnergyBurned),
958
+ let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .kilocalorie()), value.isFinite {
959
+ detail["activeEnergyCalories"] = value
960
+ }
961
+ if let type = HKQuantityType.quantityType(forIdentifier: .distanceWalkingRunning),
962
+ let value = builder.statistics(for: type)?.sumQuantity()?.doubleValue(for: .meter()), value.isFinite {
963
+ detail["distanceMeters"] = value
964
+ }
965
+ onMain { [weak self] in self?.sendWorkout(detail) }
966
+ }
967
+
931
968
  private static func workoutActivity(_ type: String) -> HKWorkoutActivityType? {
932
969
  switch type {
933
970
  case "running": return .running
@@ -965,6 +1002,7 @@ final class CraftHealth {
965
1002
  private var liveWorkoutSession: AnyObject?
966
1003
  private var liveWorkoutBuilder: AnyObject?
967
1004
  private var liveWorkoutDelegate: AnyObject?
1005
+ private var liveWorkoutBuilderDelegate: AnyObject?
968
1006
  private var liveWorkoutID: String?
969
1007
  private var liveWorkoutType: String?
970
1008
  private var liveWorkoutStart: Date?
@@ -1076,6 +1076,18 @@ final class CraftHybridWebSlot: UIViewController {
1076
1076
  }
1077
1077
  }
1078
1078
 
1079
+ /// A native stack lets the top screen consume Back for its presented modal
1080
+ /// before UIKit removes the screen from the navigation stack.
1081
+ final class CraftNativeNavigationController: UINavigationController {
1082
+ override func popViewController(animated: Bool) -> UIViewController? {
1083
+ if let screen = topViewController as? CraftNativeScreenController,
1084
+ screen.handleBackForModal() {
1085
+ return nil
1086
+ }
1087
+ return super.popViewController(animated: animated)
1088
+ }
1089
+ }
1090
+
1079
1091
  final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
1080
1092
  private let config: CraftConfig
1081
1093
  private let hybrid = CraftHybrid.shared
@@ -1175,6 +1187,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1175
1187
  tabSwipeRecognizers.append(recognizer)
1176
1188
  }
1177
1189
 
1190
+ private func shouldCompleteTabSwipe(progress: CGFloat, speed: CGFloat) -> Bool {
1191
+ speed > -700 && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1192
+ }
1193
+
1178
1194
  @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1179
1195
  // A finger that lands while the last swipe settles takes the screens
1180
1196
  // from where they are, as a paging scroll view does. This swipe used
@@ -1186,7 +1202,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1186
1202
  let translation = recognizer.translation(in: view).x
1187
1203
  let velocity = recognizer.velocity(in: view).x
1188
1204
  switch recognizer.state {
1189
- case .changed:
1205
+ case .began, .changed:
1206
+ // Catching a settle can begin at zero translation. Keep its
1207
+ // committed selection until the new finger actually moves.
1208
+ if recognizer.state == .began, caughtSettle != nil, abs(translation) <= 1 { return }
1190
1209
  if activeTabSwipeTarget == nil {
1191
1210
  let direction = abs(translation) > 1 ? translation : velocity
1192
1211
  guard abs(direction) > 1 else { return }
@@ -1201,9 +1220,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1201
1220
  // the caught screens finish where they were going.
1202
1221
  if caughtSettle != nil { settleCaughtScreens(); return }
1203
1222
  guard recognizer.state == .ended else { return }
1204
- let delta = abs(translation) >= 32 ? translation : velocity
1205
- guard abs(delta) >= 32 else { return }
1206
- let next = delta < 0 ? index + 1 : index - 1
1223
+ // UIKit can coalesce a short gesture without a changed
1224
+ // callback. Keep the same completion rule as the animated
1225
+ // path instead of treating any 32-point drag as a full page.
1226
+ let direction = abs(translation) > 1 ? translation : velocity
1227
+ guard abs(direction) > 1 else { return }
1228
+ let sign: CGFloat = direction < 0 ? -1 : 1
1229
+ guard shouldCompleteTabSwipe(progress: translation * sign, speed: velocity * sign) else { return }
1230
+ let next = direction < 0 ? index + 1 : index - 1
1207
1231
  guard tabs.indices.contains(next) else { return }
1208
1232
  CraftChrome.shared.swipe(to: tabs[next])
1209
1233
  return
@@ -1216,8 +1240,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1216
1240
  let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
1217
1241
  let progress = (translation + tabSwipeBase) * direction
1218
1242
  let speed = velocity * direction
1219
- let complete = recognizer.state == .ended && speed > -700
1220
- && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1243
+ let complete = recognizer.state == .ended && shouldCompleteTabSwipe(progress: progress, speed: speed)
1221
1244
  finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
1222
1245
  CraftFramePacing.shared.end("tab swipe")
1223
1246
  self?.activeTabSwipeTarget = nil
@@ -1566,7 +1589,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1566
1589
  // Constructing the navigation controller can load the root's view and
1567
1590
  // mount its script immediately. Set its initial focus before that.
1568
1591
  if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
1569
- let nav = UINavigationController(rootViewController: root)
1592
+ let nav = CraftNativeNavigationController(rootViewController: root)
1570
1593
  nav.delegate = self
1571
1594
  nav.view.backgroundColor = config.resolvedBackgroundColor
1572
1595
  nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
@@ -2080,7 +2103,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2080
2103
  /// The edge swipe goes ahead of the web view's own gestures, which
2081
2104
  /// otherwise hold every touch over the page until the page has answered it.
2082
2105
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
2083
- gestureRecognizer === visibleStack?.interactivePopGestureRecognizer
2106
+ if gestureRecognizer === visibleStack?.interactivePopGestureRecognizer { return true }
2107
+ guard tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }),
2108
+ let scroll = otherGestureRecognizer.view as? UIScrollView,
2109
+ otherGestureRecognizer === scroll.panGestureRecognizer,
2110
+ scroll.isScrollEnabled else { return false }
2111
+ // A vertical list must not suppress the shell's horizontal tab pan.
2112
+ // Horizontal content keeps ownership of its own drag instead.
2113
+ return !scroll.alwaysBounceHorizontal && scroll.contentSize.width <= scroll.bounds.width + 1
2084
2114
  }
2085
2115
 
2086
2116
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldBeRequiredToFailBy otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -42,7 +42,7 @@ enum CraftNativeActions {
42
42
  static func capabilities(config: CraftConfig) -> [String] {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
46
46
  values.append("storageCompareAndSet")
47
47
  values.append("storageSetIfAbsent")
48
48
  values.append("viewScroll")
@@ -63,11 +63,22 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
63
63
  onRecycle = nil
64
64
  }
65
65
 
66
+ /// Whether the row view this cell is meant to show is in it. UIKit
67
+ /// can prepare a second cell for a row (a prefetch, an update), and
68
+ /// the row's one view then moves there: the first cell keeps its
69
+ /// height and draws nothing.
70
+ var showsHostedView: Bool { hostedView.map { $0.superview === contentView } ?? true }
71
+
66
72
  func host(_ view: UIView, identity: String) {
67
73
  if let shown = representedIdentity, shown != identity { recycle() }
68
74
  representedIdentity = identity
69
- guard hostedView !== view else { return }
70
- hostedView?.removeFromSuperview()
75
+ // The same view, already here. A view that left for another cell
76
+ // and is asked for again is put back below.
77
+ guard hostedView !== view || view.superview !== contentView else { return }
78
+ // Only a view still in this cell is taken out of it: one that has
79
+ // since moved to another cell is that cell's now, and pulling it
80
+ // from there blanked a row on screen.
81
+ if let shown = hostedView, shown !== view, shown.superview === contentView { shown.removeFromSuperview() }
71
82
  hostedView = view
72
83
  view.translatesAutoresizingMaskIntoConstraints = false
73
84
  contentView.addSubview(view)
@@ -81,10 +92,16 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
81
92
 
82
93
  func releaseHostedView() {
83
94
  recycle()
84
- hostedView?.removeFromSuperview()
95
+ if let shown = hostedView, shown.superview === contentView { shown.removeFromSuperview() }
85
96
  hostedView = nil
86
97
  }
87
98
 
99
+ /// Puts this cell's row view back when another cell took it.
100
+ func reclaimHostedView() {
101
+ guard let identity = representedIdentity, let view = hostedView, view.superview !== contentView else { return }
102
+ host(view, identity: identity)
103
+ }
104
+
88
105
  override func preferredLayoutAttributesFitting(
89
106
  _ layoutAttributes: UICollectionViewLayoutAttributes
90
107
  ) -> UICollectionViewLayoutAttributes {
@@ -279,10 +296,22 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
279
296
  override func layoutSubviews() {
280
297
  updateItemEstimate()
281
298
  super.layoutSubviews()
299
+ reclaimVisibleRows()
282
300
  craftApplyCornerRadius()
283
301
  scrollToPendingTarget()
284
302
  }
285
303
 
304
+ /// A row on screen whose view went to a cell UIKit prepared for the same
305
+ /// row and then did not show gets it back, rather than staying blank.
306
+ private func reclaimVisibleRows() {
307
+ for case let cell as Cell in visibleCells where !cell.showsHostedView { cell.reclaimHostedView() }
308
+ }
309
+
310
+ /// Every row on screen draws its own view (simulator-hosted XCTest).
311
+ var visibleRowsShowTheirViews: Bool {
312
+ visibleCells.allSatisfy { ($0 as? Cell)?.showsHostedView ?? true }
313
+ }
314
+
286
315
  /// Scroll to a virtualized row identified by its native testID, id, or key.
287
316
  /// The row need not be mounted yet; the data snapshot is enough to find its
288
317
  /// index, and UIKit will mount it as part of the scroll.
@@ -371,24 +400,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
371
400
  @objc private func keyboardTap() {
372
401
  guard keyboardShouldPersistTaps != "always" else { return }
373
402
  let point = keyboardTapGesture.location(in: self)
374
- var hit = hitTest(point, with: nil)
375
- var onControl = false
376
- var onButton = false
377
- while let view = hit, view !== self {
378
- if view is UIButton { onButton = true }
379
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
380
- hit = view.superview
403
+ craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
404
+ self.craftScreenController?.forgetFocusedInput()
381
405
  }
382
- if keyboardShouldPersistTaps == "handled", onControl { return }
383
- // A button keeps the keyboard: its press gives the focus straight
384
- // back. Ending editing first only let the keyboard's inset go while
385
- // the finger was down, the button moved out from under it, and the
386
- // press was lost.
387
- if onButton { return }
388
- // Otherwise the keyboard goes; after a tap on nothing, for good (the
389
- // next render must not bring the last input back).
390
- if !onControl { craftScreenController?.forgetFocusedInput() }
391
- window?.endEditing(true)
392
406
  }
393
407
 
394
408
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -1090,6 +1090,8 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1090
1090
  private var playingObservation: NSKeyValueObservation?
1091
1091
  private var timeObserver: Any?
1092
1092
  private var loops = false
1093
+ private var requestedTime: Double?
1094
+ private var pendingSeek: Double?
1093
1095
  private var wasPlaying: Bool?
1094
1096
  private var playIntent: Bool?
1095
1097
  private var userPaused = false
@@ -1097,7 +1099,8 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1097
1099
  /// This video holds a claim on the audio session (`CraftNativeSounds`).
1098
1100
  private var claimedSession = false
1099
1101
  /// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
1100
- /// `onProgress`, `onLoad`, `onError`), and how an event reaches them.
1102
+ /// `onProgress`, `onLoadStart`, `onLoad`, `onLoadEnd`, `onError`), and how
1103
+ /// an event reaches them.
1101
1104
  var handlers: [String: String] = [:]
1102
1105
  var emit: ((_ handler: String, _ nativeEvent: [String: Any]) -> Void)?
1103
1106
 
@@ -1170,8 +1173,21 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1170
1173
  CraftNativeSounds.releaseSession()
1171
1174
  }
1172
1175
 
1173
- func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
1176
+ private func applyPendingSeek() {
1177
+ guard let target = pendingSeek, let player, let item = player.currentItem, item.status == .readyToPlay else { return }
1178
+ let current = seconds(player.currentTime())
1179
+ pendingSeek = nil
1180
+ guard abs(current - target) > 0.25 else { return }
1181
+ player.seek(to: CMTime(seconds: target, preferredTimescale: 600))
1182
+ }
1183
+
1184
+ func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, currentTime: Any?, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
1174
1185
  let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
1186
+ let nextTime = number(currentTime).map(Double.init).map { max(0, $0) }
1187
+ if nextTime != requestedTime {
1188
+ requestedTime = nextTime
1189
+ pendingSeek = nextTime
1190
+ }
1175
1191
  let nextShouldPlay = autoplay && !paused
1176
1192
  if playIntent != nextShouldPlay {
1177
1193
  userPaused = false
@@ -1184,6 +1200,7 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1184
1200
  userPaused = false
1185
1201
  ended = false
1186
1202
  loops = false
1203
+ pendingSeek = requestedTime
1187
1204
  // The copy `craft.media.keep` put on the phone plays in place of
1188
1205
  // the address, so a kept video plays with no signal.
1189
1206
  let kept = CraftNativeMedia.local(uri)
@@ -1200,11 +1217,13 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1200
1217
  return
1201
1218
  }
1202
1219
  let next = AVPlayer(url: url)
1220
+ fire("onLoadStart")
1203
1221
  player = next
1204
1222
  playerController.player = next
1205
1223
  observe(next)
1206
1224
  }
1207
1225
  guard let player else { return }
1226
+ applyPendingSeek()
1208
1227
  player.isMuted = muted
1209
1228
  // A muted video makes no sound, so it leaves the session as it is.
1210
1229
  if muted { releaseSession() } else { claimSession() }
@@ -1258,7 +1277,10 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1258
1277
  DispatchQueue.main.async {
1259
1278
  guard let self, self.player?.currentItem === item else { return }
1260
1279
  switch item.status {
1261
- case .readyToPlay: self.fire("onLoad", ["duration": self.seconds(item.duration)])
1280
+ case .readyToPlay:
1281
+ self.applyPendingSeek()
1282
+ self.fire("onLoad", ["duration": self.seconds(item.duration)])
1283
+ self.fire("onLoadEnd")
1262
1284
  case .failed: self.fire("onError", ["message": item.error?.localizedDescription ?? "The video could not be played"])
1263
1285
  default: break
1264
1286
  }
@@ -1306,6 +1328,8 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1306
1328
  playerController.player = nil
1307
1329
  playerController.delegate = nil
1308
1330
  sourceURI = ""
1331
+ requestedTime = nil
1332
+ pendingSeek = nil
1309
1333
  playIntent = nil
1310
1334
  userPaused = false
1311
1335
  ended = false
@@ -2022,6 +2046,8 @@ private struct CraftNativeLayoutStyle {
2022
2046
  let bottom: CGFloat?
2023
2047
  let left: CGFloat?
2024
2048
  let alignSelf: String?
2049
+ /// CSS/Yoga order; equal values retain source order.
2050
+ let order: Int
2025
2051
  let flexGrow: CGFloat
2026
2052
  /// Nil when the style does not say: the parent picks the default for the
2027
2053
  /// kind of view (text shrinks along a row, a box does not).
@@ -2059,6 +2085,7 @@ private struct CraftNativeLayoutStyle {
2059
2085
  left = number(raw["left"])
2060
2086
  let align = raw["alignSelf"] as? String
2061
2087
  alignSelf = align == "auto" ? nil : align
2088
+ order = number(raw["order"]).map { Int($0.rounded(.towardZero)) } ?? 0
2062
2089
  // `flex: n` is the web's and React Native's shorthand: a positive
2063
2090
  // number grows by n, shrinks, and starts from nothing, so `flex-1`
2064
2091
  // siblings share a row equally whatever their content.
@@ -2516,6 +2543,7 @@ final class CraftNativeCelebration {
2516
2543
  group.duration = 2.2 + Double(spread) * 0.45
2517
2544
  group.beginTime = now + Double(index % 7) * 0.018
2518
2545
  group.fillMode = .backwards
2546
+ group.atDisplayRate()
2519
2547
  piece.add(group, forKey: "craft.celebration.particle")
2520
2548
  }
2521
2549
 
@@ -2529,6 +2557,7 @@ final class CraftNativeCelebration {
2529
2557
  entrance.stiffness = 160
2530
2558
  entrance.mass = 1
2531
2559
  entrance.duration = 0.95
2560
+ entrance.atDisplayRate()
2532
2561
  view.layer.add(entrance, forKey: "craft.celebration.entrance")
2533
2562
 
2534
2563
  let sheen = CAGradientLayer()
@@ -2554,6 +2583,7 @@ final class CraftNativeCelebration {
2554
2583
  shine.animations = [sweep, light]
2555
2584
  shine.duration = 1.15
2556
2585
  shine.beginTime = now + 0.55
2586
+ shine.atDisplayRate()
2557
2587
  sheen.add(shine, forKey: "craft.celebration.sweep")
2558
2588
  let cleanup = DispatchWorkItem { [weak self] in self?.stop() }
2559
2589
  self.cleanup = cleanup
@@ -2886,7 +2916,15 @@ private class CraftNativeFlowView: UIStackView {
2886
2916
 
2887
2917
  /// Children that take part in the flow: shown, not a modal overlay.
2888
2918
  private var visibleChildren: [UIView] {
2889
- flowChildren.filter { $0.superview === self && !$0.isHidden && !($0 is CraftNativeModalView) }
2919
+ flowChildren.enumerated()
2920
+ .filter { $0.element.superview === self && !$0.element.isHidden && !($0.element is CraftNativeModalView) }
2921
+ .sorted {
2922
+ if $0.element.craftLayoutStyle.order != $1.element.craftLayoutStyle.order {
2923
+ return $0.element.craftLayoutStyle.order < $1.element.craftLayoutStyle.order
2924
+ }
2925
+ return $0.offset < $1.offset
2926
+ }
2927
+ .map(\.element)
2890
2928
  }
2891
2929
 
2892
2930
  /// Builds the eligible portion of this container for the host-neutral
@@ -3088,7 +3126,8 @@ private class CraftNativeFlowView: UIStackView {
3088
3126
  frames: &frames
3089
3127
  )
3090
3128
  }
3091
- if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height), (sharedTree.nodes.count > flow.count + 1 || grid) {
3129
+ if place, flow.count == children.count, let sharedTree = sharedTreeFrames(flow, width: width, height: height),
3130
+ (sharedTree.nodes.count > flow.count + 1 || grid || reverse || wrapReverse || gridAutoRows != nil || gridRows != nil) {
3092
3131
  frames = []
3093
3132
  for index in 1..<sharedTree.nodes.count {
3094
3133
  guard let view = sharedTree.nodes[index].view else { continue }
@@ -3795,6 +3834,17 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3795
3834
  didSet { blocker.backgroundColor = transparent ? .clear : UIColor.black.withAlphaComponent(0.32) }
3796
3835
  }
3797
3836
 
3837
+ /// Existing Modal nodes block by default. A non-modal popup keeps the
3838
+ /// screen-level overlay but lets hit testing fall through outside its
3839
+ /// interactive content.
3840
+ var blocksBackground = true {
3841
+ didSet { blocker.isUserInteractionEnabled = blocksBackground }
3842
+ }
3843
+ /// An opt-in non-modal dismissal policy. The default popup remains
3844
+ /// pass-through; when enabled, an outside tap is consumed and routed to
3845
+ /// onRequestClose instead of reaching the view below.
3846
+ var dismissOnOutsideTap = false
3847
+
3798
3848
  override init(frame: CGRect) {
3799
3849
  super.init(frame: frame)
3800
3850
  isUserInteractionEnabled = true
@@ -3876,10 +3926,13 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3876
3926
  transform = .identity
3877
3927
  alpha = 0
3878
3928
  isUserInteractionEnabled = true
3929
+ blocksBackground = true
3930
+ dismissOnOutsideTap = false
3879
3931
  onRequestClose = nil
3880
3932
  }
3881
3933
 
3882
3934
  func requestClose(reason: String = "backdrop") {
3935
+ guard blocksBackground || dismissOnOutsideTap else { return }
3883
3936
  guard let onRequestClose else { return }
3884
3937
  isUserInteractionEnabled = false
3885
3938
  onRequestClose(reason)
@@ -3912,6 +3965,14 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3912
3965
  candidate = view.superview
3913
3966
  }
3914
3967
  }
3968
+ if !blocksBackground {
3969
+ // A dismissible popup consumes its outside hit so the close event
3970
+ // cannot also activate the screen below. Otherwise it owns the
3971
+ // hit only when a blocking modal is underneath it; with no modal
3972
+ // below, the screen remains pass-through.
3973
+ if dismissOnOutsideTap { return self }
3974
+ return craftScreenController?.hasBlockingModalBelow(self) == true ? self : nil
3975
+ }
3915
3976
  return blocker.hitTest(blocker.convert(point, from: self), with: event)
3916
3977
  }
3917
3978
 
@@ -3954,6 +4015,23 @@ func craftScrollGesture(_ gesture: UIGestureRecognizer, runsWith other: UIGestur
3954
4015
  return false
3955
4016
  }
3956
4017
 
4018
+ /// Scroll containers observe taps without cancelling native input focus or
4019
+ /// selection. Text views are not UIControls, and their selection subviews may
4020
+ /// have recognizers of their own, so inspect the whole ancestor chain.
4021
+ func craftHandleKeyboardTap(in container: UIView, hit: UIView?, persistTaps: String, forgetFocus: () -> Void) {
4022
+ guard persistTaps != "always" else { return }
4023
+ var candidate = hit
4024
+ var onControl = false
4025
+ while let view = candidate, view !== container {
4026
+ if view is UITextField || view is UITextView || view is UIButton { return }
4027
+ if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true }
4028
+ candidate = view.superview
4029
+ }
4030
+ if persistTaps == "handled", onControl { return }
4031
+ if !onControl { forgetFocus() }
4032
+ container.window?.endEditing(true)
4033
+ }
4034
+
3957
4035
  private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDelegate {
3958
4036
  let contentStack = CraftNativeFlowView()
3959
4037
  private let keyboardTapGesture = UITapGestureRecognizer()
@@ -4226,24 +4304,9 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4226
4304
  @objc private func keyboardTap() {
4227
4305
  guard keyboardShouldPersistTaps != "always" else { return }
4228
4306
  let point = keyboardTapGesture.location(in: self)
4229
- var hit = hitTest(point, with: nil)
4230
- var onControl = false
4231
- var onButton = false
4232
- while let view = hit, view !== self {
4233
- if view is UIButton { onButton = true }
4234
- if view is UIControl || !(view.gestureRecognizers ?? []).isEmpty { onControl = true; break }
4235
- hit = view.superview
4236
- }
4237
- if keyboardShouldPersistTaps == "handled", onControl { return }
4238
- // A button keeps the keyboard: its press gives the focus straight
4239
- // back. Ending editing first only let the keyboard's inset go while
4240
- // the finger was down, the button moved out from under it, and the
4241
- // press was lost.
4242
- if onButton { return }
4243
- // Otherwise the keyboard goes; after a tap on nothing, for good (the
4244
- // next render must not bring the last input back).
4245
- if !onControl { craftScreenController?.forgetFocusedInput() }
4246
- window?.endEditing(true)
4307
+ craftHandleKeyboardTap(in: self, hit: hitTest(point, with: nil), persistTaps: keyboardShouldPersistTaps) {
4308
+ self.craftScreenController?.forgetFocusedInput()
4309
+ }
4247
4310
  }
4248
4311
 
4249
4312
  func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
@@ -4822,6 +4885,62 @@ enum CraftNativeScriptThread {
4822
4885
  }
4823
4886
  }
4824
4887
 
4888
+ extension CAAnimation {
4889
+ /// Asks for the display's full rate, up to 120Hz on ProMotion. Core
4890
+ /// Animation otherwise picks a rate for the motion and often stays at 60;
4891
+ /// a transition or a spring the eye follows reads smoother at 120.
4892
+ func atDisplayRate() {
4893
+ if #available(iOS 15.0, *) { preferredFrameRateRange = CAFrameRateRange(minimum: 80, maximum: 120, preferred: 120) }
4894
+ }
4895
+ }
4896
+
4897
+ /// `requestAnimationFrame` for native screens: one display link, at the
4898
+ /// display's own rate (up to 120Hz on a ProMotion iPhone, which the app
4899
+ /// allows with `CADisableMinimumFrameDurationOnPhone`), running only while a
4900
+ /// screen on display is waiting for a frame. Each frame hands every waiting
4901
+ /// screen one `__craftNativeFireFrame`, where its script runs; a screen asks
4902
+ /// again for the frame after, as a browser's page does. A screen out of sight
4903
+ /// waits until it is shown, as a browser pauses a hidden tab's frames, and as
4904
+ /// Craft's Android host does (Choreographer). Main thread only.
4905
+ final class CraftNativeFrameClock: NSObject {
4906
+ static let shared = CraftNativeFrameClock()
4907
+
4908
+ private var waiting: [ObjectIdentifier: () -> Void] = [:]
4909
+ private var link: CADisplayLink?
4910
+
4911
+ /// How many screens wait for the next frame.
4912
+ var waitingCount: Int { waiting.count }
4913
+ /// Whether the display link is counting frames now.
4914
+ var isRunning: Bool { link.map { !$0.isPaused } ?? false }
4915
+
4916
+ /// `deliver` on the next frame, once. Asking again before it comes keeps one.
4917
+ func request(_ owner: AnyObject, _ deliver: @escaping () -> Void) {
4918
+ waiting[ObjectIdentifier(owner)] = deliver
4919
+ if link == nil {
4920
+ let link = CADisplayLink(target: self, selector: #selector(frame(_:)))
4921
+ if #available(iOS 15.0, *) {
4922
+ link.preferredFrameRateRange = CAFrameRateRange(minimum: 30, maximum: 120, preferred: 120)
4923
+ }
4924
+ link.add(to: .main, forMode: .common)
4925
+ self.link = link
4926
+ }
4927
+ link?.isPaused = false
4928
+ }
4929
+
4930
+ /// `owner` no longer waits: it went out of sight or away.
4931
+ func cancel(_ owner: AnyObject) {
4932
+ waiting.removeValue(forKey: ObjectIdentifier(owner))
4933
+ if waiting.isEmpty { link?.isPaused = true }
4934
+ }
4935
+
4936
+ @objc private func frame(_ link: CADisplayLink) {
4937
+ let due = waiting
4938
+ waiting.removeAll()
4939
+ if waiting.isEmpty { link.isPaused = true }
4940
+ due.values.forEach { $0() }
4941
+ }
4942
+ }
4943
+
4825
4944
  /// The native screens' JavaScript. A screen is a JavaScriptCore context of
4826
4945
  /// its own and evaluates the whole file it is given, so each route has a file
4827
4946
  /// of its own when stx wrote one (`dist/native-screens/<route>.js`, stx's
@@ -4898,6 +5017,25 @@ enum CraftNativeBundle {
4898
5017
  }
4899
5018
  }
4900
5019
 
5020
+ private final class CraftNativeScreenRootView: UIView {
5021
+ weak var controller: CraftNativeScreenController?
5022
+
5023
+ override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
5024
+ // A popup's host frame is only its authored card, so UIKit would not
5025
+ // ask it about an outside point. Shield that point at the screen root
5026
+ // only when a blocking modal is underneath; ordinary non-modal
5027
+ // overlays remain pass-through.
5028
+ if let controller {
5029
+ for modal in controller.view.subviews.compactMap({ $0 as? CraftNativeModalView }).reversed()
5030
+ where !modal.blocksBackground && !modal.isHidden && modal.alpha > 0.01 {
5031
+ let local = modal.convert(point, from: self)
5032
+ if !modal.bounds.contains(local) && (modal.dismissOnOutsideTap || controller.hasBlockingModalBelow(modal)) { return modal }
5033
+ }
5034
+ }
5035
+ return super.hitTest(point, with: event)
5036
+ }
5037
+ }
5038
+
4901
5039
  final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate, UITextFieldDelegate, UITextViewDelegate, UIPickerViewDataSource, UIPickerViewDelegate {
4902
5040
  private final class RenderedNode {
4903
5041
  /// A recycled list row's root takes the identity of the row it shows next.
@@ -4950,6 +5088,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4950
5088
  private var scrollMomentumEndHandlers: [ObjectIdentifier: String] = [:]
4951
5089
  private var sliderCompleteHandlers: [ObjectIdentifier: String] = [:]
4952
5090
  private var sliderSteps: [ObjectIdentifier: Float] = [:]
5091
+ /// A controlled switch can be reconciled while UIKit is still animating
5092
+ /// the user's tap. Remember that native value briefly so the echo does not
5093
+ /// interrupt the animation and leave the thumb painted at its old edge.
5094
+ private var switchUserValues: [ObjectIdentifier: Bool] = [:]
4953
5095
  private var pickerOptions: [ObjectIdentifier: [(value: String, label: String)]] = [:]
4954
5096
  private var modalVisibility: [ObjectIdentifier: Bool] = [:]
4955
5097
  private var modalFocusRestore: [ObjectIdentifier: UIView] = [:]
@@ -5024,6 +5166,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5024
5166
  var pending: [Int: DispatchWorkItem] = [:]
5025
5167
  }
5026
5168
  private let timers = Timers()
5169
+ /// The script asked for an animation frame it has not had yet
5170
+ /// (`requestAnimationFrame`, CraftNativeFrameClock). Main thread only.
5171
+ private var frameWanted = false
5027
5172
  private var lifecycleObservers: [NSObjectProtocol] = []
5028
5173
  private var deepLinkListener: UUID?
5029
5174
  private var keyboardSafeAreaInset: CGFloat = 0
@@ -5054,6 +5199,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5054
5199
  fatalError("init(coder:) has not been implemented")
5055
5200
  }
5056
5201
 
5202
+ override func loadView() {
5203
+ let root = CraftNativeScreenRootView()
5204
+ root.controller = self
5205
+ view = root
5206
+ }
5207
+
5057
5208
  deinit {
5058
5209
  disposeRuntime()
5059
5210
  imageTasks.values.forEach { $0.cancel() }
@@ -5200,6 +5351,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5200
5351
  CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
5201
5352
  }
5202
5353
 
5354
+ /// `requestAnimationFrame`: the next display frame, for a screen on display
5355
+ /// (or the app script, which is never shown). One out of sight keeps the
5356
+ /// request until it is shown again (screenVisibilityChanged).
5357
+ private func requestFrame() {
5358
+ guard !runtimeDisposed, !frameWanted else { return }
5359
+ frameWanted = true
5360
+ guard isAppScript || reportedVisible != false else { return }
5361
+ CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() }
5362
+ }
5363
+
5364
+ private func fireFrame() {
5365
+ guard frameWanted, !runtimeDisposed else { return }
5366
+ frameWanted = false
5367
+ let context = jsContext
5368
+ CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: []) }
5369
+ }
5370
+
5203
5371
  /// The screen came on screen or went off it: a screen pushed over it was
5204
5372
  /// popped, or the hybrid shell switched to or away from its tab (which
5205
5373
  /// hides a stack rather than moving it, so UIKit says nothing). The first
@@ -5214,6 +5382,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5214
5382
  // preview also restores a screen that never lost focus, so restore its
5215
5383
  // chrome even when no new SCREEN_FOCUS event is needed.
5216
5384
  if visible { restoreShellChrome() }
5385
+ if visible, frameWanted { CraftNativeFrameClock.shared.request(self) { [weak self] in self?.fireFrame() } }
5386
+ if !visible { CraftNativeFrameClock.shared.cancel(self) }
5217
5387
  guard visible != reportedVisible else { return }
5218
5388
  let first = reportedVisible == nil
5219
5389
  reportedVisible = visible
@@ -5240,6 +5410,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5240
5410
  private func disposeRuntime() {
5241
5411
  guard !runtimeDisposed else { return }
5242
5412
  runtimeDisposed = true
5413
+ frameWanted = false
5414
+ CraftNativeFrameClock.shared.cancel(self)
5243
5415
  disposeRenderedTree()
5244
5416
  let context = jsContext
5245
5417
  CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
@@ -5310,9 +5482,58 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5310
5482
  if bar.intersects(view.bounds) { insets.top = max(insets.top, bar.maxY) }
5311
5483
  }
5312
5484
  for modal in modals {
5313
- if modal.frame != view.bounds { modal.frame = view.bounds }
5314
- modal.contentInsets = insets
5485
+ guard !modal.blocksBackground else {
5486
+ if modal.frame != view.bounds { modal.frame = view.bounds }
5487
+ modal.contentInsets = insets
5488
+ continue
5489
+ }
5490
+
5491
+ // Non-modal overlays are viewport children, not full-screen
5492
+ // scrims. Their absolute style uses the same full screenViewport
5493
+ // origin that View.measure returns. Safe-area-aware placement is
5494
+ // owned by the caller (for example, STX's anchor/clamp logic), so
5495
+ // an authored left/top is never shifted by an implicit inset.
5496
+ let viewport = view.bounds
5497
+ let style = modal.craftLayoutStyle
5498
+ let width = style.left != nil && style.right != nil
5499
+ ? max(0, viewport.width - (style.left ?? 0) - (style.right ?? 0))
5500
+ : style.width?.resolve(viewport.width)
5501
+ ?? min(viewport.width, modal.contentStack.sizeThatFits(viewport.size).width)
5502
+ let height = style.top != nil && style.bottom != nil
5503
+ ? max(0, viewport.height - (style.top ?? 0) - (style.bottom ?? 0))
5504
+ : style.height?.resolve(viewport.height)
5505
+ ?? min(viewport.height, modal.contentStack.sizeThatFits(viewport.size).height)
5506
+ let x = viewport.minX + (style.left ?? (style.right.map { viewport.width - $0 - width } ?? 0))
5507
+ let y = viewport.minY + (style.top ?? (style.bottom.map { viewport.height - $0 - height } ?? 0))
5508
+ let frame = CGRect(x: x, y: y, width: width, height: height)
5509
+ if modal.frame != frame { modal.frame = frame }
5510
+ // The frame already excludes the safe area; inset the content no
5511
+ // further or authored top/left coordinates would be shifted twice.
5512
+ modal.contentInsets = .zero
5513
+ }
5514
+ }
5515
+
5516
+ /// A non-modal popup above a blocking modal must not leak its outside tap
5517
+ /// to the modal's scrim and dismiss the wrong layer.
5518
+ fileprivate func hasBlockingModalBelow(_ modal: CraftNativeModalView) -> Bool {
5519
+ guard let index = view.subviews.firstIndex(where: { $0 === modal }) else { return false }
5520
+ return view.subviews[..<index].contains { candidate in
5521
+ guard let other = candidate as? CraftNativeModalView else { return false }
5522
+ return other.blocksBackground && !other.isHidden && other.alpha > 0.01
5523
+ }
5524
+ }
5525
+
5526
+ /// The native route's Back button dismisses the top presented modal before
5527
+ /// it pops the route, matching Android's activity back callback.
5528
+ @discardableResult
5529
+ func handleBackForModal() -> Bool {
5530
+ let modal = view.subviews.reversed().compactMap { $0 as? CraftNativeModalView }.first {
5531
+ !$0.isHidden && $0.alpha > 0.01 && ($0.blocksBackground || $0.dismissOnOutsideTap)
5315
5532
  }
5533
+ guard let modal else { return false }
5534
+ let hasHandler = modal.onRequestClose != nil
5535
+ modal.requestClose(reason: "back")
5536
+ return hasHandler
5316
5537
  }
5317
5538
 
5318
5539
  private static func safeJavaScriptString(_ value: JSValue?) -> String? {
@@ -5413,6 +5634,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5413
5634
  }
5414
5635
  jsContext.setObject(scheduleTimer, forKeyedSubscript: "craftNativeScheduleTimer" as NSString)
5415
5636
  jsContext.setObject(cancelTimer, forKeyedSubscript: "craftNativeCancelTimer" as NSString)
5637
+ // The next display frame, from the script's thread; the frame itself
5638
+ // comes back there as `__craftNativeFireFrame`.
5639
+ let requestFrame: @convention(block) () -> Void = { [weak self] in
5640
+ CraftNativeScriptThread.toMain { self?.requestFrame() }
5641
+ }
5642
+ jsContext.setObject(requestFrame, forKeyedSubscript: "craftNativeRequestFrame" as NSString)
5416
5643
  let capabilityJSON = inputs.capabilityJSON
5417
5644
  // The app's site and build, as JSON strings: the site an all-native
5418
5645
  // app's paths and links belong to (`devServerURL`), and the version a
@@ -5448,6 +5675,41 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5448
5675
  jsContext.evaluateScript(Self.nativeWebAPICompatibilityPrelude)
5449
5676
  installHostAPIs()
5450
5677
  jsContext.evaluateScript("""
5678
+ // requestAnimationFrame on the display's frames (CraftNativeFrameClock),
5679
+ // at its own rate, 120 a second on ProMotion: a timer guessing at 60
5680
+ // would cap every animation a screen draws itself. One request covers
5681
+ // every callback for the frame; a callback that throws does not stop
5682
+ // the others, and is reported after them.
5683
+ if (typeof globalThis.requestAnimationFrame !== 'function') {
5684
+ (function() {
5685
+ var frames = [];
5686
+ var cancelled = new Set();
5687
+ var nextFrame = 0;
5688
+ globalThis.requestAnimationFrame = function(callback) {
5689
+ if (typeof callback !== 'function') return 0;
5690
+ var id = ++nextFrame;
5691
+ frames.push({ id: id, callback: callback });
5692
+ if (frames.length === 1) craftNativeRequestFrame();
5693
+ return id;
5694
+ };
5695
+ globalThis.cancelAnimationFrame = function(id) {
5696
+ if (frames.some(function(frame) { return frame.id === id; })) cancelled.add(id);
5697
+ };
5698
+ globalThis.__craftNativeFireFrame = function() {
5699
+ var due = frames;
5700
+ var skip = cancelled;
5701
+ frames = [];
5702
+ cancelled = new Set();
5703
+ var now = globalThis.performance && typeof globalThis.performance.now === 'function' ? globalThis.performance.now() : Date.now();
5704
+ var failures = [];
5705
+ for (var i = 0; i < due.length; i++) {
5706
+ if (skip.has(due[i].id)) continue;
5707
+ try { due[i].callback(now); } catch (error) { failures.push(error); }
5708
+ }
5709
+ failures.forEach(function(error) { globalThis.setTimeout(function() { throw error; }, 0); });
5710
+ };
5711
+ })();
5712
+ }
5451
5713
  if (typeof globalThis.setTimeout !== 'function') {
5452
5714
  (function() {
5453
5715
  var timers = new Map();
@@ -7326,6 +7588,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7326
7588
  let modal = result as! CraftNativeModalView
7327
7589
  let focusBeforeContent = firstResponder(in: rootStack) ?? lastFocusedInput
7328
7590
  modal.transparent = props["transparent"] as? Bool == true
7591
+ modal.blocksBackground = props["nonModal"] as? Bool != true
7592
+ modal.dismissOnOutsideTap = props["dismissOnOutsideTap"] as? Bool == true
7329
7593
  let visible: Bool
7330
7594
  if let propVisible = props["visible"] as? Bool {
7331
7595
  // An explicit visible prop owns modal visibility. The
@@ -7383,7 +7647,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7383
7647
  // UIKit has already changed the value before emitting the
7384
7648
  // event. Echoing it through the script must not restart its
7385
7649
  // native animation.
7386
- if toggle.isOn != value { toggle.isOn = value }
7650
+ let id = ObjectIdentifier(toggle)
7651
+ if switchUserValues[id] != value, toggle.isOn != value {
7652
+ // Programmatic changes should settle immediately. A
7653
+ // native tap is handled above without touching UIKit
7654
+ // while its own thumb animation is in flight.
7655
+ toggle.setOn(value, animated: false)
7656
+ }
7387
7657
  }
7388
7658
  toggle.isEnabled = props["disabled"] as? Bool != true
7389
7659
  if let track = props["trackColor"] as? [String: Any] {
@@ -7510,6 +7780,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7510
7780
  paused: (props["paused"] as? Bool) == true,
7511
7781
  loop: (props["loop"] as? Bool) == true,
7512
7782
  muted: (props["muted"] as? Bool) == true,
7783
+ currentTime: props["currentTime"],
7513
7784
  controls: (props["controls"] as? Bool) == true,
7514
7785
  resizeMode: style["resizeMode"] as? String ?? props["resizeMode"] as? String,
7515
7786
  pictureInPicture: (props["pictureInPicture"] as? Bool) == true
@@ -8102,6 +8373,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8102
8373
  imageLoadEndHandlers.removeValue(forKey: id)
8103
8374
  imageErrorHandlers.removeValue(forKey: id)
8104
8375
  checkboxIndeterminate.removeValue(forKey: id)
8376
+ switchUserValues.removeValue(forKey: id)
8105
8377
  if let video = node.view as? CraftNativeVideoView { video.stop() }
8106
8378
  if let map = node.view as? CraftNativeMapView { map.destroy() }
8107
8379
  for child in node.children { forgetHandlers(child, preservingInputDrafts: preservingInputDrafts) }
@@ -9084,7 +9356,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9084
9356
  let hasLinkTarget = ["href", "to"].contains { !(props[$0] as? String ?? "").isEmpty }
9085
9357
  let linkedControl = ["Link", "TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type) && hasLinkTarget
9086
9358
  let touchable = ["TouchableOpacity", "TouchableHighlight", "Pressable"].contains(type)
9087
- let role = (props["accessibilityRole"] as? String) ?? (linkedControl ? "link" : touchable ? "button" : nil)
9359
+ let role = (props["accessibilityRole"] as? String)
9360
+ ?? (linkedControl ? "link" : touchable ? "button" : ["FlatList", "SectionList"].contains(type) ? "list" : nil)
9088
9361
  // A scroll view, a list or a group of controls holds what VoiceOver
9089
9362
  // reads: its label names the group, as a tab strip or a pager's
9090
9363
  // name, and never stands in for its content. As an element of its
@@ -9279,7 +9552,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9279
9552
  }
9280
9553
 
9281
9554
  @objc private func switchChanged(_ sender: UISwitch) {
9282
- guard let handler = handlers[ObjectIdentifier(sender)] else { return }
9555
+ let id = ObjectIdentifier(sender)
9556
+ switchUserValues[id] = sender.isOn
9557
+ DispatchQueue.main.asyncAfter(deadline: .now() + 0.35) { [weak self, weak sender] in
9558
+ guard let self, let sender, self.switchUserValues[id] == sender.isOn else { return }
9559
+ self.switchUserValues.removeValue(forKey: id)
9560
+ }
9561
+ guard let handler = handlers[id] else { return }
9283
9562
  send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["value": sender.isOn]])
9284
9563
  }
9285
9564
 
@@ -9960,6 +10239,7 @@ extension CraftNativeScreenController {
9960
10239
  }
9961
10240
  animation.fromValue = from
9962
10241
  let key = "\(CraftNativeRunningAnimation.key).\(keyPath)"
10242
+ animation.atDisplayRate()
9963
10243
  layer.add(animation, forKey: key)
9964
10244
  running.layers.append((layer, key))
9965
10245
  }
@@ -68,6 +68,26 @@ final class DriverTests: XCTestCase {
68
68
  return result
69
69
  }
70
70
 
71
+ /// The journey, from the runner's control server, asked for again
72
+ /// until a minute has passed.
73
+ ///
74
+ /// The server is up before the test starts, but a freshly created
75
+ /// simulator's first request can outlast one wait: the first journey of
76
+ /// a CI run spent 15s on it and failed before doing anything. XCTWaiter
77
+ /// reports a timeout instead of failing the test, so a slow first answer
78
+ /// is retried rather than fatal.
79
+ func remoteScript(seconds: Double = 60) -> Data? {
80
+ let end = Date().addingTimeInterval(seconds)
81
+ repeat {
82
+ var result: Data?
83
+ let done = XCTestExpectation(description: "/script")
84
+ URLSession.shared.dataTask(with: URL(string: control + "/script")!) { data, _, _ in result = data; done.fulfill() }.resume()
85
+ if XCTWaiter().wait(for: [done], timeout: 15) == .completed, let result, !result.isEmpty { return result }
86
+ Thread.sleep(forTimeInterval: 1)
87
+ } while Date() < end
88
+ return nil
89
+ }
90
+
71
91
  func post(_ path: String, _ body: Data) {
72
92
  if bundledJourney {
73
93
  guard path.hasPrefix("/save?"),
@@ -246,7 +266,7 @@ final class DriverTests: XCTestCase {
246
266
  bundledJourney = true
247
267
  data = try Data(contentsOf: resource)
248
268
  } else {
249
- data = get("/script")
269
+ data = remoteScript()
250
270
  }
251
271
  guard let data, let script = String(data: data, encoding: .utf8) else {
252
272
  XCTFail("no script")