@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
package/templates/CraftApp.swift
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
1439
|
-
|
|
1440
|
-
|
|
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
|
-
|
|
1447
|
-
view.
|
|
1448
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
4611
|
-
//
|
|
4612
|
-
|
|
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
|
-
|
|
4624
|
-
//
|
|
4625
|
-
|
|
4626
|
-
|
|
4627
|
-
|
|
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
|
-
|
|
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"]
|
|
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.
|
|
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
|
}
|