@craft-native/ios 0.0.249 → 0.0.251

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.249",
3
+ "version": "0.0.251",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -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,9 +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 {
3996
- // A drag beginning on a button scrolls; lifting without dragging
3997
- // remains a tap. Sliders, switches and text fields keep tracking.
3998
- if view is UIButton { return true }
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 }
3999
3989
  if view is UIControl { return false }
4000
3990
  return super.touchesShouldCancel(in: view)
4001
3991
  }
@@ -7366,11 +7356,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7366
7356
  modal.onRequestClose = nonEmptyHandler(events["onRequestClose"]).map { handler in
7367
7357
  { [weak self] reason in self?.send(type: "EVENT", payload: ["handlerName": handler, "nativeEvent": ["reason": reason]]) }
7368
7358
  }
7369
- if visible { modal.clearDismissSuppression() }
7370
- let suppressDismiss = !visible && modal.consumeDismissSuppression()
7371
7359
  if wasVisible != visible, visible || wasVisible == true {
7372
7360
  let lifecycleHandler = visible ? events["onShow"] : events["onDismiss"]
7373
- if let lifecycleHandler = nonEmptyHandler(lifecycleHandler), !suppressDismiss {
7361
+ if let lifecycleHandler = nonEmptyHandler(lifecycleHandler) {
7374
7362
  send(type: "EVENT", payload: ["handlerName": lifecycleHandler, "nativeEvent": [:]])
7375
7363
  }
7376
7364
  }
@@ -7448,10 +7436,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7448
7436
  // already-stopped view changes hidesWhenStopped to false.
7449
7437
  // Android applies the requested visibility on every render;
7450
7438
  // keep the two hosts in lockstep for dynamic prop changes.
7451
- indicator.isHidden = indicator.hidesWhenStopped
7439
+ indicator.isHidden = style["display"] as? String == "none" || indicator.hidesWhenStopped
7452
7440
  } else {
7453
7441
  indicator.startAnimating()
7454
- indicator.isHidden = false
7442
+ indicator.isHidden = style["display"] as? String == "none"
7455
7443
  }
7456
7444
  case "Svg":
7457
7445
  let drawing = result as! CraftNativeSvgView
@@ -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,57 @@ final class DriverTests: XCTestCase {
139
139
  return nil
140
140
  }
141
141
 
142
+ /// About a third of a screen of scrolling, as a held drag.
143
+ ///
144
+ /// `app.swipeUp()` is a flick, and how far a flick throws a scroll view
145
+ /// depends on how its touches arrive: on a loaded machine, with a map
146
+ /// keeping the app busy, eight of them moved a trail page less than one
147
+ /// screen. A drag that stops before it lifts moves the content by its
148
+ /// own length every time. It runs between the middle of the screen and
149
+ /// the upper part, where a flick began too, so it stays off the keys when
150
+ /// a keyboard is up - an iOS 27 simulator reports its keyboard's frame
151
+ /// below the screen while drawing it over the lower half - and it stops
152
+ /// short of a keyboard whose frame is known.
153
+ func scrollPage(towardTop: Bool) {
154
+ let frame = app.frame
155
+ var lower = frame.minY + frame.height * 0.55
156
+ let keyboard = app.keyboards.firstMatch
157
+ if keyboard.exists, !keyboard.frame.isEmpty, keyboard.frame.minY < frame.maxY {
158
+ lower = min(lower, keyboard.frame.minY - 24)
159
+ }
160
+ let upper = max(frame.minY + frame.height * 0.15, lower - frame.height * 0.4)
161
+ let point = { (y: CGFloat) in self.app.coordinate(withNormalizedOffset: .zero).withOffset(CGVector(dx: frame.midX, dy: y)) }
162
+ let (from, to) = towardTop ? (point(upper), point(lower)) : (point(lower), point(upper))
163
+ from.press(forDuration: 0.05, thenDragTo: to, withVelocity: .default, thenHoldForDuration: 0.2)
164
+ }
165
+
166
+ /// Whether `target`, or a field inside it, holds the keyboard.
167
+ func hasKeyboardFocus(_ target: XCUIElement) -> Bool {
168
+ if (target.value(forKey: "hasKeyboardFocus") as? Bool) == true { return true }
169
+ return target.descendants(matching: .any).matching(NSPredicate(format: "hasKeyboardFocus == 1")).firstMatch.exists
170
+ }
171
+
172
+ /// Taps `target` until it holds the keyboard, for up to `seconds`.
173
+ ///
174
+ /// A tap is not focus. The first keyboard a freshly created simulator
175
+ /// shows takes its keyboard process many seconds to start - one CI run
176
+ /// spent 20s delivering the tap - and the field had not become first
177
+ /// responder when the text was typed, which failed with "Neither element
178
+ /// nor any descendant has keyboard focus". A later tap lands once the app
179
+ /// is idle again.
180
+ func focus(_ target: XCUIElement, seconds: Double = 15) -> Bool {
181
+ let end = Date().addingTimeInterval(seconds)
182
+ repeat {
183
+ target.tap()
184
+ let settle = Date().addingTimeInterval(3)
185
+ repeat {
186
+ if hasKeyboardFocus(target) { return true }
187
+ Thread.sleep(forTimeInterval: 0.25)
188
+ } while Date() < settle
189
+ } while Date() < end
190
+ return hasKeyboardFocus(target)
191
+ }
192
+
142
193
  func waitFor(_ text: String, _ seconds: Double) -> XCUIElement? {
143
194
  poll(seconds) { element(containing: text) }
144
195
  }
@@ -375,8 +426,10 @@ final class DriverTests: XCTestCase {
375
426
  let idParts = arg.split(separator: " ", maxSplits: 1).map(String.init)
376
427
  let target = identified(idParts[0])
377
428
  if target.waitForExistence(timeout: 10) {
378
- target.tap()
379
- Thread.sleep(forTimeInterval: 0.8)
429
+ guard focus(target) else {
430
+ check(false, "typeid \(idParts[0]) takes keyboard focus")
431
+ return
432
+ }
380
433
  app.typeText(idParts.count > 1 ? idParts[1] : "")
381
434
  note("typeid \(idParts[0]): ok")
382
435
  } else { XCTFail("Element not found"); note("FAIL typeid \(idParts[0]): NOT FOUND") }
@@ -402,8 +455,10 @@ final class DriverTests: XCTestCase {
402
455
  let index = Int(fieldParts[0]) ?? 0
403
456
  let fields = app.textFields.allElementsBoundByIndex + app.secureTextFields.allElementsBoundByIndex
404
457
  if index >= 0 && index < fields.count {
405
- fields[index].tap()
406
- Thread.sleep(forTimeInterval: 0.6)
458
+ guard focus(fields[index]) else {
459
+ check(false, "field \(index) takes keyboard focus")
460
+ return
461
+ }
407
462
  app.typeText(fieldParts.count > 1 ? fieldParts[1] : "")
408
463
  note("field \(index): ok")
409
464
  } else { XCTFail("Element not found"); note("FAIL field \(index): NOT FOUND (\(fields.count) fields)") }
@@ -447,14 +502,11 @@ final class DriverTests: XCTestCase {
447
502
  else { XCTFail("Element not found"); note("FAIL tab \(arg): NOT FOUND") }
448
503
  case "scrollto":
449
504
  var found = element(containing: arg)
450
- var swipes = 0
451
- while (found == nil || !(found!.isHittable)) && swipes < 8 {
452
- if let target = found, !target.frame.isEmpty, target.frame.midY < app.frame.midY {
453
- app.swipeDown()
454
- } else {
455
- app.swipeUp()
456
- }
457
- swipes += 1
505
+ var drags = 0
506
+ while (found == nil || !(found!.isHittable)) && drags < 12 {
507
+ let towardTop = found.map { !$0.frame.isEmpty && $0.frame.midY < app.frame.midY } ?? false
508
+ scrollPage(towardTop: towardTop)
509
+ drags += 1
458
510
  found = element(containing: arg)
459
511
  }
460
512
  check(found != nil && found!.isHittable, "scrollto \(arg)")