@craft-native/ios 0.0.242 → 0.0.244

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.242",
3
+ "version": "0.0.244",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -670,6 +670,7 @@ final class CraftChrome: ObservableObject {
670
670
 
671
671
  @Published var tabs: [Tab] = []
672
672
  @Published var tabAccessory: Tab?
673
+ private weak var tabAccessoryOwner: AnyObject?
673
674
  @Published var selected: String?
674
675
  let tabSwipeProgress = CraftTabSwipeProgress()
675
676
  private(set) var tabSwipePosition: CGFloat? {
@@ -771,7 +772,7 @@ final class CraftChrome: ObservableObject {
771
772
  tabs[index] = next
772
773
  }
773
774
  /// A destination beside the tab pill, outside the tab stacks and swipe order.
774
- func updateTabAccessory(_ item: [String: Any]?) {
775
+ func updateTabAccessory(_ item: [String: Any]?, owner: AnyObject? = nil) {
775
776
  let next: Tab?
776
777
  if let item, let path = item["path"] as? String,
777
778
  path.hasPrefix("/"), !path.hasPrefix("//"),
@@ -782,10 +783,17 @@ final class CraftChrome: ObservableObject {
782
783
  } else {
783
784
  next = nil
784
785
  }
786
+ tabAccessoryOwner = owner
785
787
  guard tabAccessory != next else { return }
786
788
  tabAccessory = next
787
789
  }
788
790
 
791
+ /// A retained or prepared screen cannot clear another screen's chrome.
792
+ func clearTabAccessory(ownedBy owner: AnyObject) {
793
+ guard tabAccessoryOwner === owner else { return }
794
+ updateTabAccessory(nil)
795
+ }
796
+
789
797
  func setHiddenByScreen(_ hidden: Bool) {
790
798
  guard hiddenByScreen != hidden else { return }
791
799
  hiddenByScreen = hidden
@@ -896,12 +904,9 @@ final class CraftChrome: ObservableObject {
896
904
  // stack. Cancellation belongs to the stack the finger left.
897
905
  tabSwipePrevious = previous
898
906
  tabSwipeTarget = id
899
- // Only the indicator moves now. The highlight follows it across the
900
- // midpoint (updateTabSwipe), as iOS's own tab bar lens does; turning
901
- // it at once rebuilt every tab in the swipe's first frame, which is
902
- // also when UIKit puts the incoming screen on screen.
903
907
  withAnimation(nil) {
904
908
  tabSwipePosition = tabs.firstIndex(where: { $0.id == previous }).map { CGFloat($0) }
909
+ selected = id
905
910
  }
906
911
  }
907
912
 
@@ -913,14 +918,11 @@ final class CraftChrome: ObservableObject {
913
918
  let to = tabs.firstIndex(where: { $0.id == target }) else { return }
914
919
  let position = CGFloat(from) + CGFloat(to - from) * min(1, max(0, progress))
915
920
  if tabSwipePosition != position { withAnimation(nil) { tabSwipePosition = position } }
916
- let lit = progress >= 0.5 ? target : previous
917
- if selected != lit { withAnimation(nil) { selected = lit } }
918
921
  }
919
922
 
920
923
  func settleTabSwipe(completed: Bool, duration: TimeInterval) {
921
924
  guard let id = completed ? tabSwipeTarget : tabSwipePrevious,
922
925
  let index = tabs.firstIndex(where: { $0.id == id }) else { return }
923
- if selected != id { withAnimation(nil) { selected = id } }
924
926
  withAnimation(Self.motion(.easeOut(duration: duration))) { tabSwipePosition = CGFloat(index) }
925
927
  }
926
928
 
@@ -1402,7 +1402,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1402
1402
 
1403
1403
  // MARK: Stacks
1404
1404
 
1405
- private func makeStack(tab: String, rootPath: String) -> UINavigationController {
1405
+ /// `hidden`: a stack prepared off screen. It is hidden before it joins
1406
+ /// the view hierarchy, so its screen never takes the shell's chrome (the
1407
+ /// bar, the tab accessory) from the one on screen.
1408
+ private func makeStack(tab: String, rootPath: String, hidden: Bool = false) -> UINavigationController {
1406
1409
  let nav = UINavigationController(rootViewController: entry(for: rootPath, isTabRoot: true, depth: nil))
1407
1410
  nav.delegate = self
1408
1411
  nav.view.backgroundColor = config.resolvedBackgroundColor
@@ -1411,6 +1414,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1411
1414
  addChild(nav)
1412
1415
  nav.view.frame = view.bounds
1413
1416
  nav.view.autoresizingMask = [.flexibleWidth, .flexibleHeight]
1417
+ nav.view.isHidden = hidden
1414
1418
  view.addSubview(nav.view)
1415
1419
  nav.didMove(toParent: self)
1416
1420
  // Only once the stack's view has loaded: the recognizer is made then,
@@ -1515,8 +1519,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1515
1519
  let id = tabs[next]
1516
1520
  guard self.stacks[id] == nil else { self.prepareAdjacentNativeTabs(); return }
1517
1521
  let nav = CraftFramePacing.shared.measure("prepare \(id)") { () -> UINavigationController in
1518
- let nav = self.makeStack(tab: id, rootPath: id)
1519
- nav.view.isHidden = true
1522
+ let nav = self.makeStack(tab: id, rootPath: id, hidden: true)
1520
1523
  nav.view.layoutIfNeeded()
1521
1524
  // Draw its text and pictures now. A hidden layer is not
1522
1525
  // drawn at commit, so the first swipe to this tab drew
@@ -1752,7 +1755,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1752
1755
  // Keep this screen up until the page has switched, rather than
1753
1756
  // showing it half way.
1754
1757
  cancelPendingTab()
1755
- if stacks[id] == nil { _ = makeStack(tab: id, rootPath: id); stacks[id]?.view.isHidden = true; view.bringSubviewToFront(visibleStack?.view ?? view) }
1758
+ if stacks[id] == nil { _ = makeStack(tab: id, rootPath: id, hidden: true); view.bringSubviewToFront(visibleStack?.view ?? view) }
1756
1759
  let deadline = DispatchWorkItem { [weak self] in self?.show(tab: id) }
1757
1760
  pendingTab = (id, deadline)
1758
1761
  DispatchQueue.main.asyncAfter(deadline: .now() + 0.45, execute: deadline)
@@ -29,6 +29,7 @@ struct CraftNativeMutationResult {
29
29
  final class CraftNativeMutationDocument {
30
30
  private struct Node {
31
31
  let id: String
32
+ let key: String?
32
33
  var type: String
33
34
  var props: [String: Any]
34
35
  var style: [String: Any]
@@ -177,6 +178,7 @@ final class CraftNativeMutationDocument {
177
178
  }
178
179
  nodes[id] = Node(
179
180
  id: id,
181
+ key: value["key"] as? String,
180
182
  type: type,
181
183
  props: dictionary(value["props"]),
182
184
  style: dictionary(value["style"]),
@@ -330,6 +332,7 @@ final class CraftNativeMutationDocument {
330
332
  "style": node.style,
331
333
  "events": node.events,
332
334
  ]
335
+ if let key = node.key { value["key"] = key }
333
336
  let text: [Any] = node.text
334
337
  let children: [Any] = node.children.compactMap { materialize($0, nodes: nodes) }
335
338
  value["children"] = text + children
@@ -350,6 +353,7 @@ final class CraftNativeMutationDocument {
350
353
  let children = value["children"] as? [Any] ?? []
351
354
  nodes[id] = Node(
352
355
  id: id,
356
+ key: value["key"] as? String,
353
357
  type: value["type"] as? String ?? "View",
354
358
  props: props,
355
359
  style: dictionary(value["style"]),
@@ -1435,21 +1435,47 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1435
1435
 
1436
1436
  private func configureMarkerView(_ view: MKAnnotationView, id: String) {
1437
1437
  if let bearing = markerBearings[id] {
1438
- let configuration = UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold)
1439
- .applying(UIImage.SymbolConfiguration(paletteColors: [.white, .systemGreen]))
1440
- // MapKit otherwise tints this as a monochrome template and loses
1441
- // the contrasting route arrow when displayed over map content.
1442
- view.image = UIImage(systemName: "arrow.up.circle.fill", withConfiguration: configuration)?
1443
- .withRenderingMode(.alwaysOriginal)
1438
+ let color = (markerPinColors[id] ?? .systemGreen).resolvedColor(with: traitCollection)
1439
+ view.tintColor = color
1440
+ view.image = Self.markerImage(color: color, direction: true)
1444
1441
  view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1445
1442
  } else {
1446
- view.tintColor = markerPinColors[id] ?? .systemRed
1447
- view.image = UIImage(systemName: "mappin.circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 28))?
1448
- .withTintColor(view.tintColor, renderingMode: .alwaysOriginal)
1443
+ let color = (markerPinColors[id] ?? .systemRed).resolvedColor(with: traitCollection)
1444
+ view.tintColor = color
1445
+ view.image = Self.markerImage(color: color, direction: false)
1449
1446
  view.transform = .identity
1450
1447
  }
1451
1448
  }
1452
1449
 
1450
+ private static let markerImages: NSCache<NSString, UIImage> = {
1451
+ let cache = NSCache<NSString, UIImage>()
1452
+ cache.countLimit = 64
1453
+ return cache
1454
+ }()
1455
+
1456
+ private static func markerImage(color: UIColor, direction: Bool) -> UIImage {
1457
+ let key = "\(direction ? "direction" : "pin"):\(color.description)" as NSString
1458
+ if let cached = markerImages.object(forKey: key) { return cached }
1459
+ let size = direction ? CGSize(width: 20, height: 24) : CGSize(width: 28, height: 28)
1460
+ // Explicit native drawing preserves the light backing independently
1461
+ // of MapKit's tint and SF Symbols' multicolor rendering preferences.
1462
+ let image = UIGraphicsImageRenderer(size: size).image { _ in
1463
+ let border = UIBezierPath(roundedRect: CGRect(origin: .zero, size: size).insetBy(dx: 1, dy: 1),
1464
+ cornerRadius: (size.width - 2) / 2)
1465
+ UIColor.white.setFill()
1466
+ border.fill()
1467
+ color.setStroke()
1468
+ border.lineWidth = 1.5
1469
+ border.stroke()
1470
+ let symbol = UIImage(systemName: direction ? "arrow.up" : "mappin",
1471
+ withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))?
1472
+ .withTintColor(color, renderingMode: .alwaysOriginal)
1473
+ symbol?.draw(in: direction ? CGRect(x: 5, y: 6, width: 10, height: 12) : CGRect(x: 7, y: 5, width: 14, height: 18))
1474
+ }.withRenderingMode(.alwaysOriginal)
1475
+ markerImages.setObject(image, forKey: key)
1476
+ return image
1477
+ }
1478
+
1453
1479
  override func layoutSubviews() {
1454
1480
  super.layoutSubviews()
1455
1481
  if tileTemplate != nil {
@@ -4598,19 +4624,32 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4598
4624
  }
4599
4625
  applyNavigationBarAppearance()
4600
4626
  // Each screen says whether it wants the tab bar, so one that hid it
4601
- // never leaves it hidden for the next.
4602
- restoreShellChrome()
4627
+ // never leaves it hidden for the next. Only the screen on screen:
4628
+ // UIKit tells a tab's screen prepared in a hidden stack that it
4629
+ // appears too, and that one took the accessory of the screen showing.
4630
+ if isShownOnScreen { restoreShellChrome() }
4603
4631
  refreshTraitDefaults()
4604
4632
  }
4605
4633
 
4634
+ /// In the window with nothing above it hidden: the screen the person
4635
+ /// sees, not a tab prepared behind it.
4636
+ private var isShownOnScreen: Bool {
4637
+ guard let window = view.window else { return false }
4638
+ var candidate: UIView? = view
4639
+ while let current = candidate, current !== window {
4640
+ if current.isHidden { return false }
4641
+ candidate = current.superview
4642
+ }
4643
+ return true
4644
+ }
4645
+
4606
4646
  override func viewWillDisappear(_ animated: Bool) {
4607
4647
  super.viewWillDisappear(animated)
4608
4648
  screenVisibilityChanged(false)
4609
4649
  stopCelebrations(in: view)
4610
- // The accessory belongs to the visible native screen. Clear the
4611
- // shared shell state before a hybrid/web route appears, otherwise a
4612
- // destination from the outgoing screen remains tappable on top of it.
4613
- CraftChrome.shared.updateTabAccessory(nil)
4650
+ // Release only this screen's accessory. A retained or prepared screen
4651
+ // can disappear after the destination has already published its own.
4652
+ CraftChrome.shared.clearTabAccessory(ownedBy: self)
4614
4653
  // Popped (or its stack dismissed) while the bar was away: it comes
4615
4654
  // back for whatever shows next, a web page included.
4616
4655
  if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
@@ -4620,11 +4659,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4620
4659
 
4621
4660
  override func viewDidAppear(_ animated: Bool) {
4622
4661
  super.viewDidAppear(animated)
4623
- screenVisibilityChanged(true)
4624
- // A navigation breadcrumb for the crash reporter: the trail a crash
4625
- // report carries is the screens that led to it.
4626
- if !isAppScript, let routeName, !routeName.isEmpty {
4627
- CraftCrashReporter.shared.screenAppeared(routeName)
4662
+ // A tab prepared in a hidden stack "appears" to UIKit as well; the
4663
+ // shell says when it really shows (screenVisibilityChanged).
4664
+ if isShownOnScreen {
4665
+ screenVisibilityChanged(true)
4666
+ // A navigation breadcrumb for the crash reporter: the trail a
4667
+ // crash report carries is the screens that led to it.
4668
+ if !isAppScript, let routeName, !routeName.isEmpty {
4669
+ CraftCrashReporter.shared.screenAppeared(routeName)
4670
+ }
4628
4671
  }
4629
4672
  // onLayout reports views that are on screen: a pushed screen lays
4630
4673
  // out before it has a window, and nothing moved again once it had
@@ -4651,7 +4694,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
4651
4694
  private func restoreShellChrome() {
4652
4695
  guard !isAppScript else { return }
4653
4696
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
4654
- CraftChrome.shared.updateTabAccessory(tabAccessory)
4697
+ CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
4655
4698
  }
4656
4699
 
4657
4700
  /// The screen came on screen or went off it: a screen pushed over it was
@@ -5322,7 +5365,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5322
5365
  if options.keys.contains("tabAccessory") {
5323
5366
  tabAccessory = options["tabAccessory"] as? [String: Any]
5324
5367
  if navigationController?.topViewController === self, view.window != nil {
5325
- CraftChrome.shared.updateTabAccessory(tabAccessory)
5368
+ CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
5326
5369
  }
5327
5370
  }
5328
5371
  if options.keys.contains("title") {
@@ -6847,7 +6890,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6847
6890
 
6848
6891
  private func explicitKey(_ node: [String: Any], props: [String: Any]) -> String? {
6849
6892
  if let authored = authoredKey(node, props: props) { return authored }
6850
- return (node["id"] as? String).flatMap { $0.isEmpty ? nil : $0 }
6893
+ // Repeated test selectors are valid for list rows. Retain each row by
6894
+ // its unique protocol ID before considering legacy selector identity.
6895
+ return [node["id"], props["testID"]]
6896
+ .compactMap { $0 as? String }
6897
+ .first { !$0.isEmpty }
6851
6898
  }
6852
6899
 
6853
6900
  /// The key a node's author gave it. A wrapper of one child (a
@@ -6859,7 +6906,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6859
6906
  private func authoredKey(_ node: [String: Any], props: [String: Any]) -> String? {
6860
6907
  // The current @stacksjs/stx native compiler emits key in props; accept the IR
6861
6908
  // field too so keyed children keep working when the compiler adopts it.
6862
- let direct = [node["key"], props["key"], props["testID"]]
6909
+ let direct = [node["key"], props["key"]]
6863
6910
  .compactMap { $0 as? String }
6864
6911
  .first { !$0.isEmpty }
6865
6912
  if let direct { return direct }
@@ -448,7 +448,11 @@ final class DriverTests: XCTestCase {
448
448
  var found = element(containing: arg)
449
449
  var swipes = 0
450
450
  while (found == nil || !(found!.isHittable)) && swipes < 8 {
451
- app.swipeUp()
451
+ if let target = found, !target.frame.isEmpty, target.frame.midY < app.frame.midY {
452
+ app.swipeDown()
453
+ } else {
454
+ app.swipeUp()
455
+ }
452
456
  swipes += 1
453
457
  found = element(containing: arg)
454
458
  }