@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 +17 -6
- package/package.json +1 -1
- package/templates/CraftApp.swift +36 -4
- package/templates/CraftHealth.swift +38 -0
- package/templates/CraftHybrid.swift +38 -8
- package/templates/CraftNativeActions.swift +1 -1
- package/templates/CraftNativeFlatList.swift +34 -20
- package/templates/CraftNativeScreen.swift +308 -28
- package/templates/testing/DriverTests.swift.template +21 -1
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
|
|
81
|
-
`transparent`, `
|
|
82
|
-
|
|
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
|
-
|
|
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
package/templates/CraftApp.swift
CHANGED
|
@@ -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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
70
|
-
|
|
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
|
|
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
|
-
|
|
375
|
-
|
|
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
|
|
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
|
|
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:
|
|
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.
|
|
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),
|
|
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
|
-
|
|
4230
|
-
|
|
4231
|
-
|
|
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
|
-
|
|
5314
|
-
|
|
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
|
-
|
|
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)
|
|
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
|
-
|
|
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 =
|
|
269
|
+
data = remoteScript()
|
|
250
270
|
}
|
|
251
271
|
guard let data, let script = String(data: data, encoding: .utf8) else {
|
|
252
272
|
XCTFail("no script")
|