@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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@craft-native/ios",
3
- "version": "0.0.248",
3
+ "version": "0.0.250",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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 nav = UINavigationController(rootViewController: entry(for: rootPath, isTabRoot: true, depth: nil))
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 reportedVisible == false {
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), !suppressDismiss {
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 = false
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
- let container = ["ScrollView", "FlatList", "SectionList"].contains(type)
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", "Picker", "DatePicker": traits.insert(.adjustable)
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, AirDrop and Copy. Its title belongs in the
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? { url }
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.tap()
379
- Thread.sleep(forTimeInterval: 0.8)
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].tap()
406
- Thread.sleep(forTimeInterval: 0.6)
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)") }