@craft-native/ios 0.0.248 → 0.0.250
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/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -648,6 +648,9 @@ final class CraftChrome: ObservableObject {
|
|
|
648
648
|
var titleOverride: String? = nil
|
|
649
649
|
var iconTint: String? = nil
|
|
650
650
|
var iconPulse = false
|
|
651
|
+
/// The page's own symbol for the tab while it says so (a live
|
|
652
|
+
/// reading's filled heart), in place of the configured one.
|
|
653
|
+
var iconSymbol: String? = nil
|
|
651
654
|
var selectedSymbol: String? = nil
|
|
652
655
|
var selectedIconTint: String? = nil
|
|
653
656
|
var selectedIconPulse: Bool? = nil
|
|
@@ -658,7 +661,7 @@ final class CraftChrome: ObservableObject {
|
|
|
658
661
|
}
|
|
659
662
|
|
|
660
663
|
func iconAppearance(selected: Bool, motionAllowed: Bool) -> (symbol: String, tint: String?, pulse: Bool) {
|
|
661
|
-
let name = selected ? selectedSymbol ?? symbol : symbol
|
|
664
|
+
let name = selected ? selectedSymbol ?? iconSymbol ?? symbol : iconSymbol ?? symbol
|
|
662
665
|
let tint = selected ? selectedIconTint ?? iconTint : iconTint
|
|
663
666
|
let pulse = selected ? selectedIconPulse ?? iconPulse : iconPulse
|
|
664
667
|
return (name, tint, pulse && motionAllowed)
|
|
@@ -750,6 +753,9 @@ final class CraftChrome: ObservableObject {
|
|
|
750
753
|
if item.keys.contains("iconPulse") {
|
|
751
754
|
next.iconPulse = item["iconPulse"] as? Bool ?? false
|
|
752
755
|
}
|
|
756
|
+
if item.keys.contains("symbol") {
|
|
757
|
+
next.iconSymbol = Tab.normalizedSelectedSymbol(item["symbol"] as? String)
|
|
758
|
+
}
|
|
753
759
|
if item.keys.contains("selectedSymbol") {
|
|
754
760
|
next.selectedSymbol = Tab.normalizedSelectedSymbol(item["selectedSymbol"] as? String)
|
|
755
761
|
}
|
|
@@ -1249,8 +1249,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1249
1249
|
// create a second web root here: the shared WebView cannot draw
|
|
1250
1250
|
// both pages during the same gesture.
|
|
1251
1251
|
guard hybrid.routes.match(id) != nil else { return false }
|
|
1252
|
-
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id) }
|
|
1253
|
-
(target.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1252
|
+
target = CraftFramePacing.shared.measure("tab swipe mount \(id)") { makeStack(tab: id, rootPath: id, hidden: true) }
|
|
1254
1253
|
}
|
|
1255
1254
|
guard current.view !== target.view,
|
|
1256
1255
|
!(current.topViewController is CraftHybridWebSlot && target.topViewController is CraftHybridWebSlot),
|
|
@@ -1546,6 +1545,12 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1546
1545
|
|
|
1547
1546
|
private var visibleStack: UINavigationController? { stacks[currentTab] }
|
|
1548
1547
|
|
|
1548
|
+
/// A swipe preview can be visible without owning navigation or focus.
|
|
1549
|
+
/// UIKit appearance callbacks must follow the committed native stack.
|
|
1550
|
+
func isFocusedScreen(_ screen: CraftNativeScreenController) -> Bool {
|
|
1551
|
+
visibleStack?.topViewController === screen
|
|
1552
|
+
}
|
|
1553
|
+
|
|
1549
1554
|
var webIsVisible: Bool {
|
|
1550
1555
|
guard let webHost, let slot = visibleStack?.topViewController as? CraftHybridWebSlot else { return false }
|
|
1551
1556
|
return webHost.view.superview === slot.view
|
|
@@ -1557,7 +1562,11 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1557
1562
|
/// the view hierarchy, so its screen never takes the shell's chrome (the
|
|
1558
1563
|
/// bar, the tab accessory) from the one on screen.
|
|
1559
1564
|
private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
|
|
1560
|
-
let
|
|
1565
|
+
let root = entry(for: rootPath, isTabRoot: true, depth: nil)
|
|
1566
|
+
// Constructing the navigation controller can load the root's view and
|
|
1567
|
+
// mount its script immediately. Set its initial focus before that.
|
|
1568
|
+
if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
|
|
1569
|
+
let nav = UINavigationController(rootViewController: root)
|
|
1561
1570
|
nav.delegate = self
|
|
1562
1571
|
nav.view.backgroundColor = config.resolvedBackgroundColor
|
|
1563
1572
|
nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
|
|
@@ -3788,7 +3788,6 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
3788
3788
|
private let blocker = UIView()
|
|
3789
3789
|
private let backdropTap = UITapGestureRecognizer()
|
|
3790
3790
|
private var renderedVisible = false
|
|
3791
|
-
private var dismissSuppressedByRequest = false
|
|
3792
3791
|
private var animationGeneration = 0
|
|
3793
3792
|
var onRequestClose: ((String) -> Void)?
|
|
3794
3793
|
|
|
@@ -3877,25 +3876,15 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
|
|
|
3877
3876
|
transform = .identity
|
|
3878
3877
|
alpha = 0
|
|
3879
3878
|
isUserInteractionEnabled = true
|
|
3880
|
-
dismissSuppressedByRequest = false
|
|
3881
3879
|
onRequestClose = nil
|
|
3882
3880
|
}
|
|
3883
3881
|
|
|
3884
3882
|
func requestClose(reason: String = "backdrop") {
|
|
3885
3883
|
guard let onRequestClose else { return }
|
|
3886
|
-
dismissSuppressedByRequest = true
|
|
3887
3884
|
isUserInteractionEnabled = false
|
|
3888
3885
|
onRequestClose(reason)
|
|
3889
3886
|
}
|
|
3890
3887
|
|
|
3891
|
-
func consumeDismissSuppression() -> Bool {
|
|
3892
|
-
let suppressed = dismissSuppressedByRequest
|
|
3893
|
-
dismissSuppressedByRequest = false
|
|
3894
|
-
return suppressed
|
|
3895
|
-
}
|
|
3896
|
-
|
|
3897
|
-
func clearDismissSuppression() { dismissSuppressedByRequest = false }
|
|
3898
|
-
|
|
3899
3888
|
@objc private func backdropTapped() { requestClose(reason: "backdrop") }
|
|
3900
3889
|
|
|
3901
3890
|
func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch) -> Bool {
|
|
@@ -3993,6 +3982,10 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
|
|
|
3993
3982
|
required init?(coder: NSCoder) { nil }
|
|
3994
3983
|
|
|
3995
3984
|
override func touchesShouldCancel(in view: UIView) -> Bool {
|
|
3985
|
+
// Buttons and text fields yield once a scroll drag begins; an
|
|
3986
|
+
// ordinary tap still reaches them immediately. Value controls such
|
|
3987
|
+
// as sliders, switches and date pickers keep their tracking gesture.
|
|
3988
|
+
if view is UIButton || view is UITextField { return true }
|
|
3996
3989
|
if view is UIControl { return false }
|
|
3997
3990
|
return super.touchesShouldCancel(in: view)
|
|
3998
3991
|
}
|
|
@@ -5143,6 +5136,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5143
5136
|
/// sees, not a tab prepared behind it.
|
|
5144
5137
|
private var isShownOnScreen: Bool {
|
|
5145
5138
|
guard let window = view.window else { return false }
|
|
5139
|
+
if let shell = navigationController?.parent as? CraftHybridController,
|
|
5140
|
+
!shell.isFocusedScreen(self) { return false }
|
|
5146
5141
|
var candidate: UIView? = view
|
|
5147
5142
|
while let current = candidate, current !== window {
|
|
5148
5143
|
if current.isHidden { return false }
|
|
@@ -5326,13 +5321,14 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5326
5321
|
}
|
|
5327
5322
|
|
|
5328
5323
|
/// What a screen's script starts with that only the main thread may read:
|
|
5329
|
-
/// the app's state, the appearance, what the device can do.
|
|
5324
|
+
/// the app's state, the appearance, initial visibility, what the device can do.
|
|
5330
5325
|
private struct ScriptInputs {
|
|
5331
5326
|
let capabilityJSON: String
|
|
5332
5327
|
let appState: String
|
|
5333
5328
|
let colorScheme: String
|
|
5334
5329
|
let appearanceJSON: String
|
|
5335
5330
|
let sessionTokenKey: String
|
|
5331
|
+
let focused: Bool?
|
|
5336
5332
|
}
|
|
5337
5333
|
|
|
5338
5334
|
private func scriptInputs() -> ScriptInputs {
|
|
@@ -5343,7 +5339,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5343
5339
|
appState: CraftNativeActions.currentAppState(),
|
|
5344
5340
|
colorScheme: colorScheme,
|
|
5345
5341
|
appearanceJSON: appearanceJSON(),
|
|
5346
|
-
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config))
|
|
5342
|
+
sessionTokenKey: Self.jsonString(CraftSession.tokenKey(config)),
|
|
5343
|
+
focused: reportedVisible
|
|
5347
5344
|
)
|
|
5348
5345
|
}
|
|
5349
5346
|
|
|
@@ -5355,7 +5352,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
5355
5352
|
jsContext.setObject(routeProps as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
|
|
5356
5353
|
// Hidden before its script ran: the SCREEN_FOCUS saying so reached no
|
|
5357
5354
|
// callback, so the script reads it as it starts instead.
|
|
5358
|
-
if
|
|
5355
|
+
if inputs.focused == false {
|
|
5359
5356
|
jsContext.setObject(false, forKeyedSubscript: "__stxNativeFocused" as NSString)
|
|
5360
5357
|
}
|
|
5361
5358
|
jsContext.exceptionHandler = { [weak self] context, exception in
|
|
@@ -7359,11 +7356,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7359
7356
|
modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
|
|
7360
7357
|
{ [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
|
|
7361
7358
|
}
|
|
7362
|
-
if visible { modal.clearDismissSuppression() }
|
|
7363
|
-
let suppressDismiss = !visible && modal.consumeDismissSuppression()
|
|
7364
7359
|
if wasVisible != visible, visible || wasVisible == true {
|
|
7365
7360
|
let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
|
|
7366
|
-
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler)
|
|
7361
|
+
if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
|
|
7367
7362
|
send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
|
|
7368
7363
|
}
|
|
7369
7364
|
}
|
|
@@ -7441,10 +7436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
7441
7436
|
// already-stopped view changes hidesWhenStopped to false.
|
|
7442
7437
|
// Android applies the requested visibility on every render;
|
|
7443
7438
|
// keep the two hosts in lockstep for dynamic prop changes.
|
|
7444
|
-
indicator.isHidden = indicator.hidesWhenStopped
|
|
7439
|
+
indicator.isHidden = style["display"] as? String == "none" || indicator.hidesWhenStopped
|
|
7445
7440
|
} else {
|
|
7446
7441
|
indicator.startAnimating()
|
|
7447
|
-
indicator.isHidden =
|
|
7442
|
+
indicator.isHidden = style["display"] as? String == "none"
|
|
7448
7443
|
}
|
|
7449
7444
|
case "Svg":
|
|
7450
7445
|
let drawing = result as! CraftNativeSvgView
|
|
@@ -9097,7 +9092,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9097
9092
|
// A video with its system controls is a group too: VoiceOver reaches
|
|
9098
9093
|
// play, the scrubber, skipping and full screen inside it.
|
|
9099
9094
|
let controlledVideo = type == "Video" && (props["controls"] as? Bool) == true
|
|
9100
|
-
|
|
9095
|
+
// UIPickerView exposes adjustable component wheels. Making the
|
|
9096
|
+
// picker itself a leaf hides those wheels and their selected values.
|
|
9097
|
+
let container = ["ScrollView", "FlatList", "SectionList", "Picker"].contains(type)
|
|
9101
9098
|
|| ["tablist", "radiogroup", "list", "menu", "menubar", "toolbar"].contains(role ?? "")
|
|
9102
9099
|
|| controlledVideo
|
|
9103
9100
|
view.isAccessibilityElement = !container && role != "none" && (view.accessibilityLabel != nil || role != nil || ["Text", "Button", "Link", "Checkbox", "Image", "ImageBackground", "Video", "TextInput", "Picker", "DatePicker", "Switch", "Slider"].contains(type))
|
|
@@ -9108,7 +9105,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
|
|
|
9108
9105
|
// screen omits redundant accessibility metadata.
|
|
9109
9106
|
var traits: UIAccessibilityTraits = []
|
|
9110
9107
|
switch type {
|
|
9111
|
-
case "Slider", "
|
|
9108
|
+
case "Slider", "DatePicker": traits.insert(.adjustable)
|
|
9112
9109
|
default: break
|
|
9113
9110
|
}
|
|
9114
9111
|
switch role ?? type.lowercased() {
|
|
@@ -690,8 +690,15 @@ enum CraftNativePush {
|
|
|
690
690
|
|
|
691
691
|
// MARK: - Share sheet
|
|
692
692
|
|
|
693
|
-
/// A URL stays a URL in Messages
|
|
693
|
+
/// A URL stays a URL in Messages and AirDrop. Its title belongs in the
|
|
694
694
|
/// system preview and mail subject, rather than becoming a second text item.
|
|
695
|
+
///
|
|
696
|
+
/// Copy is the exception: it writes the address as text. A URL-only
|
|
697
|
+
/// pasteboard item has no textual representation, so `UIPasteboard.string`
|
|
698
|
+
/// is nil, a web page's `navigator.clipboard.readText()` gets nothing, and
|
|
699
|
+
/// so does the simulator's `pbpaste` - the shared link copied as nothing
|
|
700
|
+
/// anywhere text is read. Text that is an address is still detected as a
|
|
701
|
+
/// link wherever it is pasted.
|
|
695
702
|
final class CraftShareLink: NSObject, UIActivityItemSource {
|
|
696
703
|
let url: URL
|
|
697
704
|
let title: String
|
|
@@ -703,7 +710,9 @@ final class CraftShareLink: NSObject, UIActivityItemSource {
|
|
|
703
710
|
|
|
704
711
|
func activityViewControllerPlaceholderItem(_ activityViewController: UIActivityViewController) -> Any { url }
|
|
705
712
|
|
|
706
|
-
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
713
|
+
func activityViewController(_ activityViewController: UIActivityViewController, itemForActivityType activityType: UIActivity.ActivityType?) -> Any? {
|
|
714
|
+
activityType == .copyToPasteboard ? url.absoluteString : url
|
|
715
|
+
}
|
|
707
716
|
|
|
708
717
|
func activityViewController(_ activityViewController: UIActivityViewController, subjectForActivityType activityType: UIActivity.ActivityType?) -> String { title }
|
|
709
718
|
|
|
@@ -139,6 +139,33 @@ final class DriverTests: XCTestCase {
|
|
|
139
139
|
return nil
|
|
140
140
|
}
|
|
141
141
|
|
|
142
|
+
/// Whether `target`, or a field inside it, holds the keyboard.
|
|
143
|
+
func hasKeyboardFocus(_ target: XCUIElement) -> Bool {
|
|
144
|
+
if (target.value(forKey: "hasKeyboardFocus") as? Bool) == true { return true }
|
|
145
|
+
return target.descendants(matching: .any).matching(NSPredicate(format: "hasKeyboardFocus == 1")).firstMatch.exists
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/// Taps `target` until it holds the keyboard, for up to `seconds`.
|
|
149
|
+
///
|
|
150
|
+
/// A tap is not focus. The first keyboard a freshly created simulator
|
|
151
|
+
/// shows takes its keyboard process many seconds to start - one CI run
|
|
152
|
+
/// spent 20s delivering the tap - and the field had not become first
|
|
153
|
+
/// responder when the text was typed, which failed with "Neither element
|
|
154
|
+
/// nor any descendant has keyboard focus". A later tap lands once the app
|
|
155
|
+
/// is idle again.
|
|
156
|
+
func focus(_ target: XCUIElement, seconds: Double = 15) -> Bool {
|
|
157
|
+
let end = Date().addingTimeInterval(seconds)
|
|
158
|
+
repeat {
|
|
159
|
+
target.tap()
|
|
160
|
+
let settle = Date().addingTimeInterval(3)
|
|
161
|
+
repeat {
|
|
162
|
+
if hasKeyboardFocus(target) { return true }
|
|
163
|
+
Thread.sleep(forTimeInterval: 0.25)
|
|
164
|
+
} while Date() < settle
|
|
165
|
+
} while Date() < end
|
|
166
|
+
return hasKeyboardFocus(target)
|
|
167
|
+
}
|
|
168
|
+
|
|
142
169
|
func waitFor(_ text: String, _ seconds: Double) -> XCUIElement? {
|
|
143
170
|
poll(seconds) { element(containing: text) }
|
|
144
171
|
}
|
|
@@ -375,8 +402,10 @@ final class DriverTests: XCTestCase {
|
|
|
375
402
|
let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
|
|
376
403
|
let target = identified(idParts[0])
|
|
377
404
|
if target.waitForExistence(timeout: 10) {
|
|
378
|
-
target
|
|
379
|
-
|
|
405
|
+
guard focus(target) else {
|
|
406
|
+
check(false, "typeid \(idParts[0]) takes keyboard focus")
|
|
407
|
+
return
|
|
408
|
+
}
|
|
380
409
|
app.typeText(idParts.count > 1 ? idParts[1] : "")
|
|
381
410
|
note("typeid \(idParts[0]): ok")
|
|
382
411
|
} else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
|
|
@@ -402,8 +431,10 @@ final class DriverTests: XCTestCase {
|
|
|
402
431
|
let index = Int(fieldParts[0]) ?? 0
|
|
403
432
|
let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
|
|
404
433
|
if index >= 0 && index < fields.count {
|
|
405
|
-
fields[index]
|
|
406
|
-
|
|
434
|
+
guard focus(fields[index]) else {
|
|
435
|
+
check(false, "field \(index) takes keyboard focus")
|
|
436
|
+
return
|
|
437
|
+
}
|
|
407
438
|
app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
|
|
408
439
|
note("field \(index): ok")
|
|
409
440
|
} else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
|