@craft-native/ios 0.0.204 → 0.0.205
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
|
@@ -119,6 +119,9 @@ In Xcode:
|
|
|
119
119
|
|
|
120
120
|
Native screens also support `ImageBackground`, which loads an image behind
|
|
121
121
|
ordinary native children while preserving the shared flex layout contract.
|
|
122
|
+
Native image sources accept HTTPS everywhere and loopback HTTP for local
|
|
123
|
+
development (`localhost`, `127.0.0.1`, `::1`, and `*.localhost`); public HTTP
|
|
124
|
+
and unsupported schemes are rejected.
|
|
122
125
|
`KeyboardAvoidingView` also responds to the native keyboard with `padding`,
|
|
123
126
|
`position`, or `height` behavior.
|
|
124
127
|
|
|
@@ -129,10 +132,12 @@ authorization or requiring local notifications to be enabled.
|
|
|
129
132
|
Keyboard frame changes update the native controller's additional safe area, so
|
|
130
133
|
focused inputs and responsive `onLayout` measurements move with the keyboard.
|
|
131
134
|
|
|
132
|
-
Shared view transforms, z-order,
|
|
133
|
-
with the same style keys as Android;
|
|
135
|
+
Shared view transforms, z-order, view/text shadows, per-edge border styles,
|
|
136
|
+
and linear gradients are applied by UIKit with the same style keys as Android;
|
|
137
|
+
`elevation` remains Android-specific. Gradient layers follow the native view's
|
|
138
|
+
bounds and corners through layout changes.
|
|
134
139
|
|
|
135
|
-
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `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. `Map` uses MapKit for trail polylines and marker taps; its overlays carry flat latitude/longitude `geometry` arrays
|
|
140
|
+
The opt-in `native` renderer hosts compiled `.stx` screens in JavaScriptCore and renders their `View`, `SafeAreaView`, `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. `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 timestamps; `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. 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).
|
|
136
141
|
|
|
137
142
|
```bash
|
|
138
143
|
craft ios init NativeDemo --renderer native --output ./ios-native
|
|
@@ -195,6 +200,9 @@ screen background (including light/dark trait changes) with
|
|
|
195
200
|
UIKit's translucent material; set it back to `false` when a screen no longer
|
|
196
201
|
needs a solid bar.
|
|
197
202
|
|
|
203
|
+
The `backgroundColor` navigation option is shared with the Android native host;
|
|
204
|
+
omitting it or passing `null` returns to the app's dynamic light/dark colour.
|
|
205
|
+
|
|
198
206
|
The optional Liquid Glass tab-bar styling is compiler-gated for Xcode 26/Swift 6.2 and later. Older SDKs compile the same tab bar with the material fallback, so generated projects remain buildable on Xcode 16.
|
|
199
207
|
|
|
200
208
|
The iOS host overlays `craft.config.json` on its defaults. Missing or `null` capability flags stay disabled; explicitly set a flag to `true` to enable it. Both the Swift host and Zig bridge reject malformed values and fall back to all capabilities disabled.
|
package/dist/testing.d.ts
CHANGED
|
@@ -11,7 +11,7 @@ export interface SimulatorDriverOptions {
|
|
|
11
11
|
/** Generate the reusable XCUITest shell from package templates. No app-owned Swift. */
|
|
12
12
|
export declare function initSimulatorDriver(options: SimulatorDriverOptions): void;
|
|
13
13
|
export type SimulatorStep = {
|
|
14
|
-
command: 'launch' | 'activate' | 'terminate' | 'home' | 'back' | 'return' | 'unlabelled';
|
|
14
|
+
command: 'launch' | 'activate' | 'terminate' | 'home' | 'back' | 'return' | 'unlabelled' | 'notifications';
|
|
15
15
|
} | {
|
|
16
16
|
command: 'background' | 'wait';
|
|
17
17
|
seconds: number;
|
|
@@ -45,14 +45,14 @@ export type SimulatorStep = {
|
|
|
45
45
|
id: string;
|
|
46
46
|
text: string;
|
|
47
47
|
} | {
|
|
48
|
-
command: 'expect' | 'expectexact' | 'expectid';
|
|
48
|
+
command: 'expect' | 'expectexact' | 'expectid' | 'systemexpect';
|
|
49
49
|
text: string;
|
|
50
50
|
seconds?: number;
|
|
51
51
|
} | {
|
|
52
|
-
command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log';
|
|
52
|
+
command: 'tap' | 'tapif' | 'tapid' | 'tapexact' | 'type' | 'absent' | 'absentid' | 'selected' | 'tab' | 'scrollto' | 'dismiss' | 'log' | 'systemtap' | 'systemabsent';
|
|
53
53
|
text: string;
|
|
54
54
|
} | {
|
|
55
|
-
command: 'shot' | 'tree' | 'audit' | 'exec';
|
|
55
|
+
command: 'shot' | 'tree' | 'audit' | 'exec' | 'systemtree';
|
|
56
56
|
name: string;
|
|
57
57
|
} | {
|
|
58
58
|
command: 'openurl';
|
package/package.json
CHANGED
|
@@ -146,8 +146,10 @@ struct CraftHybridRoutes {
|
|
|
146
146
|
let scheme = (url.scheme ?? "").lowercased()
|
|
147
147
|
guard scheme == "http" || scheme == "https",
|
|
148
148
|
let host = url.host?.lowercased(), !host.isEmpty,
|
|
149
|
-
let
|
|
150
|
-
|
|
149
|
+
let siteScheme = site?.scheme?.lowercased(), scheme == siteScheme,
|
|
150
|
+
let siteHost = site?.host?.lowercased(), host == siteHost,
|
|
151
|
+
url.port == site?.port else { return false }
|
|
152
|
+
return true
|
|
151
153
|
}
|
|
152
154
|
|
|
153
155
|
/// Where a path no native screen draws opens in an app with no web view:
|
|
@@ -979,6 +981,17 @@ final class CraftHybridWebSlot: UIViewController {
|
|
|
979
981
|
/// ready. This belongs to the incoming slot, beyond the push animation.
|
|
980
982
|
private(set) var waitingPageSnapshot: UIView?
|
|
981
983
|
|
|
984
|
+
/// A canceled tab swipe may leave this slot hidden after the page has
|
|
985
|
+
/// already reported its destination. Reconcile that route before the
|
|
986
|
+
/// WebView is adopted again, so the slot cannot stay transparent forever.
|
|
987
|
+
@discardableResult
|
|
988
|
+
func markArrivedIfNeeded(_ currentPath: String?) -> Bool {
|
|
989
|
+
guard awaitingPage, let currentPath,
|
|
990
|
+
CraftHybridRoutes.normalize(currentPath) == CraftHybridRoutes.normalize(path) else { return false }
|
|
991
|
+
awaitingPage = false
|
|
992
|
+
return true
|
|
993
|
+
}
|
|
994
|
+
|
|
982
995
|
func keepPageVisible(_ snapshot: UIView) {
|
|
983
996
|
waitingPageSnapshot?.removeFromSuperview()
|
|
984
997
|
snapshot.frame = view.bounds
|
|
@@ -1392,7 +1405,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1392
1405
|
|
|
1393
1406
|
/// The entry for a path: its native screen, or a web entry.
|
|
1394
1407
|
private func entry(for path: String, isTabRoot: Bool, depth: Int?, params extra: [String: Any] = [:]) -> UIViewController {
|
|
1395
|
-
if let match = hybrid.routes.match(path) {
|
|
1408
|
+
if hybrid.isEnabled, let match = hybrid.routes.match(path) {
|
|
1396
1409
|
var params = self.params(for: path, match)
|
|
1397
1410
|
extra.forEach { params[$0.key] = $0.value }
|
|
1398
1411
|
let screen = CraftNativeScreenController(config: config, routeName: match.screen, routeParams: params)
|
|
@@ -1441,6 +1454,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1441
1454
|
func adoptWeb(into slot: CraftHybridWebSlot, allowHiddenStack: Bool = false) {
|
|
1442
1455
|
guard let webHost,
|
|
1443
1456
|
allowHiddenStack || slot.navigationController === visibleStack || visibleStack == nil else { return }
|
|
1457
|
+
slot.markArrivedIfNeeded(hybrid.pagePath)
|
|
1444
1458
|
attachWeb()
|
|
1445
1459
|
moveWeb(to: slot, in: slot.view)
|
|
1446
1460
|
webHost.view.alpha = slot.awaitingPage ? 0 : 1
|
|
@@ -158,6 +158,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
158
158
|
private var refreshHandler: (() -> Void)?
|
|
159
159
|
private let keyboardTapGesture = UITapGestureRecognizer()
|
|
160
160
|
private var keyboardShouldPersistTaps = "never"
|
|
161
|
+
private var scrollTargetKey: String?
|
|
162
|
+
private var pendingScrollTarget: (id: String, animated: Bool, attempts: Int)?
|
|
161
163
|
|
|
162
164
|
init() {
|
|
163
165
|
let layout = CraftNativeFlatListLayout()
|
|
@@ -271,6 +273,37 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
271
273
|
updateItemEstimate()
|
|
272
274
|
super.layoutSubviews()
|
|
273
275
|
craftApplyCornerRadius()
|
|
276
|
+
scrollToPendingTarget()
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
/// Scroll to a virtualized row identified by its native testID, id, or key.
|
|
280
|
+
/// The row need not be mounted yet; the data snapshot is enough to find its
|
|
281
|
+
/// index, and UIKit will mount it as part of the scroll.
|
|
282
|
+
func setScrollTarget(_ value: Any?) {
|
|
283
|
+
var id: String?
|
|
284
|
+
var animated = true
|
|
285
|
+
if let text = value as? String {
|
|
286
|
+
id = text
|
|
287
|
+
} else if let target = value as? [String: Any] {
|
|
288
|
+
id = target["id"] as? String
|
|
289
|
+
animated = target["animated"] as? Bool ?? true
|
|
290
|
+
}
|
|
291
|
+
guard let id, !id.isEmpty else {
|
|
292
|
+
scrollTargetKey = nil
|
|
293
|
+
pendingScrollTarget = nil
|
|
294
|
+
return
|
|
295
|
+
}
|
|
296
|
+
let key: String
|
|
297
|
+
if let target = value as? [String: Any],
|
|
298
|
+
let data = try? JSONSerialization.data(withJSONObject: target, options: [.sortedKeys]) {
|
|
299
|
+
key = String(decoding: data, as: UTF8.self)
|
|
300
|
+
} else {
|
|
301
|
+
key = id
|
|
302
|
+
}
|
|
303
|
+
guard key != scrollTargetKey else { return }
|
|
304
|
+
scrollTargetKey = key
|
|
305
|
+
pendingScrollTarget = (id, animated, 0)
|
|
306
|
+
scrollToPendingTarget()
|
|
274
307
|
}
|
|
275
308
|
|
|
276
309
|
func setContentContainerStyle(_ raw: [String: Any]?) {
|
|
@@ -473,6 +506,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
473
506
|
onMomentumScrollBegin = nil
|
|
474
507
|
onMomentumScrollEnd = nil
|
|
475
508
|
pendingThemeRefresh = false
|
|
509
|
+
scrollTargetKey = nil
|
|
510
|
+
pendingScrollTarget = nil
|
|
476
511
|
}
|
|
477
512
|
|
|
478
513
|
func refreshThemeDefaults() {
|
|
@@ -543,10 +578,39 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
543
578
|
self.refreshThemeDefaults()
|
|
544
579
|
return
|
|
545
580
|
}
|
|
581
|
+
self.scrollToPendingTarget()
|
|
546
582
|
self.evaluateEndReached()
|
|
547
583
|
}
|
|
548
584
|
}
|
|
549
585
|
|
|
586
|
+
private func targetIndex(for id: String) -> Int? {
|
|
587
|
+
items.firstIndex { item in
|
|
588
|
+
let props = item.node["props"] as? [String: Any] ?? [:]
|
|
589
|
+
return [props["testID"], props["id"], item.node["id"], item.identity]
|
|
590
|
+
.compactMap { $0 as? String }
|
|
591
|
+
.contains(id)
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
|
|
595
|
+
private func scrollToPendingTarget() {
|
|
596
|
+
guard let target = pendingScrollTarget else { return }
|
|
597
|
+
guard let index = targetIndex(for: target.id), index < numberOfItems(inSection: 0) else {
|
|
598
|
+
if target.attempts >= 4 {
|
|
599
|
+
// A diffable snapshot may still be applying. Keep the target
|
|
600
|
+
// for finishApplyingSnapshot(), but discard an invalid target
|
|
601
|
+
// once the list is otherwise idle.
|
|
602
|
+
if !isApplyingSnapshot { pendingScrollTarget = nil }
|
|
603
|
+
return
|
|
604
|
+
}
|
|
605
|
+
pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
|
|
606
|
+
DispatchQueue.main.async { [weak self] in self?.scrollToPendingTarget() }
|
|
607
|
+
return
|
|
608
|
+
}
|
|
609
|
+
pendingScrollTarget = nil
|
|
610
|
+
let position: UICollectionView.ScrollPosition = flowLayout.scrollDirection == .horizontal ? .left : .top
|
|
611
|
+
scrollToItem(at: IndexPath(item: index, section: 0), at: position, animated: target.animated)
|
|
612
|
+
}
|
|
613
|
+
|
|
550
614
|
private func clamped(_ offset: CGPoint) -> CGPoint {
|
|
551
615
|
CGPoint(
|
|
552
616
|
x: min(max(-adjustedContentInset.left, offset.x), max(-adjustedContentInset.left, contentSize.width - bounds.width + adjustedContentInset.right)),
|
|
@@ -21,6 +21,12 @@ private func number(_ value: Any?) -> CGFloat? {
|
|
|
21
21
|
return value.isFinite ? value : nil
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
private func craftNativePickerValue(_ value: Any?) -> String? {
|
|
25
|
+
if let value = value as? String, !value.isEmpty { return value }
|
|
26
|
+
if let value = value as? NSNumber, value.doubleValue.isFinite { return String(value.doubleValue) }
|
|
27
|
+
return nil
|
|
28
|
+
}
|
|
29
|
+
|
|
24
30
|
private func craftNativeGridColumnCount(_ value: Any?) -> Int {
|
|
25
31
|
if let number = value as? NSNumber { return max(1, number.intValue) }
|
|
26
32
|
guard let text = value as? String else { return 1 }
|
|
@@ -751,6 +757,9 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
751
757
|
private var timeObserver: Any?
|
|
752
758
|
private var loops = false
|
|
753
759
|
private var wasPlaying: Bool?
|
|
760
|
+
private var playIntent: Bool?
|
|
761
|
+
private var userPaused = false
|
|
762
|
+
private var ended = false
|
|
754
763
|
/// This video holds a claim on the audio session (`CraftNativeSounds`).
|
|
755
764
|
private var claimedSession = false
|
|
756
765
|
/// The screen's handlers by event name (`onEnd`, `onPlay`, `onPause`,
|
|
@@ -829,9 +838,17 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
829
838
|
|
|
830
839
|
func configure(source: Any?, autoplay: Bool, paused: Bool, loop: Bool, muted: Bool, controls: Bool, resizeMode: String?, pictureInPicture: Bool = false) {
|
|
831
840
|
let uri = (source as? String) ?? (source as? [String: Any])?["uri"] as? String ?? ""
|
|
841
|
+
let nextShouldPlay = autoplay && !paused
|
|
842
|
+
if playIntent != nextShouldPlay {
|
|
843
|
+
userPaused = false
|
|
844
|
+
ended = false
|
|
845
|
+
}
|
|
846
|
+
playIntent = nextShouldPlay
|
|
832
847
|
if uri != sourceURI {
|
|
833
848
|
detachPlayer()
|
|
834
849
|
sourceURI = uri
|
|
850
|
+
userPaused = false
|
|
851
|
+
ended = false
|
|
835
852
|
loops = false
|
|
836
853
|
// The copy `craft.media.keep` put on the phone plays in place of
|
|
837
854
|
// the address, so a kept video plays with no signal.
|
|
@@ -871,7 +888,11 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
871
888
|
playerController.canStartPictureInPictureAutomaticallyFromInline = floating
|
|
872
889
|
}
|
|
873
890
|
loops = loop
|
|
874
|
-
if
|
|
891
|
+
if nextShouldPlay {
|
|
892
|
+
if !userPaused && !ended { player.play() }
|
|
893
|
+
} else {
|
|
894
|
+
player.pause()
|
|
895
|
+
}
|
|
875
896
|
}
|
|
876
897
|
|
|
877
898
|
// MARK: AVPlayerViewControllerDelegate
|
|
@@ -915,8 +936,12 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
915
936
|
queue: .main
|
|
916
937
|
) { [weak self, weak player] _ in
|
|
917
938
|
guard let self else { return }
|
|
939
|
+
self.ended = !self.loops
|
|
918
940
|
self.fire("onEnd")
|
|
919
|
-
if self.loops {
|
|
941
|
+
if self.loops {
|
|
942
|
+
self.ended = false
|
|
943
|
+
player?.seek(to: .zero) { _ in player?.play() }
|
|
944
|
+
}
|
|
920
945
|
}
|
|
921
946
|
}
|
|
922
947
|
playingObservation = player.observe(\.timeControlStatus, options: [.new]) { [weak self] player, _ in
|
|
@@ -927,6 +952,12 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
927
952
|
// Paused before it ever played is where it starts, not news.
|
|
928
953
|
if self.wasPlaying == nil && !playing { self.wasPlaying = false; return }
|
|
929
954
|
self.wasPlaying = playing
|
|
955
|
+
if playing {
|
|
956
|
+
self.userPaused = false
|
|
957
|
+
self.ended = false
|
|
958
|
+
} else if self.playIntent == true {
|
|
959
|
+
self.userPaused = true
|
|
960
|
+
}
|
|
930
961
|
self.fire(playing ? "onPlay" : "onPause")
|
|
931
962
|
}
|
|
932
963
|
}
|
|
@@ -940,6 +971,9 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
|
|
|
940
971
|
detachPlayer()
|
|
941
972
|
playerController.player = nil
|
|
942
973
|
sourceURI = ""
|
|
974
|
+
playIntent = nil
|
|
975
|
+
userPaused = false
|
|
976
|
+
ended = false
|
|
943
977
|
if playerController.parent != nil {
|
|
944
978
|
playerController.willMove(toParent: nil)
|
|
945
979
|
playerController.view.removeFromSuperview()
|
|
@@ -956,6 +990,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
956
990
|
private var overlayCoordinatesByID: [String: [CLLocationCoordinate2D]] = [:]
|
|
957
991
|
private var overlayColors: [ObjectIdentifier: UIColor] = [:]
|
|
958
992
|
private var overlayWidths: [ObjectIdentifier: CGFloat] = [:]
|
|
993
|
+
private var initialCameraKey: String?
|
|
994
|
+
private var pendingFitKey: String?
|
|
959
995
|
private(set) var markerHandlers: [String: String] = [:]
|
|
960
996
|
var onMarkerTap: ((String) -> Void)?
|
|
961
997
|
|
|
@@ -998,7 +1034,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
998
1034
|
if type == "mapmarker" || type == "marker" {
|
|
999
1035
|
guard let point = points.first else { continue }
|
|
1000
1036
|
nextMarkers[id] = (point, childProps["title"] as? String)
|
|
1001
|
-
if let handler = childEvents["onPress"]
|
|
1037
|
+
if let handler = nonEmptyHandler(childEvents["onPress"])
|
|
1038
|
+
?? nonEmptyHandler(childEvents["onMarkerPress"]) {
|
|
1002
1039
|
nextHandlers[id] = handler
|
|
1003
1040
|
}
|
|
1004
1041
|
} else if type == "mappolyline" || type == "polyline" {
|
|
@@ -1056,23 +1093,62 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1056
1093
|
addOverlay(overlay)
|
|
1057
1094
|
}
|
|
1058
1095
|
|
|
1059
|
-
if let region = Self.region(props["region"]
|
|
1096
|
+
if let region = Self.region(props["region"]) {
|
|
1097
|
+
// `region` is controlled: an authored change is allowed to move
|
|
1098
|
+
// the camera, even when the map itself was already mounted.
|
|
1099
|
+
pendingFitKey = nil
|
|
1060
1100
|
setRegion(region, animated: false)
|
|
1101
|
+
initialCameraKey = nil
|
|
1102
|
+
} else if let initial = Self.region(props["initialRegion"]) {
|
|
1103
|
+
pendingFitKey = nil
|
|
1104
|
+
let key = "region:\(Self.regionKey(initial))"
|
|
1105
|
+
if initialCameraKey != key {
|
|
1106
|
+
setRegion(initial, animated: false)
|
|
1107
|
+
initialCameraKey = key
|
|
1108
|
+
}
|
|
1061
1109
|
} else if props["fitToBounds"] as? Bool == true {
|
|
1062
|
-
|
|
1110
|
+
let key = "fit:\(Self.overlayKey(markers: nextMarkers.mapValues { $0.coordinate }, polylines: nextPolylines.mapValues { $0.coordinates }))"
|
|
1111
|
+
if initialCameraKey != key {
|
|
1112
|
+
// A native screen can reconcile before UIKit assigns the map
|
|
1113
|
+
// its final safe-area-constrained bounds. Keep the fit
|
|
1114
|
+
// pending until there is a real viewport, matching Android's
|
|
1115
|
+
// onSizeChanged retry instead of silently accepting a no-op.
|
|
1116
|
+
pendingFitKey = key
|
|
1117
|
+
if fitToOverlays() {
|
|
1118
|
+
initialCameraKey = key
|
|
1119
|
+
pendingFitKey = nil
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
} else {
|
|
1123
|
+
initialCameraKey = nil
|
|
1124
|
+
pendingFitKey = nil
|
|
1063
1125
|
}
|
|
1064
1126
|
}
|
|
1065
1127
|
|
|
1066
|
-
|
|
1128
|
+
override func layoutSubviews() {
|
|
1129
|
+
super.layoutSubviews()
|
|
1130
|
+
guard let key = pendingFitKey else { return }
|
|
1131
|
+
pendingFitKey = nil
|
|
1132
|
+
guard fitToOverlays() else {
|
|
1133
|
+
pendingFitKey = key
|
|
1134
|
+
return
|
|
1135
|
+
}
|
|
1136
|
+
initialCameraKey = key
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
@discardableResult
|
|
1140
|
+
private func fitToOverlays() -> Bool {
|
|
1141
|
+
guard bounds.width > 0, bounds.height > 0 else { return false }
|
|
1067
1142
|
var rect = MKMapRect.null
|
|
1068
1143
|
for overlay in overlaysByID.values { rect = rect.union(overlay.boundingMapRect) }
|
|
1069
1144
|
for annotation in annotationsByID.values {
|
|
1070
1145
|
let point = MKMapPoint(annotation.coordinate)
|
|
1071
1146
|
rect = rect.union(MKMapRect(x: point.x, y: point.y, width: 0, height: 0))
|
|
1072
1147
|
}
|
|
1073
|
-
guard !rect.isNull else { return }
|
|
1148
|
+
guard !rect.isNull else { return false }
|
|
1074
1149
|
let inset = UIEdgeInsets(top: 24, left: 24, bottom: 24, right: 24)
|
|
1075
1150
|
setVisibleMapRect(rect, edgePadding: inset, animated: false)
|
|
1151
|
+
return true
|
|
1076
1152
|
}
|
|
1077
1153
|
|
|
1078
1154
|
func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
|
|
@@ -1118,6 +1194,25 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1118
1194
|
}
|
|
1119
1195
|
}
|
|
1120
1196
|
|
|
1197
|
+
private static func regionKey(_ region: MKCoordinateRegion) -> String {
|
|
1198
|
+
"\(region.center.latitude),\(region.center.longitude),\(region.span.latitudeDelta),\(region.span.longitudeDelta)"
|
|
1199
|
+
}
|
|
1200
|
+
|
|
1201
|
+
private static func overlayKey(
|
|
1202
|
+
markers: [String: CLLocationCoordinate2D],
|
|
1203
|
+
polylines: [String: [CLLocationCoordinate2D]]
|
|
1204
|
+
) -> String {
|
|
1205
|
+
let markerKey = markers.keys.sorted().compactMap { id in
|
|
1206
|
+
guard let point = markers[id] else { return nil }
|
|
1207
|
+
return "\(id):\(point.latitude),\(point.longitude)"
|
|
1208
|
+
}.joined(separator: "|")
|
|
1209
|
+
let polylineKey = polylines.keys.sorted().compactMap { id in
|
|
1210
|
+
guard let points = polylines[id] else { return nil }
|
|
1211
|
+
return "\(id):" + points.map { "\($0.latitude),\($0.longitude)" }.joined(separator: ";")
|
|
1212
|
+
}.joined(separator: "|")
|
|
1213
|
+
return "m[\(markerKey)]p[\(polylineKey)]"
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1121
1216
|
private static func region(_ value: Any?) -> MKCoordinateRegion? {
|
|
1122
1217
|
guard let value = value as? [String: Any],
|
|
1123
1218
|
let center = value["center"] as? [String: Any],
|
|
@@ -1140,7 +1235,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
|
|
|
1140
1235
|
case "orange": return .systemOrange
|
|
1141
1236
|
case "purple": return .systemPurple
|
|
1142
1237
|
case "yellow": return .systemYellow
|
|
1143
|
-
default:
|
|
1238
|
+
default:
|
|
1239
|
+
guard let value else { return .systemBlue }
|
|
1240
|
+
return UIColor(css: value) ?? UIColor(hex: value) ?? .systemBlue
|
|
1144
1241
|
}
|
|
1145
1242
|
}
|
|
1146
1243
|
|
|
@@ -1371,13 +1468,32 @@ extension UIView {
|
|
|
1371
1468
|
let right = sides[1].frame.width
|
|
1372
1469
|
let bottom = sides[2].frame.height
|
|
1373
1470
|
let left = sides[3].frame.width
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1471
|
+
let frames = [
|
|
1472
|
+
CGRect(x: 0, y: 0, width: bounds.width, height: top),
|
|
1473
|
+
CGRect(x: max(0, bounds.width - right), y: 0, width: right, height: bounds.height),
|
|
1474
|
+
CGRect(x: 0, y: max(0, bounds.height - bottom), width: bounds.width, height: bottom),
|
|
1475
|
+
CGRect(x: 0, y: 0, width: left, height: bounds.height),
|
|
1476
|
+
]
|
|
1477
|
+
for (index, side) in sides.enumerated() {
|
|
1478
|
+
side.frame = frames[index]
|
|
1479
|
+
if let shape = side as? CAShapeLayer {
|
|
1480
|
+
shape.path = craftSideBorderPath(shape.bounds, index: index)
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1378
1483
|
}
|
|
1379
1484
|
}
|
|
1380
1485
|
|
|
1486
|
+
private func craftSideBorderPath(_ bounds: CGRect, index: Int) -> CGPath {
|
|
1487
|
+
let path = UIBezierPath()
|
|
1488
|
+
switch index {
|
|
1489
|
+
case 0: path.move(to: CGPoint(x: bounds.minX, y: bounds.midY)); path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
|
|
1490
|
+
case 1: path.move(to: CGPoint(x: bounds.midX, y: bounds.minY)); path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY))
|
|
1491
|
+
case 2: path.move(to: CGPoint(x: bounds.minX, y: bounds.midY)); path.addLine(to: CGPoint(x: bounds.maxX, y: bounds.midY))
|
|
1492
|
+
default: path.move(to: CGPoint(x: bounds.midX, y: bounds.minY)); path.addLine(to: CGPoint(x: bounds.midX, y: bounds.maxY))
|
|
1493
|
+
}
|
|
1494
|
+
return path.cgPath
|
|
1495
|
+
}
|
|
1496
|
+
|
|
1381
1497
|
fileprivate func craftApplyBorderStyle(_ style: String?, width: CGFloat, color: UIColor) {
|
|
1382
1498
|
guard width > 0, style == "dashed" || style == "dotted" else {
|
|
1383
1499
|
(objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
|
|
@@ -1410,6 +1526,7 @@ extension UIView {
|
|
|
1410
1526
|
(objc_getAssociatedObject(self, &craftNativeBorderLayerKey) as? CAShapeLayer)?.removeFromSuperlayer()
|
|
1411
1527
|
objc_setAssociatedObject(self, &craftNativeBorderLayerKey, nil, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
|
|
1412
1528
|
layer.borderWidth = 0
|
|
1529
|
+
(objc_getAssociatedObject(self, &craftNativeSideBorderLayersKey) as? [CALayer])?.forEach { $0.removeFromSuperlayer() }
|
|
1413
1530
|
let top = max(0, widths.count > 0 ? widths[0] : 0)
|
|
1414
1531
|
let right = max(0, widths.count > 1 ? widths[1] : 0)
|
|
1415
1532
|
let bottom = max(0, widths.count > 2 ? widths[2] : 0)
|
|
@@ -1421,9 +1538,25 @@ extension UIView {
|
|
|
1421
1538
|
CGRect(x: 0, y: 0, width: left, height: bounds.height),
|
|
1422
1539
|
]
|
|
1423
1540
|
let layers = frames.enumerated().map { index, frame in
|
|
1424
|
-
let side
|
|
1541
|
+
let side: CALayer
|
|
1542
|
+
if style == "dashed" || style == "dotted" {
|
|
1543
|
+
let shape = CAShapeLayer()
|
|
1544
|
+
shape.strokeColor = (colors.count > index ? colors[index] : .clear).cgColor
|
|
1545
|
+
shape.fillColor = nil
|
|
1546
|
+
shape.lineWidth = index % 2 == 0 ? frame.height : frame.width
|
|
1547
|
+
shape.lineCap = style == "dotted" ? .round : .butt
|
|
1548
|
+
shape.lineDashPattern = style == "dotted"
|
|
1549
|
+
? [NSNumber(value: Double(shape.lineWidth)), NSNumber(value: Double(shape.lineWidth * 1.5))]
|
|
1550
|
+
: [NSNumber(value: Double(shape.lineWidth * 3)), NSNumber(value: Double(shape.lineWidth * 2))]
|
|
1551
|
+
shape.contentsScale = window?.screen.scale ?? traitCollection.displayScale
|
|
1552
|
+
shape.frame = frame
|
|
1553
|
+
shape.path = craftSideBorderPath(shape.bounds, index: index)
|
|
1554
|
+
side = shape
|
|
1555
|
+
} else {
|
|
1556
|
+
side = CALayer()
|
|
1557
|
+
side.backgroundColor = (colors.count > index ? colors[index] : .clear).cgColor
|
|
1558
|
+
}
|
|
1425
1559
|
side.frame = frame
|
|
1426
|
-
side.backgroundColor = (colors.count > index ? colors[index] : .clear).cgColor
|
|
1427
1560
|
layer.addSublayer(side)
|
|
1428
1561
|
return side
|
|
1429
1562
|
}
|
|
@@ -1523,6 +1656,8 @@ extension UIView {
|
|
|
1523
1656
|
private class CraftNativeFlowView: UIStackView {
|
|
1524
1657
|
var pressActiveOpacity: CGFloat?
|
|
1525
1658
|
var pressBaseOpacity: CGFloat = 1
|
|
1659
|
+
var pressUnderlayColor: UIColor?
|
|
1660
|
+
var pressBaseBackgroundColor: UIColor?
|
|
1526
1661
|
var wrap = false { didSet { if wrap != oldValue { invalidateMeasurements() } } }
|
|
1527
1662
|
var alignItems = "stretch" { didSet { alignment = alignmentValue; if alignItems != oldValue { invalidateMeasurements() } } }
|
|
1528
1663
|
var alignContent = "flex-start" { didSet { if alignContent != oldValue { invalidateMeasurements() } } }
|
|
@@ -3174,10 +3309,16 @@ private final class CraftNativeSvgView: UIView {
|
|
|
3174
3309
|
var strokeWidth: CGFloat = 1
|
|
3175
3310
|
var color: String? = "#000000"
|
|
3176
3311
|
var opacity: CGFloat = 1
|
|
3312
|
+
var fillOpacity: CGFloat = 1
|
|
3313
|
+
var strokeOpacity: CGFloat = 1
|
|
3314
|
+
var strokeDashoffset: CGFloat = 0
|
|
3315
|
+
var strokeMiterlimit: CGFloat = 4
|
|
3316
|
+
var fillRule: String = "nonzero"
|
|
3177
3317
|
var linecap: String?
|
|
3178
3318
|
var linejoin: String?
|
|
3179
3319
|
var dashes: [CGFloat]?
|
|
3180
3320
|
var vectorEffect: String?
|
|
3321
|
+
var dominantBaseline: String?
|
|
3181
3322
|
|
|
3182
3323
|
func merged(_ node: [String: Any]) -> Paint {
|
|
3183
3324
|
var next = self
|
|
@@ -3196,10 +3337,16 @@ private final class CraftNativeSvgView: UIView {
|
|
|
3196
3337
|
// Computed style wins over the component prop, matching Android
|
|
3197
3338
|
// and the browser's cascade when both provide opacity.
|
|
3198
3339
|
if let opacity = finite(style["opacity"] ?? props["opacity"]) { next.opacity *= opacity }
|
|
3340
|
+
if let opacity = finite(value("fillOpacity")) { next.fillOpacity = opacity }
|
|
3341
|
+
if let opacity = finite(value("strokeOpacity")) { next.strokeOpacity = opacity }
|
|
3342
|
+
if let offset = finite(value("strokeDashoffset")) { next.strokeDashoffset = offset }
|
|
3343
|
+
if let limit = finite(value("strokeMiterlimit")), limit > 0 { next.strokeMiterlimit = limit }
|
|
3344
|
+
if let rule = value("fillRule") as? String { next.fillRule = rule }
|
|
3199
3345
|
if let cap = value("strokeLinecap") as? String { next.linecap = cap }
|
|
3200
3346
|
if let join = value("strokeLinejoin") as? String { next.linejoin = join }
|
|
3201
3347
|
if let dashes = value("strokeDasharray") as? [Any] { next.dashes = dashes.compactMap(finite).filter { $0 >= 0 } }
|
|
3202
3348
|
if let vectorEffect = value("vectorEffect") as? String { next.vectorEffect = vectorEffect }
|
|
3349
|
+
if let baseline = value("dominantBaseline") as? String { next.dominantBaseline = baseline }
|
|
3203
3350
|
return next
|
|
3204
3351
|
}
|
|
3205
3352
|
|
|
@@ -3257,10 +3404,13 @@ private final class CraftNativeSvgView: UIView {
|
|
|
3257
3404
|
default:
|
|
3258
3405
|
guard let path = CraftNativeSvgView.path((shape["props"] as? [String: Any])?["geometry"]) else { continue }
|
|
3259
3406
|
context.saveGState()
|
|
3260
|
-
|
|
3407
|
+
path.usesEvenOddFillRule = own.fillRule == "evenodd"
|
|
3261
3408
|
if let fill = own.resolve(own.fill) {
|
|
3409
|
+
context.saveGState()
|
|
3410
|
+
context.setAlpha(max(0, min(1, own.opacity * own.fillOpacity)))
|
|
3262
3411
|
fill.setFill()
|
|
3263
3412
|
path.fill()
|
|
3413
|
+
context.restoreGState()
|
|
3264
3414
|
}
|
|
3265
3415
|
if let stroke = own.resolve(own.stroke), own.strokeWidth > 0 {
|
|
3266
3416
|
// `vector-effect="non-scaling-stroke"`: the stroke is in the
|
|
@@ -3277,10 +3427,12 @@ private final class CraftNativeSvgView: UIView {
|
|
|
3277
3427
|
line = path
|
|
3278
3428
|
}
|
|
3279
3429
|
stroke.setStroke()
|
|
3430
|
+
context.setAlpha(max(0, min(1, own.opacity * own.strokeOpacity)))
|
|
3280
3431
|
line.lineWidth = own.strokeWidth
|
|
3432
|
+
line.miterLimit = own.strokeMiterlimit
|
|
3281
3433
|
line.lineCapStyle = own.linecap == "round" ? .round : own.linecap == "square" ? .square : .butt
|
|
3282
3434
|
line.lineJoinStyle = own.linejoin == "round" ? .round : own.linejoin == "bevel" ? .bevel : .miter
|
|
3283
|
-
if let dashes = own.dashes, !dashes.isEmpty { line.setLineDash(dashes, count: dashes.count, phase:
|
|
3435
|
+
if let dashes = own.dashes, !dashes.isEmpty { line.setLineDash(dashes, count: dashes.count, phase: own.strokeDashoffset) }
|
|
3284
3436
|
line.stroke()
|
|
3285
3437
|
}
|
|
3286
3438
|
context.restoreGState()
|
|
@@ -3295,9 +3447,13 @@ private final class CraftNativeSvgView: UIView {
|
|
|
3295
3447
|
let style = node["style"] as? [String: Any] ?? [:]
|
|
3296
3448
|
let size = number(props["fontSize"]) ?? number(style["fontSize"]) ?? 12
|
|
3297
3449
|
let weight: UIFont.Weight = (props["fontWeight"] as? String ?? style["fontWeight"] as? String).map { ["500": .medium, "600": .semibold, "700": .bold, "bold": .bold][$0] ?? .regular } ?? .regular
|
|
3450
|
+
let font = UIFont.systemFont(ofSize: size, weight: weight)
|
|
3451
|
+
let resolvedTextColor = paint.resolve(paint.fill) ?? UIColor.label
|
|
3452
|
+
let textAlpha = max(0, min(1, paint.opacity * paint.fillOpacity))
|
|
3453
|
+
let textColor = resolvedTextColor.withAlphaComponent(resolvedTextColor.cgColor.alpha * textAlpha)
|
|
3298
3454
|
let string = NSAttributedString(string: text, attributes: [
|
|
3299
|
-
.font:
|
|
3300
|
-
.foregroundColor:
|
|
3455
|
+
.font: font,
|
|
3456
|
+
.foregroundColor: textColor,
|
|
3301
3457
|
])
|
|
3302
3458
|
var x = number(props["x"]) ?? 0
|
|
3303
3459
|
let y = number(props["y"]) ?? 0
|
|
@@ -3306,8 +3462,14 @@ private final class CraftNativeSvgView: UIView {
|
|
|
3306
3462
|
case "end": x -= string.size().width
|
|
3307
3463
|
default: break
|
|
3308
3464
|
}
|
|
3309
|
-
|
|
3310
|
-
|
|
3465
|
+
let baseline: CGFloat
|
|
3466
|
+
switch paint.dominantBaseline {
|
|
3467
|
+
case "middle", "central", "mathematical": baseline = y - (font.ascender + font.descender) / 2
|
|
3468
|
+
case "hanging", "text-before-edge", "before-edge": baseline = y + font.ascender
|
|
3469
|
+
case "text-after-edge", "after-edge": baseline = y + font.descender
|
|
3470
|
+
default: baseline = y
|
|
3471
|
+
}
|
|
3472
|
+
string.draw(at: CGPoint(x: x, y: baseline - font.ascender))
|
|
3311
3473
|
}
|
|
3312
3474
|
|
|
3313
3475
|
static func path(_ value: Any?) -> UIBezierPath? {
|
|
@@ -3505,6 +3667,8 @@ private final class CraftNativeIconView: UIImageView {
|
|
|
3505
3667
|
|
|
3506
3668
|
private final class CraftNativeTextView: UITextView {
|
|
3507
3669
|
private let placeholderLabel = UILabel()
|
|
3670
|
+
private var nativeTextContainerInset: UIEdgeInsets?
|
|
3671
|
+
private var nativeLineFragmentPadding: CGFloat?
|
|
3508
3672
|
var placeholder: String? {
|
|
3509
3673
|
didSet { refreshPlaceholder() }
|
|
3510
3674
|
}
|
|
@@ -3529,6 +3693,20 @@ private final class CraftNativeTextView: UITextView {
|
|
|
3529
3693
|
configurePlaceholder()
|
|
3530
3694
|
}
|
|
3531
3695
|
|
|
3696
|
+
func applyAuthoredPadding(_ padding: UIEdgeInsets) {
|
|
3697
|
+
if nativeTextContainerInset == nil {
|
|
3698
|
+
nativeTextContainerInset = textContainerInset
|
|
3699
|
+
nativeLineFragmentPadding = textContainer.lineFragmentPadding
|
|
3700
|
+
}
|
|
3701
|
+
if padding == .zero {
|
|
3702
|
+
textContainerInset = nativeTextContainerInset ?? .zero
|
|
3703
|
+
textContainer.lineFragmentPadding = nativeLineFragmentPadding ?? 0
|
|
3704
|
+
} else {
|
|
3705
|
+
textContainerInset = padding
|
|
3706
|
+
textContainer.lineFragmentPadding = 0
|
|
3707
|
+
}
|
|
3708
|
+
}
|
|
3709
|
+
|
|
3532
3710
|
override func layoutSubviews() {
|
|
3533
3711
|
super.layoutSubviews()
|
|
3534
3712
|
let inset = textContainerInset
|
|
@@ -3582,6 +3760,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3582
3760
|
private var handlers: [ObjectIdentifier: String] = [:]
|
|
3583
3761
|
private var pressInHandlers: [ObjectIdentifier: String] = [:]
|
|
3584
3762
|
private var pressOutHandlers: [ObjectIdentifier: String] = [:]
|
|
3763
|
+
private var underlayShowHandlers: [ObjectIdentifier: String] = [:]
|
|
3764
|
+
private var underlayHideHandlers: [ObjectIdentifier: String] = [:]
|
|
3585
3765
|
private var focusHandlers: [ObjectIdentifier: String] = [:]
|
|
3586
3766
|
private var blurHandlers: [ObjectIdentifier: String] = [:]
|
|
3587
3767
|
private var endEditingHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -3607,6 +3787,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
3607
3787
|
private var imageSources: [ObjectIdentifier: String] = [:]
|
|
3608
3788
|
private var imageTasks: [ObjectIdentifier: URLSessionDataTask] = [:]
|
|
3609
3789
|
private var imageErrors: [ObjectIdentifier: String] = [:]
|
|
3790
|
+
/// Text inputs retain UIKit's generated accessibility value unless the
|
|
3791
|
+
/// bundle authored one; remember that distinction so removing the prop
|
|
3792
|
+
/// restores the native value instead of leaving stale metadata behind.
|
|
3793
|
+
private var authoredAccessibilityValues = Set<ObjectIdentifier>()
|
|
3610
3794
|
private var checkboxIndeterminate: [ObjectIdentifier: Bool] = [:]
|
|
3611
3795
|
private var tintedImages = Set<ObjectIdentifier>()
|
|
3612
3796
|
private var imageLoadStartHandlers: [ObjectIdentifier: String] = [:]
|
|
@@ -5138,6 +5322,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5138
5322
|
// reads the node's new layout style on its next pass.
|
|
5139
5323
|
CraftNativeFlexLayout.invalidateAncestors(of: next.view)
|
|
5140
5324
|
}
|
|
5325
|
+
if !applied.isEmpty {
|
|
5326
|
+
// A targeted mutation can change geometry without causing UIKit
|
|
5327
|
+
// to call viewDidLayoutSubviews again. Keep onLayout delivery
|
|
5328
|
+
// aligned with Android's post-mutation layout pass.
|
|
5329
|
+
scheduleLayoutEvents()
|
|
5330
|
+
}
|
|
5141
5331
|
restoreFocus(focused)
|
|
5142
5332
|
return true
|
|
5143
5333
|
}
|
|
@@ -5406,17 +5596,23 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5406
5596
|
}
|
|
5407
5597
|
case "Picker":
|
|
5408
5598
|
let picker = result as! UIPickerView
|
|
5409
|
-
let
|
|
5410
|
-
guard let
|
|
5411
|
-
|
|
5412
|
-
|
|
5413
|
-
|
|
5414
|
-
|
|
5415
|
-
|
|
5599
|
+
let itemOptions = (props["items"] as? [[String: Any]])?.compactMap { item -> (value: String, label: String)? in
|
|
5600
|
+
guard let label = item["label"] as? String, !label.isEmpty else { return nil }
|
|
5601
|
+
return (value: craftNativePickerValue(item["value"]) ?? label, label: label)
|
|
5602
|
+
} ?? []
|
|
5603
|
+
let options = itemOptions.isEmpty
|
|
5604
|
+
? children.compactMap { child -> (value: String, label: String)? in
|
|
5605
|
+
guard let node = child as? [String: Any], node["type"] as? String == "Text" else { return nil }
|
|
5606
|
+
let optionProps = node["props"] as? [String: Any] ?? [:]
|
|
5607
|
+
let label = (node["children"] as? [Any] ?? []).compactMap { $0 as? String }.joined()
|
|
5608
|
+
guard !label.isEmpty else { return nil }
|
|
5609
|
+
return (value: craftNativePickerValue(optionProps["value"]) ?? label, label: label)
|
|
5610
|
+
}
|
|
5611
|
+
: itemOptions
|
|
5416
5612
|
let pickerID = ObjectIdentifier(picker)
|
|
5417
5613
|
pickerOptions[pickerID] = options
|
|
5418
5614
|
picker.reloadAllComponents()
|
|
5419
|
-
if let selected = (props["selectedValue"]
|
|
5615
|
+
if let selected = craftNativePickerValue(props["selectedValue"]) ?? craftNativePickerValue(props["value"]),
|
|
5420
5616
|
let row = options.firstIndex(where: { $0.value == selected }) {
|
|
5421
5617
|
picker.selectRow(row, inComponent: 0, animated: false)
|
|
5422
5618
|
} else if !options.isEmpty {
|
|
@@ -5492,9 +5688,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5492
5688
|
let pressable = result as! CraftNativeFlowView
|
|
5493
5689
|
configureStack(pressable, style: style)
|
|
5494
5690
|
reconcileChildren(children, in: pressable, parent: current, path: path, style: style)
|
|
5495
|
-
|
|
5691
|
+
let disabled = props["disabled"] as? Bool == true
|
|
5692
|
+
pressable.pressActiveOpacity = disabled
|
|
5496
5693
|
? nil : number(props["activeOpacity"]) ?? 0.5
|
|
5497
5694
|
pressable.pressBaseOpacity = number(style["opacity"]) ?? 1
|
|
5695
|
+
pressable.pressUnderlayColor = type == "TouchableHighlight" && !disabled ? color(props["underlayColor"]) : nil
|
|
5696
|
+
pressable.pressBaseBackgroundColor = pressable.backgroundColor
|
|
5498
5697
|
case "Switch":
|
|
5499
5698
|
let toggle = result as! UISwitch
|
|
5500
5699
|
toggle.isOn = props["value"] as? Bool ?? props["checked"] as? Bool ?? false
|
|
@@ -5614,8 +5813,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5614
5813
|
)
|
|
5615
5814
|
case "Map":
|
|
5616
5815
|
let map = result as! CraftNativeMapView
|
|
5816
|
+
let mapMarkerHandler = nonEmptyHandler(events["onMarkerPress"])
|
|
5617
5817
|
map.configure(props: props, children: children) { [weak self, weak map] markerID in
|
|
5618
|
-
guard let self, let map,
|
|
5818
|
+
guard let self, let map,
|
|
5819
|
+
let handler = map.markerHandlers[markerID] ?? mapMarkerHandler else { return }
|
|
5619
5820
|
self.send(type: "EVENT", payload: [
|
|
5620
5821
|
"handlerName": handler,
|
|
5621
5822
|
"nativeEvent": ["id": markerID],
|
|
@@ -5649,13 +5850,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5649
5850
|
let refreshNode = children.compactMap { $0 as? [String: Any] }.first {
|
|
5650
5851
|
canonicalType($0["type"] as? String ?? "") == "RefreshControl"
|
|
5651
5852
|
}
|
|
5652
|
-
let
|
|
5853
|
+
let propRefresh = props["refreshControl"] as? [String: Any] ?? [:]
|
|
5854
|
+
let refreshProps = refreshNode?["props"] as? [String: Any] ?? propRefresh
|
|
5653
5855
|
let refreshEvents = refreshNode?["events"] as? [String: String] ?? [:]
|
|
5654
5856
|
let refreshHandlerName = nonEmptyHandler(refreshEvents["onRefresh"])
|
|
5655
5857
|
?? nonEmptyHandler(events["onRefresh"])
|
|
5858
|
+
let refreshing = (refreshProps["refreshing"] ?? props["refreshing"]) as? Bool == true
|
|
5656
5859
|
scroll.setRefreshHandler(refreshHandlerName.map { [weak self] handler in
|
|
5657
5860
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
5658
|
-
}, refreshing:
|
|
5861
|
+
}, refreshing: refreshing)
|
|
5659
5862
|
scroll.setRefreshAppearance(refreshProps)
|
|
5660
5863
|
var contentStyle = style
|
|
5661
5864
|
if let override = props["contentContainerStyle"] as? [String: Any] {
|
|
@@ -5690,6 +5893,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5690
5893
|
disabled ? nil : nonEmptyHandler(events["onPressOut"]),
|
|
5691
5894
|
for: result
|
|
5692
5895
|
)
|
|
5896
|
+
updateUnderlayHandlers(
|
|
5897
|
+
type == "TouchableHighlight" && !disabled ? nonEmptyHandler(events["onShowUnderlay"]) : nil,
|
|
5898
|
+
type == "TouchableHighlight" && !disabled ? nonEmptyHandler(events["onHideUnderlay"]) : nil,
|
|
5899
|
+
for: result
|
|
5900
|
+
)
|
|
5693
5901
|
if disabled { result.isUserInteractionEnabled = false }
|
|
5694
5902
|
}
|
|
5695
5903
|
applyAccessibility(props, type: type, to: result)
|
|
@@ -5993,6 +6201,13 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5993
6201
|
{ self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
5994
6202
|
}
|
|
5995
6203
|
)
|
|
6204
|
+
let horizontal = props["horizontal"] as? Bool == true
|
|
6205
|
+
list.showsVerticalScrollIndicator = props["showsVerticalScrollIndicator"] as? Bool ?? true
|
|
6206
|
+
list.showsHorizontalScrollIndicator = props["showsHorizontalScrollIndicator"] as? Bool ?? true
|
|
6207
|
+
list.isPagingEnabled = props["pagingEnabled"] as? Bool ?? false
|
|
6208
|
+
list.setScrollTarget(props["scrollTarget"])
|
|
6209
|
+
list.alwaysBounceVertical = props["alwaysBounceVertical"] as? Bool ?? !horizontal
|
|
6210
|
+
list.alwaysBounceHorizontal = props["alwaysBounceHorizontal"] as? Bool ?? horizontal
|
|
5996
6211
|
parent.children = []
|
|
5997
6212
|
}
|
|
5998
6213
|
|
|
@@ -6045,6 +6260,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6045
6260
|
transitionIDs.removeValue(forKey: id)
|
|
6046
6261
|
pressInHandlers.removeValue(forKey: id)
|
|
6047
6262
|
pressOutHandlers.removeValue(forKey: id)
|
|
6263
|
+
underlayShowHandlers.removeValue(forKey: id)
|
|
6264
|
+
underlayHideHandlers.removeValue(forKey: id)
|
|
6048
6265
|
layoutHandlers.removeValue(forKey: id)
|
|
6049
6266
|
lastLayoutFrames.removeValue(forKey: id)
|
|
6050
6267
|
focusHandlers.removeValue(forKey: id)
|
|
@@ -6076,6 +6293,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6076
6293
|
imageSources.removeValue(forKey: id)
|
|
6077
6294
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
6078
6295
|
imageErrors.removeValue(forKey: id)
|
|
6296
|
+
authoredAccessibilityValues.remove(id)
|
|
6079
6297
|
tintedImages.remove(id)
|
|
6080
6298
|
imageLoadStartHandlers.removeValue(forKey: id)
|
|
6081
6299
|
imageLoadHandlers.removeValue(forKey: id)
|
|
@@ -6117,6 +6335,19 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6117
6335
|
|
|
6118
6336
|
private var layoutEventsScheduled = false
|
|
6119
6337
|
|
|
6338
|
+
/// UIView.frame is undefined while a transform is applied. onLayout is
|
|
6339
|
+
/// the authored layout in the parent coordinate space, matching Android's
|
|
6340
|
+
/// left/top/right/bottom values rather than the transformed visual bounds.
|
|
6341
|
+
private func layoutFrame(of view: UIView) -> CGRect {
|
|
6342
|
+
guard !view.transform.isIdentity else { return view.frame }
|
|
6343
|
+
return CGRect(
|
|
6344
|
+
x: view.center.x - view.bounds.width / 2,
|
|
6345
|
+
y: view.center.y - view.bounds.height / 2,
|
|
6346
|
+
width: view.bounds.width,
|
|
6347
|
+
height: view.bounds.height
|
|
6348
|
+
)
|
|
6349
|
+
}
|
|
6350
|
+
|
|
6120
6351
|
/// A view inside the screen moved: report onLayout once the pass that
|
|
6121
6352
|
/// moved it is over, however deep it was.
|
|
6122
6353
|
fileprivate func scheduleLayoutEvents() {
|
|
@@ -6132,7 +6363,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6132
6363
|
private func emitLayoutEvents(for node: RenderedNode) {
|
|
6133
6364
|
let id = ObjectIdentifier(node.view)
|
|
6134
6365
|
if let handler = layoutHandlers[id], isEffectivelyVisible(node.view) {
|
|
6135
|
-
let frame = node.view
|
|
6366
|
+
let frame = layoutFrame(of: node.view)
|
|
6136
6367
|
if lastLayoutFrames[id] != frame {
|
|
6137
6368
|
lastLayoutFrames[id] = frame
|
|
6138
6369
|
send(type: "EVENT", payload: [
|
|
@@ -6205,8 +6436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6205
6436
|
let id = ObjectIdentifier(view)
|
|
6206
6437
|
if let pressIn, !pressIn.isEmpty { pressInHandlers[id] = pressIn } else { pressInHandlers.removeValue(forKey: id) }
|
|
6207
6438
|
if let pressOut, !pressOut.isEmpty { pressOutHandlers[id] = pressOut } else { pressOutHandlers.removeValue(forKey: id) }
|
|
6208
|
-
|
|
6439
|
+
let hasUnderlay = (view as? CraftNativeFlowView)?.pressUnderlayColor != nil
|
|
6440
|
+
guard pressInHandlers[id] != nil || pressOutHandlers[id] != nil || hasUnderlay else {
|
|
6209
6441
|
if let recognizer = pressFeedbackRecognizers.removeValue(forKey: id) { view.removeGestureRecognizer(recognizer) }
|
|
6442
|
+
if let pressable = view as? CraftNativeFlowView { pressable.backgroundColor = pressable.pressBaseBackgroundColor }
|
|
6210
6443
|
if !ownsTouches(view) { view.isUserInteractionEnabled = handlers[id] != nil || linkTargets[id] != nil || longPressHandlers[id] != nil || view is CraftNativeFlowView }
|
|
6211
6444
|
return
|
|
6212
6445
|
}
|
|
@@ -6222,6 +6455,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6222
6455
|
if !ownsTouches(view) { view.isUserInteractionEnabled = true }
|
|
6223
6456
|
}
|
|
6224
6457
|
|
|
6458
|
+
private func updateUnderlayHandlers(_ show: String?, _ hide: String?, for view: UIView) {
|
|
6459
|
+
let id = ObjectIdentifier(view)
|
|
6460
|
+
if let show, !show.isEmpty { underlayShowHandlers[id] = show } else { underlayShowHandlers.removeValue(forKey: id) }
|
|
6461
|
+
if let hide, !hide.isEmpty { underlayHideHandlers[id] = hide } else { underlayHideHandlers.removeValue(forKey: id) }
|
|
6462
|
+
}
|
|
6463
|
+
|
|
6225
6464
|
private func updateLongPressHandler(_ handler: String?, for view: UIView) {
|
|
6226
6465
|
let id = ObjectIdentifier(view)
|
|
6227
6466
|
if let handler, !handler.isEmpty {
|
|
@@ -6289,7 +6528,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6289
6528
|
applyAutofill(to: textView, props: props)
|
|
6290
6529
|
// The style's padding is where the text starts, as in a web textarea.
|
|
6291
6530
|
let padding = paddingInsets(style)
|
|
6292
|
-
if
|
|
6531
|
+
if let craftTextView = textView as? CraftNativeTextView {
|
|
6532
|
+
craftTextView.applyAuthoredPadding(padding)
|
|
6533
|
+
} else if padding != .zero {
|
|
6293
6534
|
if textView.textContainerInset != padding { textView.textContainerInset = padding }
|
|
6294
6535
|
textView.textContainer.lineFragmentPadding = 0
|
|
6295
6536
|
}
|
|
@@ -6714,7 +6955,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6714
6955
|
}
|
|
6715
6956
|
|
|
6716
6957
|
private func updateImage(_ view: UIImageView, source: Any?, defaultSource: Any? = nil) {
|
|
6717
|
-
let
|
|
6958
|
+
let sourceObject = source as? [String: Any]
|
|
6959
|
+
let uri = (source as? String) ?? sourceObject?["uri"] as? String
|
|
6960
|
+
let headers = (sourceObject?["headers"] as? [String: Any])?.reduce(into: [String: String]()) { result, entry in
|
|
6961
|
+
if let value = entry.value as? String { result[entry.key] = value }
|
|
6962
|
+
} ?? [:]
|
|
6963
|
+
let requestKey = uri.map { value in
|
|
6964
|
+
value + "\u{0}" + headers.sorted { $0.key < $1.key }.map { "\($0.key)=\($0.value)" }.joined(separator: "\u{0}")
|
|
6965
|
+
}
|
|
6718
6966
|
let id = ObjectIdentifier(view)
|
|
6719
6967
|
guard let uri, !uri.isEmpty else {
|
|
6720
6968
|
if imageSources[id] == "" { return }
|
|
@@ -6723,9 +6971,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6723
6971
|
imageFailure(view, uri: nil, message: "Image source is missing")
|
|
6724
6972
|
return
|
|
6725
6973
|
}
|
|
6726
|
-
guard imageSources[id] !=
|
|
6974
|
+
guard let requestKey, imageSources[id] != requestKey else { return }
|
|
6727
6975
|
imageTasks.removeValue(forKey: id)?.cancel()
|
|
6728
|
-
imageSources[id] =
|
|
6976
|
+
imageSources[id] = requestKey
|
|
6729
6977
|
imageErrors.removeValue(forKey: id)
|
|
6730
6978
|
view.image = immediateImage(defaultSource).map { imageForDisplay($0, view: view) }
|
|
6731
6979
|
imageDidChange(view)
|
|
@@ -6746,10 +6994,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6746
6994
|
return
|
|
6747
6995
|
}
|
|
6748
6996
|
if let url = URL(string: uri), Self.loadsImage(from: url) {
|
|
6749
|
-
|
|
6997
|
+
var request = URLRequest(url: url)
|
|
6998
|
+
for (name, value) in headers { request.setValue(value, forHTTPHeaderField: name) }
|
|
6999
|
+
let task = URLSession.shared.dataTask(with: request) { [weak self, weak view] data, _, _ in
|
|
6750
7000
|
guard let self, let view else { return }
|
|
6751
7001
|
DispatchQueue.main.async {
|
|
6752
|
-
guard self.imageSources[ObjectIdentifier(view)] ==
|
|
7002
|
+
guard self.imageSources[ObjectIdentifier(view)] == requestKey else { return }
|
|
6753
7003
|
self.imageTasks.removeValue(forKey: ObjectIdentifier(view))
|
|
6754
7004
|
guard let data, let image = UIImage(data: data) else {
|
|
6755
7005
|
self.imageFailure(view, uri: uri, message: "Image download failed")
|
|
@@ -6823,11 +7073,17 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
6823
7073
|
}
|
|
6824
7074
|
|
|
6825
7075
|
private func applyAccessibility(_ props: [String: Any], type: String, to view: UIView) {
|
|
7076
|
+
let id = ObjectIdentifier(view)
|
|
6826
7077
|
view.accessibilityLabel = (props["accessibilityLabel"] as? String)
|
|
6827
7078
|
?? ((type == "Image" || type == "ImageBackground") ? props["alt"] as? String : nil)
|
|
6828
7079
|
view.accessibilityHint = props["accessibilityHint"] as? String
|
|
6829
7080
|
if let value = props["accessibilityValue"] {
|
|
6830
7081
|
view.accessibilityValue = (value as? String) ?? (value as? NSNumber)?.stringValue
|
|
7082
|
+
authoredAccessibilityValues.insert(id)
|
|
7083
|
+
} else if authoredAccessibilityValues.remove(id) != nil {
|
|
7084
|
+
// Clearing an authored value lets UIKit expose the field's live
|
|
7085
|
+
// value again (particularly important for TextInput).
|
|
7086
|
+
view.accessibilityValue = nil
|
|
6831
7087
|
} else if type != "TextInput" {
|
|
6832
7088
|
view.accessibilityValue = nil
|
|
6833
7089
|
}
|
|
@@ -7216,13 +7472,21 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7216
7472
|
let id = ObjectIdentifier(view)
|
|
7217
7473
|
switch sender.state {
|
|
7218
7474
|
case .began:
|
|
7219
|
-
if let pressable = view as? CraftNativeFlowView
|
|
7220
|
-
|
|
7475
|
+
if let pressable = view as? CraftNativeFlowView {
|
|
7476
|
+
if let underlayColor = pressable.pressUnderlayColor { pressable.backgroundColor = underlayColor }
|
|
7477
|
+
if let activeOpacity = pressable.pressActiveOpacity {
|
|
7478
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity * activeOpacity }
|
|
7479
|
+
}
|
|
7221
7480
|
}
|
|
7222
7481
|
if let handler = pressInHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7482
|
+
if let handler = underlayShowHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7223
7483
|
case .ended, .cancelled, .failed:
|
|
7224
|
-
if let pressable = view as? CraftNativeFlowView {
|
|
7484
|
+
if let pressable = view as? CraftNativeFlowView {
|
|
7485
|
+
pressable.backgroundColor = pressable.pressBaseBackgroundColor
|
|
7486
|
+
UIView.animate(withDuration: 0.1) { pressable.alpha = pressable.pressBaseOpacity }
|
|
7487
|
+
}
|
|
7225
7488
|
if let handler = pressOutHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7489
|
+
if let handler = underlayHideHandlers[id] { send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": [:]]) }
|
|
7226
7490
|
default:
|
|
7227
7491
|
break
|
|
7228
7492
|
}
|
|
@@ -4,6 +4,10 @@ import XCTest
|
|
|
4
4
|
/// `/script`), one command a line:
|
|
5
5
|
///
|
|
6
6
|
/// launch | activate | terminate | home
|
|
7
|
+
/// notifications Home, then reveal Notification Center's Lock Screen presentation
|
|
8
|
+
/// systemexpect <text> [| secs] / systemabsent <text> / systemtap <text>
|
|
9
|
+
/// assert or tap SpringBoard content, including Live Activities
|
|
10
|
+
/// systemtree <name> SpringBoard accessibility tree → shots/<name>.txt
|
|
7
11
|
/// background <secs> Home, wait, back to the app
|
|
8
12
|
/// wait <seconds>
|
|
9
13
|
/// net offline|online
|
|
@@ -71,9 +75,10 @@ final class DriverTests: XCTestCase {
|
|
|
71
75
|
XCTAssertTrue(ok, what)
|
|
72
76
|
}
|
|
73
77
|
|
|
74
|
-
func element(containing text: String) -> XCUIElement? {
|
|
78
|
+
func element(containing text: String, in target: XCUIApplication? = nil) -> XCUIElement? {
|
|
79
|
+
let root = target ?? app
|
|
75
80
|
let predicate = NSPredicate(format: "label CONTAINS[c] %@ OR value CONTAINS[c] %@ OR identifier == %@", text, text, text)
|
|
76
|
-
for query in [
|
|
81
|
+
for query in [root.buttons, root.links, root.staticTexts, root.otherElements, root.cells, root.textFields] {
|
|
77
82
|
let matches = query.matching(predicate)
|
|
78
83
|
let count = min(matches.count, 25)
|
|
79
84
|
for index in 0..<count {
|
|
@@ -81,7 +86,7 @@ final class DriverTests: XCTestCase {
|
|
|
81
86
|
if candidate.exists && candidate.isHittable { return candidate }
|
|
82
87
|
}
|
|
83
88
|
}
|
|
84
|
-
let any =
|
|
89
|
+
let any = root.descendants(matching: .any).matching(predicate).firstMatch
|
|
85
90
|
return any.exists ? any : nil
|
|
86
91
|
}
|
|
87
92
|
|
|
@@ -141,6 +146,9 @@ final class DriverTests: XCTestCase {
|
|
|
141
146
|
}
|
|
142
147
|
|
|
143
148
|
func answerOpenPrompt() {
|
|
149
|
+
// A direct app link has already arrived. Querying an invisible
|
|
150
|
+
// SpringBoard here can stall its accessibility snapshot on iOS 27.
|
|
151
|
+
if app.wait(for: .runningForeground, timeout: 2) { return }
|
|
144
152
|
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
145
153
|
let open = springboard.buttons["Open"]
|
|
146
154
|
if open.waitForExistence(timeout: 4) { open.tap() }
|
|
@@ -172,6 +180,27 @@ final class DriverTests: XCTestCase {
|
|
|
172
180
|
case "activate": app.activate()
|
|
173
181
|
case "terminate": app.terminate()
|
|
174
182
|
case "home": XCUIDevice.shared.press(.home)
|
|
183
|
+
case "notifications":
|
|
184
|
+
XCUIDevice.shared.press(.home)
|
|
185
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
186
|
+
let start = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.005))
|
|
187
|
+
let end = springboard.coordinate(withNormalizedOffset: CGVector(dx: 0.1, dy: 0.85))
|
|
188
|
+
start.press(forDuration: 0.1, thenDragTo: end)
|
|
189
|
+
note("notifications: opened")
|
|
190
|
+
case "systemexpect":
|
|
191
|
+
let (text, seconds) = split(arg)
|
|
192
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
193
|
+
check(poll(seconds, { element(containing: text, in: springboard) }) != nil, "systemexpect \(text)")
|
|
194
|
+
case "systemabsent":
|
|
195
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
196
|
+
check(element(containing: arg, in: springboard) == nil, "systemabsent \(arg)")
|
|
197
|
+
case "systemtap":
|
|
198
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
199
|
+
if let target = poll(10, { element(containing: arg, in: springboard) }) { press(target); note("systemtap \(arg): ok") }
|
|
200
|
+
else { XCTFail("System element not found"); note("FAIL systemtap \(arg): NOT FOUND") }
|
|
201
|
+
case "systemtree":
|
|
202
|
+
let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
|
|
203
|
+
post("/save?name=\(arg).txt", Data(springboard.debugDescription.utf8))
|
|
175
204
|
case "background":
|
|
176
205
|
XCUIDevice.shared.press(.home)
|
|
177
206
|
Thread.sleep(forTimeInterval: Double(arg) ?? 3)
|