@craft-native/ios 0.0.262 → 0.0.276

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.262",
3
+ "version": "0.0.276",
4
4
  "homepage": "https://github.com/craft-native/craft/tree/main/packages/ios#readme",
5
5
  "repository": {
6
6
  "type": "git",
@@ -797,10 +797,21 @@ final class CraftChrome: ObservableObject {
797
797
  tabAccessory = next
798
798
  }
799
799
 
800
- /// A retained or prepared screen cannot clear another screen's chrome.
801
- func clearTabAccessory(ownedBy owner: AnyObject) {
800
+ /// A screen going away gives its accessory up without taking it off the
801
+ /// bar (a retained or prepared screen cannot clear another's): the screen coming in shows it until it says what it wants. Taken
802
+ /// off at once, the pill stretched across the bar and back while the
803
+ /// next screen (Messages, opened from the accessory; a tab opened for the
804
+ /// first time) started up and set the same accessory again.
805
+ func releaseTabAccessory(ownedBy owner: AnyObject) {
802
806
  guard tabAccessoryOwner === owner else { return }
803
- updateTabAccessory(nil)
807
+ tabAccessoryOwner = nil
808
+ }
809
+
810
+ /// Takes off an accessory nobody claims any more: the screen now showing
811
+ /// drew without asking for one, or is a page that never does.
812
+ func clearUnclaimedTabAccessory() {
813
+ guard tabAccessoryOwner == nil, tabAccessory != nil else { return }
814
+ tabAccessory = nil
804
815
  }
805
816
 
806
817
  func setHiddenByScreen(_ hidden: Bool) {
@@ -6347,6 +6358,7 @@ struct CraftWebView: UIViewRepresentable {
6347
6358
 
6348
6359
  SecItemDelete(query as CFDictionary)
6349
6360
  let status = SecItemAdd(query as CFDictionary, nil)
6361
+ CraftKeychain.forget(account: key)
6350
6362
  return status == errSecSuccess
6351
6363
  }
6352
6364
 
@@ -6373,12 +6385,14 @@ struct CraftWebView: UIViewRepresentable {
6373
6385
  kSecAttrAccount as String: key
6374
6386
  ]
6375
6387
  let status = SecItemDelete(query as CFDictionary)
6388
+ CraftKeychain.forget(account: key)
6376
6389
  return status == errSecSuccess || status == errSecItemNotFound
6377
6390
  }
6378
6391
 
6379
6392
  private func secureClear() -> Bool {
6380
6393
  let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword]
6381
6394
  let status = SecItemDelete(query as CFDictionary)
6395
+ CraftKeychain.forget(account: nil)
6382
6396
  return status == errSecSuccess || status == errSecItemNotFound
6383
6397
  }
6384
6398
 
@@ -8259,6 +8273,7 @@ struct CraftWebView: UIViewRepresentable {
8259
8273
  SecItemDelete(query as CFDictionary)
8260
8274
 
8261
8275
  let status = SecItemAdd(query as CFDictionary, nil)
8276
+ CraftKeychain.forget(account: key)
8262
8277
  if status == errSecSuccess {
8263
8278
  resolveCallback(callbackId, result: ["success": true, "key": key])
8264
8279
  } else {
@@ -8301,6 +8316,7 @@ struct CraftWebView: UIViewRepresentable {
8301
8316
  }
8302
8317
 
8303
8318
  let status = SecItemDelete(query as CFDictionary)
8319
+ CraftKeychain.forget(account: key)
8304
8320
  if status == errSecSuccess || status == errSecItemNotFound {
8305
8321
  resolveCallback(callbackId, result: ["success": true, "key": key])
8306
8322
  } else {
@@ -240,8 +240,10 @@ enum CraftHybridKeychain {
240
240
  static let nativeService = "dev.craft.native.secure"
241
241
 
242
242
  static func set(_ key: String, value: String?) {
243
+ defer { CraftKeychain.forget(account: key) }
243
244
  // Every item for the account, whatever its service.
244
245
  SecItemDelete([kSecClass as String: kSecClassGenericPassword, kSecAttrAccount as String: key] as CFDictionary)
246
+ CraftKeychain.forget(account: key)
245
247
  guard let value else { return }
246
248
  for service in [nil, nativeService] {
247
249
  var add: [String: Any] = [
@@ -355,6 +357,7 @@ final class CraftHybrid: NSObject {
355
357
  isConfigured = true
356
358
  self.config = config
357
359
  CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
360
+ if CraftNativeScriptThread.isBackground { CraftNativeBundle.prewarm() }
358
361
  hasWeb = config.renderer != "native"
359
362
  siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
360
363
  ["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
@@ -2034,6 +2037,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2034
2037
  func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
2035
2038
  lastNavigation = CACurrentMediaTime()
2036
2039
  CraftFramePacing.shared.end("navigation")
2040
+ // A page shows no native accessory; one a screen gave up is taken off.
2041
+ if viewController is CraftHybridWebSlot { CraftChrome.shared.clearUnclaimedTabAccessory() }
2037
2042
  let key = ObjectIdentifier(navigationController)
2038
2043
  let before = shownStacks[key] ?? []
2039
2044
  let now = navigationController.viewControllers
@@ -2291,16 +2296,34 @@ final class CraftFramePacing: NSObject {
2291
2296
  }
2292
2297
  }
2293
2298
 
2294
- /// One main-thread block, timed: a screen mount, a bundle evaluation.
2299
+ /// One block, timed: a screen mount, a bundle evaluation. Labelled by the
2300
+ /// thread it ran on, since a screen's script now runs off the main thread
2301
+ /// and a bundle's time there costs no frames.
2295
2302
  @discardableResult
2296
2303
  func measure<T>(_ name: String, _ work: () -> T) -> T {
2297
2304
  guard enabled else { return work() }
2298
2305
  let started = CACurrentMediaTime()
2299
2306
  let result = work()
2300
- write(String(format: "%@ main=%.1fms", name, (CACurrentMediaTime() - started) * 1000))
2307
+ write(String(format: "%@ %@=%.1fms", name, Thread.isMainThread ? "main" : "script", (CACurrentMediaTime() - started) * 1000))
2301
2308
  return result
2302
2309
  }
2303
2310
 
2311
+ /// One piece of the screens' JavaScript (CraftNativeScriptThread): how
2312
+ /// long it waited behind other screens' work, and how long it ran. The
2313
+ /// screens share one serial script thread, so a screen in front can sit
2314
+ /// behind hidden tabs reloading; `waited` is where that shows.
2315
+ func scriptWork(_ name: String, waited: CFTimeInterval, ran: CFTimeInterval) {
2316
+ guard enabled else { return }
2317
+ write(String(format: "script %@ waited=%.1fms ran=%.1fms", name, waited * 1000, ran * 1000))
2318
+ }
2319
+
2320
+ /// A capability a screen asked for (a request, the keychain), from the
2321
+ /// ask to the answer. Logged from 4ms, as script work is.
2322
+ func capability(_ name: String, took: CFTimeInterval) {
2323
+ guard enabled, took >= 0.004 else { return }
2324
+ write(String(format: "capability %@ took=%.1fms", name, took * 1000))
2325
+ }
2326
+
2304
2327
  /// Each line to the log and to `Library/Caches/craft-frame-pacing.log`
2305
2328
  /// in the app's container, which a test run reads back whole (a
2306
2329
  /// simulator's unified log can drop an app's lines under load). Written
@@ -219,11 +219,13 @@ enum CraftNativeActions {
219
219
  }
220
220
  // A write that moves the sign-in token is the account's change:
221
221
  // every screen hears it (CraftSession). Reads skip the check.
222
- if ["set", "remove", "delete", "clear"].contains(method) {
223
- sync = CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write)
224
- } else {
225
- sync = write()
222
+ // Off the main thread, in order (CraftKeychain.queue).
223
+ let isWrite = ["set", "remove", "delete", "clear"].contains(method)
224
+ CraftKeychain.queue.async {
225
+ let result = isWrite ? CraftSession.observe(key: method == "clear" ? nil : args.first as? String, config: config, write) : write()
226
+ complete(requestToken, result, completion)
226
227
  }
228
+ return
227
229
  case ("Biometrics", "isAvailable"):
228
230
  sync = config.enableBiometric ? .success(biometricContext().canEvaluatePolicy(.deviceOwnerAuthenticationWithBiometrics, error: nil)) : failure("CAPABILITY_DISABLED", "Biometric authentication is disabled")
229
231
  case ("Biometrics", "getBiometricType"):
@@ -957,6 +959,7 @@ enum CraftNativeActions {
957
959
  if method == "clear" {
958
960
  let query: [String: Any] = [kSecClass as String: kSecClassGenericPassword, kSecAttrService as String: service]
959
961
  let status = SecItemDelete(query as CFDictionary)
962
+ CraftKeychain.forget(account: nil)
960
963
  guard status == errSecSuccess || status == errSecItemNotFound else {
961
964
  return failure("SECURE_STORAGE_ERROR", "Secure storage could not be cleared")
962
965
  }
@@ -977,24 +980,38 @@ enum CraftNativeActions {
977
980
  query[kSecValueData as String] = Data(value.utf8)
978
981
  query[kSecAttrAccessible as String] = kSecAttrAccessibleWhenUnlockedThisDeviceOnly
979
982
  let removed = SecItemDelete(base as CFDictionary)
983
+ CraftKeychain.forget(account: key)
980
984
  guard removed == errSecSuccess || removed == errSecItemNotFound else {
981
985
  return failure("SECURE_STORAGE_ERROR", "Secure storage could not be replaced (\(removed))")
982
986
  }
983
987
  let status = SecItemAdd(query as CFDictionary, nil)
988
+ CraftKeychain.forget(account: key)
984
989
  return status == errSecSuccess ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be written (\(status))")
985
990
  case "get":
986
- var query = base
987
- query[kSecReturnData as String] = true
988
- query[kSecMatchLimit as String] = kSecMatchLimitOne
989
- var result: AnyObject?
990
- let status = SecItemCopyMatching(query as CFDictionary, &result)
991
- if status == errSecItemNotFound { return .success(NSNull()) }
992
- guard status == errSecSuccess, let data = result as? Data else {
991
+ // A failed read is not remembered: it is answered as a failure,
992
+ // and the next read asks the Keychain again.
993
+ var failed = false
994
+ let value = CraftKeychain.value(account: key, service: service) { () -> String? in
995
+ var query = base
996
+ query[kSecReturnData as String] = true
997
+ query[kSecMatchLimit as String] = kSecMatchLimitOne
998
+ var result: AnyObject?
999
+ let status = SecItemCopyMatching(query as CFDictionary, &result)
1000
+ if status == errSecItemNotFound { return nil }
1001
+ guard status == errSecSuccess, let data = result as? Data else {
1002
+ failed = true
1003
+ return nil
1004
+ }
1005
+ return String(decoding: data, as: UTF8.self)
1006
+ }
1007
+ if failed {
1008
+ CraftKeychain.forget(account: key)
993
1009
  return failure("SECURE_STORAGE_ERROR", "Secure storage could not be read")
994
1010
  }
995
- return .success(String(decoding: data, as: UTF8.self))
1011
+ return .success(value.map { $0 as Any } ?? NSNull())
996
1012
  case "remove":
997
1013
  let status = SecItemDelete(base as CFDictionary)
1014
+ CraftKeychain.forget(account: key)
998
1015
  return status == errSecSuccess || status == errSecItemNotFound ? .success(true) : failure("SECURE_STORAGE_ERROR", "Secure storage could not be removed")
999
1016
  default:
1000
1017
  return failure("UNKNOWN_ACTION", "Unsupported SecureStorage method")
@@ -1500,15 +1517,85 @@ extension CraftNativeActions {
1500
1517
  /// `secureStore` writes it. Nil when there is none.
1501
1518
  static func webSecureValue(forKey key: String) -> String? {
1502
1519
  guard let key = validKey(key) else { return nil }
1503
- let query: [String: Any] = [
1504
- kSecClass as String: kSecClassGenericPassword,
1505
- kSecAttrAccount as String: key,
1506
- kSecReturnData as String: true,
1507
- kSecMatchLimit as String: kSecMatchLimitOne,
1508
- ]
1509
- var result: AnyObject?
1510
- guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
1511
- return String(data: data, encoding: .utf8)
1520
+ return CraftKeychain.value(account: key, service: nil) {
1521
+ let query: [String: Any] = [
1522
+ kSecClass as String: kSecClassGenericPassword,
1523
+ kSecAttrAccount as String: key,
1524
+ kSecReturnData as String: true,
1525
+ kSecMatchLimit as String: kSecMatchLimitOne,
1526
+ ]
1527
+ var result: AnyObject?
1528
+ guard SecItemCopyMatching(query as CFDictionary, &result) == errSecSuccess, let data = result as? Data else { return nil }
1529
+ return String(data: data, encoding: .utf8)
1530
+ }
1531
+ }
1532
+ }
1533
+
1534
+ /// The app's own Keychain items, read once and then remembered.
1535
+ ///
1536
+ /// A Keychain read is `SecItemCopyMatching`, which took 100-900ms a read on a
1537
+ /// simulator and is not free on a phone, and every screen reads the sign-in
1538
+ /// token and the account beside it when it opens. They ran on the main
1539
+ /// thread, one after another, so a sign-in that made each open screen check
1540
+ /// the account again held the interface for seconds.
1541
+ ///
1542
+ /// This process is the only writer of these items, through `secureStorage`,
1543
+ /// `CraftHybridKeychain` and the web bridge's secure storage, and each of
1544
+ /// them forgets what it changed. An item another process could write (a
1545
+ /// shared access group, an extension) is not remembered, and coming back to
1546
+ /// the foreground forgets everything, so a value written while the app was
1547
+ /// away is read fresh.
1548
+ enum CraftKeychain {
1549
+ /// Where native screens' secure storage runs: off the main thread, one
1550
+ /// call at a time, so a write is always seen by the read after it.
1551
+ static let queue = DispatchQueue(label: "craft.keychain", qos: .userInitiated)
1552
+ private static let lock = NSLock()
1553
+ private static var values: [String: String?] = [:]
1554
+ /// Moves on every forget, so a read that raced a write is not kept.
1555
+ private static var generation = 0
1556
+ private static var observing: NSObjectProtocol?
1557
+
1558
+ private static func cacheKey(_ account: String, _ service: String?) -> String {
1559
+ "\(service ?? "*")\u{1F}\(account)"
1560
+ }
1561
+
1562
+ /// `account`'s value under `service` (nil: whichever service holds it),
1563
+ /// from memory when it was read before, else from `read`.
1564
+ static func value(account: String, service: String?, read: () -> String?) -> String? {
1565
+ let key = cacheKey(account, service)
1566
+ lock.lock()
1567
+ if observing == nil { observeForeground() }
1568
+ if let known = values[key] {
1569
+ lock.unlock()
1570
+ return known
1571
+ }
1572
+ let started = generation
1573
+ lock.unlock()
1574
+ let value = read()
1575
+ lock.lock()
1576
+ if generation == started { values[key] = .some(value) }
1577
+ lock.unlock()
1578
+ return value
1579
+ }
1580
+
1581
+ /// Drop what is remembered for `account`, under every service; nil drops all.
1582
+ static func forget(account: String?) {
1583
+ lock.lock()
1584
+ generation += 1
1585
+ if let account {
1586
+ let suffix = "\u{1F}\(account)"
1587
+ values = values.filter { !$0.key.hasSuffix(suffix) }
1588
+ } else {
1589
+ values.removeAll()
1590
+ }
1591
+ lock.unlock()
1592
+ }
1593
+
1594
+ /// Called with `lock` held.
1595
+ private static func observeForeground() {
1596
+ observing = NotificationCenter.default.addObserver(forName: UIApplication.willEnterForegroundNotification, object: nil, queue: nil) { _ in
1597
+ forget(account: nil)
1598
+ }
1512
1599
  }
1513
1600
  }
1514
1601
 
@@ -49,6 +49,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
49
49
 
50
50
  private(set) var representedIdentity: String?
51
51
  private(set) var hostedView: UIView?
52
+ /// Top, bottom, leading and trailing: the row's own margins inset it.
53
+ private var hostedConstraints: [NSLayoutConstraint] = []
52
54
  var onRecycle: ((String) -> Void)?
53
55
  override func prepareForReuse() {
54
56
  super.prepareForReuse()
@@ -74,7 +76,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
74
76
  representedIdentity = identity
75
77
  // The same view, already here. A view that left for another cell
76
78
  // and is asked for again is put back below.
77
- guard hostedView !== view || view.superview !== contentView else { return }
79
+ guard hostedView !== view || view.superview !== contentView else { return applyHostedMargins() }
78
80
  // Only a view still in this cell is taken out of it: one that has
79
81
  // since moved to another cell is that cell's now, and pulling it
80
82
  // from there blanked a row on screen.
@@ -82,12 +84,27 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
82
84
  hostedView = view
83
85
  view.translatesAutoresizingMaskIntoConstraints = false
84
86
  contentView.addSubview(view)
85
- NSLayoutConstraint.activate([
87
+ hostedConstraints = [
86
88
  view.topAnchor.constraint(equalTo: contentView.topAnchor),
87
- view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
89
+ contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
88
90
  view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
89
- view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
90
- ])
91
+ contentView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
92
+ ]
93
+ applyHostedMargins()
94
+ NSLayoutConstraint.activate(hostedConstraints)
95
+ }
96
+
97
+ /// A row's margins (`mx-4` on a grouped row) inset it in its cell, as
98
+ /// a column inset it: pinned edge to edge, HQ's Discover results ran
99
+ /// to the screen's sides. Read again on every pass, since a style
100
+ /// update changes the row in place.
101
+ private func applyHostedMargins() {
102
+ guard let view = hostedView, view.superview === contentView, hostedConstraints.count == 4 else { return }
103
+ let margins = view.craftLayoutMargins
104
+ let constants = [margins.top, margins.bottom, margins.left, margins.right]
105
+ for (constraint, constant) in zip(hostedConstraints, constants) where constraint.constant != constant {
106
+ constraint.constant = constant
107
+ }
91
108
  }
92
109
 
93
110
  func releaseHostedView() {
@@ -106,6 +123,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
106
123
  _ layoutAttributes: UICollectionViewLayoutAttributes
107
124
  ) -> UICollectionViewLayoutAttributes {
108
125
  guard let hostedView = hostedView else { return layoutAttributes }
126
+ applyHostedMargins()
127
+ let margins = hostedView.craftLayoutMargins
109
128
  let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
110
129
  // A cell has no collectionView property; the list is its nearest
111
130
  // collection view ancestor.
@@ -121,24 +140,26 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
121
140
  let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout,
122
141
  flowLayout.scrollDirection == .horizontal {
123
142
  let height = fittingExtent ?? layoutAttributes.size.height
143
+ let inner = max(0, height - margins.top - margins.bottom)
124
144
  let measured = CraftNativeFlexLayout.measure(
125
145
  hostedView,
126
146
  width: nil,
127
- height: height,
147
+ height: inner,
128
148
  maxWidth: CraftNativeFlexLayout.unbounded,
129
- maxHeight: height
149
+ maxHeight: inner
130
150
  )
131
- attributes.size = CGSize(width: max(1, ceil(measured.width)), height: max(1, height))
151
+ attributes.size = CGSize(width: max(1, ceil(measured.width + margins.left + margins.right)), height: max(1, height))
132
152
  } else {
133
153
  let width = fittingExtent ?? layoutAttributes.size.width
154
+ let inner = max(0, width - margins.left - margins.right)
134
155
  let measured = CraftNativeFlexLayout.measure(
135
156
  hostedView,
136
- width: width,
157
+ width: inner,
137
158
  height: nil,
138
- maxWidth: width,
159
+ maxWidth: inner,
139
160
  maxHeight: CraftNativeFlexLayout.unbounded
140
161
  )
141
- attributes.size = CGSize(width: max(1, width), height: max(1, ceil(measured.height)))
162
+ attributes.size = CGSize(width: max(1, width), height: max(1, ceil(measured.height + margins.top + margins.bottom)))
142
163
  }
143
164
  return attributes
144
165
  }
@@ -184,6 +205,14 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
184
205
  private var keyboardShouldPersistTaps = "never"
185
206
  private var scrollTargetKey: String?
186
207
  private var pendingScrollTarget: (id: String, animated: Bool, attempts: Int)?
208
+ /// A target that ends at the list's end, held to the true bottom while
209
+ /// the rows near it measure themselves. Rows self-size from a 44pt
210
+ /// estimate, so an offset worked out before they are measured stops short
211
+ /// of tall rows (multi-paragraph replies, cards). Cleared by a drag.
212
+ private var endSettlePasses: Int?
213
+ private var endSettleGeneration = 0
214
+ private var isAnimatingToEnd = false
215
+ private var isMeasuringEnd = false
187
216
 
188
217
  init() {
189
218
  let layout = CraftNativeFlatListLayout()
@@ -339,6 +368,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
339
368
  guard key != scrollTargetKey else { return }
340
369
  scrollTargetKey = key
341
370
  pendingScrollTarget = (id, animated, 0)
371
+ cancelEndSettle()
342
372
  scrollToPendingTarget()
343
373
  }
344
374
 
@@ -538,6 +568,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
538
568
  pendingThemeRefresh = false
539
569
  scrollTargetKey = nil
540
570
  pendingScrollTarget = nil
571
+ cancelEndSettle()
541
572
  }
542
573
 
543
574
  /// Releases callbacks and UIKit ownership that belong to a permanently
@@ -569,15 +600,28 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
569
600
  }
570
601
 
571
602
  func scrollViewDidScroll(_ scrollView: UIScrollView) {
603
+ // The end is measured by jumping there and back in one pass, before
604
+ // anything draws: the screen hears only where the scroll goes.
605
+ guard !isMeasuringEnd else { return }
572
606
  onScrollEvent?(scrollView)
573
607
  onLayoutChanged?()
574
608
  evaluateEndReached()
575
609
  }
576
610
 
577
611
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
612
+ // The finger has the list now: stop holding it to its end.
613
+ cancelEndSettle()
578
614
  onScrollBeginDrag?(scrollView)
579
615
  }
580
616
 
617
+ func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) {
618
+ guard isAnimatingToEnd else { return }
619
+ isAnimatingToEnd = false
620
+ // Rows passed on the way measured themselves and moved the end.
621
+ settleAtEnd()
622
+ scheduleEndSettle()
623
+ }
624
+
581
625
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
582
626
  onScrollEndDrag?(scrollView)
583
627
  }
@@ -636,12 +680,16 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
636
680
 
637
681
  private func scrollToPendingTarget() {
638
682
  guard let target = pendingScrollTarget else { return }
683
+ // A snapshot that is landing, or queued behind the one landing, can
684
+ // add rows before the target or move it: an index read now is stale
685
+ // (it scrolled to the old end). finishApplyingSnapshot() aims again
686
+ // once the rows are in, and so does a list not laid out yet.
687
+ guard !isApplyingSnapshot, pendingApply == nil, bounds.width > 0, bounds.height > 0 else { return }
639
688
  guard let index = targetIndex(for: target.id), index < numberOfItems(inSection: 0) else {
689
+ // No row has this id once the list is idle: try a few more
690
+ // passes, then let it go.
640
691
  if target.attempts >= 4 {
641
- // A diffable snapshot may still be applying. Keep the target
642
- // for finishApplyingSnapshot(), but discard an invalid target
643
- // once the list is otherwise idle.
644
- if !isApplyingSnapshot { pendingScrollTarget = nil }
692
+ pendingScrollTarget = nil
645
693
  return
646
694
  }
647
695
  pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
@@ -649,8 +697,100 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
649
697
  return
650
698
  }
651
699
  pendingScrollTarget = nil
700
+ let indexPath = IndexPath(item: index, section: 0)
701
+ let identity = diffableDataSource.itemIdentifier(for: indexPath) ?? items[index].identity
702
+ if index == numberOfItems(inSection: 0) - 1 {
703
+ scrollToEnd(animated: target.animated)
704
+ return
705
+ }
652
706
  let position: UICollectionView.ScrollPosition = flowLayout.scrollDirection == .horizontal ? .left : .top
653
- scrollToItem(at: IndexPath(item: index, section: 0), at: position, animated: target.animated)
707
+ scrollToItem(at: indexPath, at: position, animated: target.animated)
708
+ guard !target.animated else { return }
709
+ // A row near the end cannot come to the top: the list stops at its
710
+ // end, which is only true once the rows there are measured.
711
+ layoutIfNeeded()
712
+ if rowReachesPastEnd(identity) { scrollToEnd(animated: false) }
713
+ }
714
+
715
+ /// The offset that shows the end of the content, along the scroll axis.
716
+ private var endOffset: CGPoint {
717
+ if flowLayout.scrollDirection == .horizontal {
718
+ return CGPoint(
719
+ x: max(-adjustedContentInset.left, contentSize.width + adjustedContentInset.right - bounds.width),
720
+ y: contentOffset.y
721
+ )
722
+ }
723
+ return CGPoint(
724
+ x: contentOffset.x,
725
+ y: max(-adjustedContentInset.top, contentSize.height + adjustedContentInset.bottom - bounds.height)
726
+ )
727
+ }
728
+
729
+ /// Whether the row's leading edge lies past where the list can scroll to.
730
+ private func rowReachesPastEnd(_ identity: String) -> Bool {
731
+ guard let indexPath = diffableDataSource.indexPath(for: identity),
732
+ let frame = layoutAttributesForItem(at: indexPath)?.frame else { return false }
733
+ if flowLayout.scrollDirection == .horizontal {
734
+ return frame.minX - adjustedContentInset.left >= endOffset.x - 0.5
735
+ }
736
+ return frame.minY - adjustedContentInset.top >= endOffset.y - 0.5
737
+ }
738
+
739
+ private func scrollToEnd(animated: Bool) {
740
+ endSettlePasses = 2
741
+ endSettleGeneration += 1
742
+ if animated {
743
+ // Measure the rows at the end where they will be, then animate
744
+ // there from where the list was, so the animation lands on the
745
+ // true end rather than on an estimate.
746
+ let start = contentOffset
747
+ isMeasuringEnd = true
748
+ settleAtEnd()
749
+ let end = contentOffset
750
+ setContentOffset(start, animated: false)
751
+ isMeasuringEnd = false
752
+ if abs(end.x - start.x) > 0.5 || abs(end.y - start.y) > 0.5 {
753
+ isAnimatingToEnd = true
754
+ setContentOffset(end, animated: true)
755
+ }
756
+ } else {
757
+ settleAtEnd()
758
+ }
759
+ scheduleEndSettle()
760
+ }
761
+
762
+ /// Moves to the end until it stops moving: each move shows rows that
763
+ /// measure themselves and change the content size.
764
+ private func settleAtEnd() {
765
+ guard endSettlePasses != nil, !isDragging, !isTracking else { return cancelEndSettle() }
766
+ for _ in 0..<6 {
767
+ layoutIfNeeded()
768
+ let end = endOffset
769
+ guard abs(end.x - contentOffset.x) > 0.5 || abs(end.y - contentOffset.y) > 0.5 else { break }
770
+ setContentOffset(end, animated: false)
771
+ }
772
+ }
773
+
774
+ /// Checks the end again on the next runloop passes, while late rows and
775
+ /// the content size settle.
776
+ private func scheduleEndSettle() {
777
+ let generation = endSettleGeneration
778
+ DispatchQueue.main.async { [weak self] in
779
+ guard let self, self.endSettleGeneration == generation, let passes = self.endSettlePasses,
780
+ // An animation on its way there settles, and checks
781
+ // again, when it ends.
782
+ !self.isAnimatingToEnd else { return }
783
+ if passes <= 0 { return self.cancelEndSettle() }
784
+ self.endSettlePasses = passes - 1
785
+ if !self.isApplyingSnapshot, self.pendingApply == nil { self.settleAtEnd() }
786
+ self.scheduleEndSettle()
787
+ }
788
+ }
789
+
790
+ private func cancelEndSettle() {
791
+ endSettlePasses = nil
792
+ endSettleGeneration += 1
793
+ isAnimatingToEnd = false
654
794
  }
655
795
 
656
796
  private func clamped(_ offset: CGPoint) -> CGPoint {
@@ -339,11 +339,13 @@ final class CraftNativeMutationDocument {
339
339
  /// `Svg`). Only View, SafeAreaView and ScrollView used to be here, so a
340
340
  /// drawing revealed after the first paint (an `:if` around an `<svg>`)
341
341
  /// had its shapes refused as INVALID_PARENT, and the whole screen was
342
- /// drawn again; Android's host already took the same list.
342
+ /// drawn again; Android's host already took the same list. A shape holds
343
+ /// its `Animate` (SMIL keyframes: HQ's moving exercise figures).
343
344
  static let containerTypes: Set<String> = [
344
345
  "View", "SafeAreaView", "ScrollView", "KeyboardAvoidingView", "Modal", "Pressable",
345
346
  "TouchableOpacity", "TouchableHighlight", "ImageBackground", "FlatList", "SectionList",
346
347
  "Fragment", "Svg", "G", "Map",
348
+ "Path", "Line", "Rect", "Circle", "Ellipse", "Polyline", "Polygon",
347
349
  ]
348
350
  private static let containerTypeKeys = Set(containerTypes.map { $0.lowercased() })
349
351
 
@@ -1345,6 +1345,64 @@ private final class CraftNativeVideoView: UIView, AVPlayerViewControllerDelegate
1345
1345
  deinit { stop() }
1346
1346
  }
1347
1347
 
1348
+ /// A map pin that answers being activated as it answers a tap.
1349
+ final class CraftMarkerAnnotationView: MKAnnotationView {
1350
+ var onActivate: (() -> Void)?
1351
+
1352
+ override func accessibilityActivate() -> Bool {
1353
+ guard let onActivate else { return super.accessibilityActivate() }
1354
+ onActivate()
1355
+ return true
1356
+ }
1357
+
1358
+ override func prepareForReuse() {
1359
+ super.prepareForReuse()
1360
+ onActivate = nil
1361
+ }
1362
+ }
1363
+
1364
+ /// A tile overlay whose tiles are kept on disk.
1365
+ ///
1366
+ /// MKTileOverlay asked the network for every tile on every pan back over the
1367
+ /// same ground, and drew nothing where a tile had not come yet. These go
1368
+ /// through one session with a cache of its own (Caches/craft-map-tiles), so
1369
+ /// ground seen before draws at once and offline, and a phone on a trail
1370
+ /// does not fetch the same hillside twice.
1371
+ final class CraftCachedTileOverlay: MKTileOverlay {
1372
+ static let session: URLSession = {
1373
+ let configuration = URLSessionConfiguration.default
1374
+ let directory = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask).first?.appendingPathComponent("craft-map-tiles", isDirectory: true)
1375
+ configuration.urlCache = URLCache(memoryCapacity: 48 << 20, diskCapacity: 512 << 20, directory: directory)
1376
+ // Topography and imagery change on the scale of months: a tile held
1377
+ // is used without asking again.
1378
+ configuration.requestCachePolicy = .returnCacheDataElseLoad
1379
+ configuration.httpMaximumConnectionsPerHost = 8
1380
+ return URLSession(configuration: configuration)
1381
+ }()
1382
+
1383
+ override func loadTile(at path: MKTileOverlayPath, result: @escaping (Data?, Error?) -> Void) {
1384
+ let request = URLRequest(url: url(forTilePath: path), cachePolicy: .returnCacheDataElseLoad, timeoutInterval: 20)
1385
+ Self.session.dataTask(with: request) { data, response, error in
1386
+ if let status = (response as? HTTPURLResponse)?.statusCode, !(200..<300).contains(status) {
1387
+ result(nil, error ?? URLError(.badServerResponse))
1388
+ return
1389
+ }
1390
+ result(data, error)
1391
+ }.resume()
1392
+ }
1393
+ }
1394
+
1395
+ /// The basemaps a `Map` names with `basemap`, drawn the best way each
1396
+ /// platform can: Apple's vector map (with real terrain from iOS 16) for
1397
+ /// standard, imagery and hybrid, and topography with contours over a muted
1398
+ /// Apple map for outdoors.
1399
+ enum CraftBasemap: String {
1400
+ case standard, outdoors, satellite, hybrid
1401
+
1402
+ static let outdoorTiles = "https://server.arcgisonline.com/ArcGIS/rest/services/World_Topo_Map/MapServer/tile/{z}/{y}/{x}"
1403
+ static let outdoorCredit = "Topography: Esri, USGS, NOAA, OpenStreetMap contributors"
1404
+ }
1405
+
1348
1406
  private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1349
1407
  private var annotationsByID: [String: MKPointAnnotation] = [:]
1350
1408
  private var overlaysByID: [String: MKPolyline] = [:]
@@ -1371,6 +1429,8 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1371
1429
  private var markerPinColors: [String: UIColor] = [:]
1372
1430
  private var tileOverlay: MKTileOverlay?
1373
1431
  private var tileTemplate: String?
1432
+ private var tileReplacesMap = true
1433
+ private var appliedBasemap: CraftBasemap?
1374
1434
  private var tileAttributionBottom: NSLayoutConstraint?
1375
1435
  private lazy var tileAttribution: UILabel = {
1376
1436
  let label = UILabel()
@@ -1395,6 +1455,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1395
1455
  isRotateEnabled = false
1396
1456
  let tap = UITapGestureRecognizer(target: self, action: #selector(pressedMap(_:)))
1397
1457
  tap.cancelsTouchesInView = false
1458
+ // Alongside MapKit's own recognizers, not instead of them: on its own
1459
+ // this tap won the touch, MapKit never selected the pin under it, and
1460
+ // a pin's onPress (Explore's trail card) never fired on a phone.
1461
+ tap.delegate = CraftMapTapDelegate.shared
1398
1462
  addGestureRecognizer(tap)
1399
1463
  }
1400
1464
 
@@ -1429,8 +1493,13 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1429
1493
  isZoomEnabled = props["zoomEnabled"] as? Bool ?? true
1430
1494
  isRotateEnabled = props["rotateEnabled"] as? Bool ?? false
1431
1495
  isPitchEnabled = props["pitchEnabled"] as? Bool ?? true
1432
- mapType = Self.mapType(props["mapType"] as? String)
1433
- configureTiles(props["tileOverlay"] as? [String: Any])
1496
+ if let basemap = (props["basemap"] as? String).flatMap({ CraftBasemap(rawValue: $0.lowercased()) }) {
1497
+ applyBasemap(basemap)
1498
+ } else {
1499
+ appliedBasemap = nil
1500
+ mapType = Self.mapType(props["mapType"] as? String)
1501
+ configureTiles(props["tileOverlay"] as? [String: Any])
1502
+ }
1434
1503
  showsCompass = props["showsCompass"] as? Bool ?? false
1435
1504
  showsScale = props["showsScale"] as? Bool ?? false
1436
1505
  authoredPitch = Self.numeric(props["cameraPitch"])
@@ -1562,7 +1631,9 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1562
1631
  overlayCoordinatesByID[id] = value.coordinates
1563
1632
  overlayColors[ObjectIdentifier(overlay)] = value.color
1564
1633
  overlayWidths[ObjectIdentifier(overlay)] = value.width
1565
- addOverlay(overlay)
1634
+ // Above labels, as a basemap's topography is: at a lower level
1635
+ // the outdoors layer covered every route drawn on it.
1636
+ addOverlay(overlay, level: .aboveLabels)
1566
1637
  }
1567
1638
  let stalePolygons = polygonsByID.keys.filter { nextPolygons[$0] == nil }
1568
1639
  for id in stalePolygons {
@@ -1595,7 +1666,7 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1595
1666
  polygonFills[ObjectIdentifier(polygon)] = value.fill
1596
1667
  polygonStrokes[ObjectIdentifier(polygon)] = value.stroke
1597
1668
  polygonWidths[ObjectIdentifier(polygon)] = value.width
1598
- addOverlay(polygon)
1669
+ addOverlay(polygon, level: .aboveLabels)
1599
1670
  }
1600
1671
  // Keep authored casing/foreground order when an earlier line changes
1601
1672
  // or retained children move. Exchanging native overlays preserves the
@@ -1684,20 +1755,48 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1684
1755
  tileAttribution.isHidden = true
1685
1756
  }
1686
1757
 
1687
- private func configureTiles(_ options: [String: Any]?) {
1758
+ /// A named basemap. Applied once per change: setting a map configuration
1759
+ /// again redraws the whole map.
1760
+ private func applyBasemap(_ basemap: CraftBasemap) {
1761
+ if appliedBasemap != basemap {
1762
+ appliedBasemap = basemap
1763
+ if #available(iOS 16.0, *) {
1764
+ switch basemap {
1765
+ case .satellite: preferredConfiguration = MKImageryMapConfiguration(elevationStyle: .realistic)
1766
+ case .hybrid: preferredConfiguration = MKHybridMapConfiguration(elevationStyle: .realistic)
1767
+ // Flat under the topography, so the overlay's contours sit
1768
+ // on the ground they describe.
1769
+ case .outdoors: preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .flat, emphasisStyle: .muted)
1770
+ case .standard: preferredConfiguration = MKStandardMapConfiguration(elevationStyle: .realistic)
1771
+ }
1772
+ }
1773
+ else {
1774
+ mapType = basemap == .satellite ? .satellite : basemap == .hybrid ? .hybrid : basemap == .outdoors ? .mutedStandard : .standard
1775
+ }
1776
+ }
1777
+ configureTiles(basemap == .outdoors ? ["urlTemplate": CraftBasemap.outdoorTiles, "attribution": CraftBasemap.outdoorCredit] : nil, replacesMap: false)
1778
+ }
1779
+
1780
+ /// `replacesMap`: an authored `tileOverlay` replaces Apple's map, as it
1781
+ /// always has. A basemap's topography draws over it instead, above its
1782
+ /// labels, so ground whose tiles have not come yet shows the map rather
1783
+ /// than an empty grid.
1784
+ private func configureTiles(_ options: [String: Any]?, replacesMap: Bool = true) {
1688
1785
  let template = options?["urlTemplate"] as? String
1689
1786
  let credit = options?["attribution"] as? String ?? ""
1690
1787
  let valid = template.map { value in value.hasPrefix("https://") && ["{z}", "{x}", "{y}"].allSatisfy { value.contains($0) } } ?? false
1691
1788
  let next = valid && !credit.isEmpty ? template : nil
1692
- if tileTemplate != next {
1789
+ if tileTemplate != next || tileReplacesMap != replacesMap {
1693
1790
  if let overlay = tileOverlay { removeOverlay(overlay) }
1694
1791
  tileOverlay = nil
1695
1792
  tileTemplate = next
1793
+ tileReplacesMap = replacesMap
1696
1794
  if let next {
1697
- let overlay = MKTileOverlay(urlTemplate: next)
1698
- overlay.canReplaceMapContent = true
1795
+ let overlay = CraftCachedTileOverlay(urlTemplate: next)
1796
+ overlay.canReplaceMapContent = replacesMap
1797
+ overlay.maximumZ = 19
1699
1798
  tileOverlay = overlay
1700
- insertOverlay(overlay, at: 0, level: .aboveRoads)
1799
+ insertOverlay(overlay, at: 0, level: replacesMap ? .aboveRoads : .aboveLabels)
1701
1800
  }
1702
1801
  }
1703
1802
  tileAttribution.text = next == nil ? nil : credit
@@ -1756,11 +1855,27 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1756
1855
  view.tintColor = color
1757
1856
  view.image = Self.markerImage(color: color, direction: true)
1758
1857
  view.transform = CGAffineTransform(rotationAngle: CGFloat((bearing - camera.heading) * .pi / 180))
1858
+ // Direction marks thin themselves: MapKit hides the ones that
1859
+ // would overlap at this zoom and brings them back closer in, so
1860
+ // a trail reads as a line with chevrons along it, not a column of
1861
+ // badges over the route.
1862
+ view.displayPriority = .defaultLow
1863
+ view.collisionMode = .circle
1864
+ // Decoration along the route: not a stop for VoiceOver.
1865
+ view.isAccessibilityElement = false
1759
1866
  } else {
1760
1867
  let color = (markerPinColors[id] ?? .systemRed).resolvedColor(with: traitCollection)
1761
1868
  view.tintColor = color
1762
1869
  view.image = Self.markerImage(color: color, direction: false)
1763
1870
  view.transform = .identity
1871
+ view.displayPriority = .required
1872
+ view.collisionMode = .rectangle
1873
+ // Above the user's own location: at a trailhead - where the map
1874
+ // is most often opened - the dot sat on the trail's pin and took
1875
+ // every tap meant for it.
1876
+ if #available(iOS 14.0, *) { view.zPriority = .max }
1877
+ view.isAccessibilityElement = true
1878
+ view.accessibilityTraits = .button
1764
1879
  }
1765
1880
  }
1766
1881
 
@@ -1771,9 +1886,10 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1771
1886
  }()
1772
1887
 
1773
1888
  private static func markerImage(color: UIColor, direction: Bool) -> UIImage {
1774
- let key = "\(direction ? "direction" : "pin"):\(color.description)" as NSString
1889
+ let key = "\(direction ? "chevron" : "pin"):\(color.description)" as NSString
1775
1890
  if let cached = markerImages.object(forKey: key) { return cached }
1776
- let size = direction ? CGSize(width: 20, height: 24) : CGSize(width: 28, height: 28)
1891
+ if direction { return directionImage(color: color, key: key) }
1892
+ let size = CGSize(width: 28, height: 28)
1777
1893
  // Explicit native drawing preserves the light backing independently
1778
1894
  // of MapKit's tint and SF Symbols' multicolor rendering preferences.
1779
1895
  let image = UIGraphicsImageRenderer(size: size).image { _ in
@@ -1787,7 +1903,32 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1787
1903
  let symbol = UIImage(systemName: direction ? "arrow.up" : "mappin",
1788
1904
  withConfiguration: UIImage.SymbolConfiguration(pointSize: 16, weight: .semibold))?
1789
1905
  .withTintColor(color, renderingMode: .alwaysOriginal)
1790
- symbol?.draw(in: direction ? CGRect(x: 5, y: 6, width: 10, height: 12) : CGRect(x: 7, y: 5, width: 14, height: 18))
1906
+ symbol?.draw(in: CGRect(x: 7, y: 5, width: 14, height: 18))
1907
+ }.withRenderingMode(.alwaysOriginal)
1908
+ markerImages.setObject(image, forKey: key)
1909
+ return image
1910
+ }
1911
+
1912
+ /// A route's direction: a 16-point chevron in the route's colour with a
1913
+ /// white edge, so it sits on the line it describes and stays legible
1914
+ /// over topography and imagery alike.
1915
+ private static func directionImage(color: UIColor, key: NSString) -> UIImage {
1916
+ let size = CGSize(width: 16, height: 16)
1917
+ let image = UIGraphicsImageRenderer(size: size).image { context in
1918
+ let chevron = UIBezierPath()
1919
+ chevron.move(to: CGPoint(x: 8, y: 2.5))
1920
+ chevron.addLine(to: CGPoint(x: 13.5, y: 12))
1921
+ chevron.addLine(to: CGPoint(x: 8, y: 9.25))
1922
+ chevron.addLine(to: CGPoint(x: 2.5, y: 12))
1923
+ chevron.close()
1924
+ chevron.lineJoinStyle = .round
1925
+ context.cgContext.setShadow(offset: .zero, blur: 1.5, color: UIColor.black.withAlphaComponent(0.25).cgColor)
1926
+ UIColor.white.setStroke()
1927
+ chevron.lineWidth = 3
1928
+ chevron.stroke()
1929
+ context.cgContext.setShadow(offset: .zero, blur: 0, color: nil)
1930
+ color.setFill()
1931
+ chevron.fill()
1791
1932
  }.withRenderingMode(.alwaysOriginal)
1792
1933
  markerImages.setObject(image, forKey: key)
1793
1934
  return image
@@ -1844,9 +1985,12 @@ private final class CraftNativeMapView: MKMapView, MKMapViewDelegate {
1844
1985
 
1845
1986
  func mapView(_ mapView: MKMapView, viewFor annotation: MKAnnotation) -> MKAnnotationView? {
1846
1987
  guard let id = annotationsByID.first(where: { $0.value === annotation })?.key else { return nil }
1847
- let view = dequeueReusableAnnotationView(withIdentifier: "craft-native-marker")
1848
- ?? MKAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1988
+ let view = (dequeueReusableAnnotationView(withIdentifier: "craft-native-marker") as? CraftMarkerAnnotationView)
1989
+ ?? CraftMarkerAnnotationView(annotation: annotation, reuseIdentifier: "craft-native-marker")
1849
1990
  view.annotation = annotation
1991
+ // Activated (VoiceOver, Switch Control, a UI test) as a tap would:
1992
+ // the plain annotation view did nothing when activated.
1993
+ view.onActivate = { [weak self] in self?.onMarkerTap?(id) }
1850
1994
  configureMarkerView(view, id: id)
1851
1995
  view.accessibilityIdentifier = id
1852
1996
  return view
@@ -2164,6 +2308,13 @@ extension UIView {
2164
2308
  set { objc_setAssociatedObject(self, &craftNativeLayoutStyleKey, CraftNativeLayoutStyleBox(newValue), .OBJC_ASSOCIATION_RETAIN_NONATOMIC) }
2165
2309
  }
2166
2310
 
2311
+ /// The margins this view's style asks for, for a container that places
2312
+ /// it without a flex parent (a FlatList cell).
2313
+ var craftLayoutMargins: UIEdgeInsets {
2314
+ let style = craftLayoutStyle
2315
+ return UIEdgeInsets(top: max(0, style.marginTop), left: max(0, style.marginLeft), bottom: max(0, style.marginBottom), right: max(0, style.marginRight))
2316
+ }
2317
+
2167
2318
  /// The corner radius the style asked for. The layer gets at most half the
2168
2319
  /// shortest side: Core Animation draws nothing at all for a radius larger
2169
2320
  /// than the view (`rounded-full` is 9999).
@@ -3991,6 +4142,16 @@ private final class CraftNativeModalView: UIView, UIGestureRecognizerDelegate {
3991
4142
  }
3992
4143
  }
3993
4144
 
4145
+ /// The delegate of a map's own tap (`Map`'s onPress): it watches beside
4146
+ /// MapKit's recognizers, so a tap on a pin still selects it.
4147
+ final class CraftMapTapDelegate: NSObject, UIGestureRecognizerDelegate {
4148
+ static let shared = CraftMapTapDelegate()
4149
+
4150
+ func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
4151
+ true
4152
+ }
4153
+ }
4154
+
3994
4155
  /// The delegate of a Pressable's press-in/out feedback. That recognizer is
3995
4156
  /// a long press of no duration, so it begins the moment a finger lands; on its
3996
4157
  /// own it would claim the touch, and the tap that is the Pressable's onPress
@@ -4328,9 +4489,13 @@ private final class CraftNativeScrollView: UIScrollView, UIGestureRecognizerDele
4328
4489
  /// 3 quadratic, 4 close, in viewBox units - and scaled to the view. Paint is
4329
4490
  /// inherited down the tree as SVG does; `dark:` classes arrive already
4330
4491
  /// resolved for the appearance, so a redraw is all a theme change needs.
4331
- private final class CraftNativeSvgView: UIView {
4492
+ final class CraftNativeSvgView: UIView {
4332
4493
  var drawing: [String: Any] = [:] {
4333
- didSet { setNeedsDisplay() }
4494
+ didSet {
4495
+ animates = Self.hasAnimation(drawing["children"] as? [Any] ?? [])
4496
+ setNeedsDisplay()
4497
+ updateFrames()
4498
+ }
4334
4499
  }
4335
4500
 
4336
4501
  override init(frame: CGRect) {
@@ -4338,10 +4503,123 @@ private final class CraftNativeSvgView: UIView {
4338
4503
  isOpaque = false
4339
4504
  backgroundColor = .clear
4340
4505
  contentMode = .redraw
4506
+ NotificationCenter.default.addObserver(self, selector: #selector(reduceMotionChanged), name: UIAccessibility.reduceMotionStatusDidChangeNotification, object: nil)
4341
4507
  }
4342
4508
 
4343
4509
  required init?(coder: NSCoder) { fatalError("init(coder:) has not been implemented") }
4344
4510
 
4511
+ deinit { CraftNativeFrameClock.shared.cancel(self) }
4512
+
4513
+ // MARK: Animation
4514
+
4515
+ /// Whether a shape carries keyframes (SMIL `<animate attributeName="d">`,
4516
+ /// stx's `Animate`: an exercise's moving figure).
4517
+ private var animates = false
4518
+ /// When this drawing's repetition began: each one starts from its first
4519
+ /// pose when it comes on screen, as a page's SMIL starts when it loads.
4520
+ private var animationStart: CFTimeInterval?
4521
+
4522
+ /// Moving now: on display, and the person has not asked for less motion,
4523
+ /// when each shape keeps its own outline (the still pose).
4524
+ var isAnimating: Bool { animates && window != nil && !isHidden && !UIAccessibility.isReduceMotionEnabled }
4525
+
4526
+ override func didMoveToWindow() {
4527
+ super.didMoveToWindow()
4528
+ updateFrames()
4529
+ }
4530
+
4531
+ override var isHidden: Bool {
4532
+ didSet { if isHidden != oldValue { updateFrames() } }
4533
+ }
4534
+
4535
+ @objc private func reduceMotionChanged() {
4536
+ updateFrames()
4537
+ setNeedsDisplay()
4538
+ }
4539
+
4540
+ /// Frames from the screens' display link (CraftNativeFrameClock, up to
4541
+ /// 120Hz) while moving; none at all while still or out of sight.
4542
+ private func updateFrames() {
4543
+ if isAnimating {
4544
+ if animationStart == nil { animationStart = CACurrentMediaTime() }
4545
+ CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
4546
+ } else {
4547
+ animationStart = nil
4548
+ CraftNativeFrameClock.shared.cancel(self)
4549
+ }
4550
+ }
4551
+
4552
+ private func nextFrame() {
4553
+ guard isAnimating else { return updateFrames() }
4554
+ // Drawn only while some of it is on screen; a row scrolled away keeps
4555
+ // its time and is right when it comes back.
4556
+ if let window, window.bounds.intersects(convert(bounds, to: window)) { setNeedsDisplay() }
4557
+ CraftNativeFrameClock.shared.request(self) { [weak self] in self?.nextFrame() }
4558
+ }
4559
+
4560
+ private static func hasAnimation(_ children: [Any]) -> Bool {
4561
+ children.contains { child in
4562
+ guard let node = child as? [String: Any] else { return false }
4563
+ if node["type"] as? String == "Animate" { return (node["props"] as? [String: Any])?["frames"] != nil }
4564
+ return hasAnimation(node["children"] as? [Any] ?? [])
4565
+ }
4566
+ }
4567
+
4568
+ /// A shape's outline now: between the keyframes either side of `elapsed`
4569
+ /// seconds into its animation (SMIL's linear `calcMode`), or its own
4570
+ /// outline when it has none, the animation has ended, or motion is reduced.
4571
+ private func outline(of shape: [String: Any]) -> Any? {
4572
+ let geometry = (shape["props"] as? [String: Any])?["geometry"]
4573
+ guard isAnimating, let start = animationStart,
4574
+ let animate = (shape["children"] as? [Any])?.lazy.compactMap({ $0 as? [String: Any] }).first(where: { $0["type"] as? String == "Animate" }),
4575
+ let current = Self.keyframe(animate["props"] as? [String: Any] ?? [:], at: CACurrentMediaTime() - start) else { return geometry }
4576
+ return current
4577
+ }
4578
+
4579
+ /// The outline `elapsed` seconds into an `Animate`'s repetitions; nil
4580
+ /// when it does not animate path data or its repetitions are over.
4581
+ static func keyframe(_ props: [String: Any], at elapsed: CFTimeInterval) -> [Any]? {
4582
+ guard props["attributeName"] as? String == "d",
4583
+ let duration = number(props["duration"]), duration > 0, duration.isFinite,
4584
+ let frames = (props["frames"] as? [Any])?.compactMap({ ($0 as? [Any])?.compactMap(number) }),
4585
+ frames.count >= 2, elapsed >= 0 else { return nil }
4586
+ if let count = number(props["repeatCount"]), elapsed >= Double(count * duration) { return nil }
4587
+ let given = (props["frameTimes"] as? [Any])?.compactMap(number) ?? []
4588
+ let times = given.count == frames.count ? given : frames.indices.map { CGFloat($0) / CGFloat(frames.count - 1) }
4589
+ let phase = CGFloat(elapsed.truncatingRemainder(dividingBy: Double(duration))) / duration
4590
+ var index = 0
4591
+ while index < frames.count - 2, times[index + 1] <= phase { index += 1 }
4592
+ let from = frames[index]
4593
+ let to = frames[index + 1]
4594
+ let span = times[index + 1] - times[index]
4595
+ let share = span > 0 ? min(1, max(0, (phase - times[index]) / span)) : 0
4596
+ return interpolate(from, to, share).map { NSNumber(value: Double($0)) }
4597
+ }
4598
+
4599
+ /// Two outlines with the same commands, a share of the way from one to
4600
+ /// the other; the first as it is when their commands differ.
4601
+ static func interpolate(_ from: [CGFloat], _ to: [CGFloat], _ share: CGFloat) -> [CGFloat] {
4602
+ guard from.count == to.count, share > 0 else { return from }
4603
+ var out = from
4604
+ var i = 0
4605
+ while i < from.count {
4606
+ let command = Int(from[i])
4607
+ guard to[i] == from[i] else { return from }
4608
+ let count: Int
4609
+ switch command {
4610
+ case 0, 1: count = 2
4611
+ case 2: count = 6
4612
+ case 3: count = 4
4613
+ case 4: count = 0
4614
+ default: return from
4615
+ }
4616
+ guard i + count < from.count else { return from }
4617
+ if count > 0 { for k in 1...count { out[i + k] = from[i + k] + (to[i + k] - from[i + k]) * share } }
4618
+ i += count + 1
4619
+ }
4620
+ return out
4621
+ }
4622
+
4345
4623
  var viewBox: CGRect? {
4346
4624
  let props = drawing["props"] as? [String: Any] ?? [:]
4347
4625
  guard let box = props["viewBox"] as? [Any], box.count == 4 else { return nil }
@@ -4461,8 +4739,10 @@ private final class CraftNativeSvgView: UIView {
4461
4739
  draw(children: shape["children"] as? [Any] ?? [], paint: own, in: context)
4462
4740
  case "SvgText":
4463
4741
  drawText(shape, paint: own)
4742
+ case "Animate":
4743
+ continue
4464
4744
  default:
4465
- guard let path = CraftNativeSvgView.path((shape["props"] as? [String: Any])?["geometry"]) else { continue }
4745
+ guard let path = CraftNativeSvgView.path(outline(of: shape)) else { continue }
4466
4746
  context.saveGState()
4467
4747
  path.usesEvenOddFillRule = own.fillRule == "evenodd"
4468
4748
  if let fill = own.resolve(own.fill) {
@@ -4859,9 +5139,24 @@ enum CraftNativeScriptThread {
4859
5139
  static let queue = DispatchQueue(label: "craft.native.script", qos: .userInteractive)
4860
5140
 
4861
5141
  /// `work` where the screens' JavaScript runs: now on the main thread, or
4862
- /// queued in order on the script thread.
4863
- static func run(_ work: @escaping () -> Void) {
4864
- if isBackground { queue.async(execute: work) } else { work() }
5142
+ /// queued in order on the script thread. With frame pacing on
5143
+ /// (`CRAFT_FRAME_PACING=1`), work that waited or ran for 4ms or more is
5144
+ /// logged under `label`, so a slow screen can be told from a queued one.
5145
+ static func run(label: @autoclosure @escaping () -> String = "script", _ work: @escaping () -> Void) {
5146
+ let pacing = CraftFramePacing.shared
5147
+ guard pacing.enabled else {
5148
+ if isBackground { queue.async(execute: work) } else { work() }
5149
+ return
5150
+ }
5151
+ let queued = CACurrentMediaTime()
5152
+ let timed = {
5153
+ let started = CACurrentMediaTime()
5154
+ work()
5155
+ let ran = CACurrentMediaTime() - started
5156
+ let waited = started - queued
5157
+ if waited + ran >= 0.004 { pacing.scriptWork(label(), waited: waited, ran: ran) }
5158
+ }
5159
+ if isBackground { queue.async(execute: timed) } else { timed() }
4865
5160
  }
4866
5161
 
4867
5162
  /// From the script thread, `work` on the main thread, not waited for.
@@ -4954,6 +5249,17 @@ enum CraftNativeBundle {
4954
5249
  /// is not parsed again. Each screen keeps a global object of its own.
4955
5250
  static let virtualMachine = JSVirtualMachine()!
4956
5251
 
5252
+ /// JavaScriptCore's first machine and context, made on the script thread
5253
+ /// at launch instead of on the main thread by the first screen: that one-
5254
+ /// time setup was a quarter of a second of the first tab's mount, all of
5255
+ /// it in front of the first frame. Nothing is evaluated, so the main
5256
+ /// thread never waits on the machine for it.
5257
+ static func prewarm() {
5258
+ CraftNativeScriptThread.queue.async {
5259
+ _ = JSContext(virtualMachine: virtualMachine)
5260
+ }
5261
+ }
5262
+
4957
5263
  private static let sources: NSCache<NSString, NSString> = {
4958
5264
  let cache = NSCache<NSString, NSString>()
4959
5265
  // Room for the one-file bundle, or a dozen route files.
@@ -5241,7 +5547,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5241
5547
  let pacing = CraftFramePacing.shared
5242
5548
  let inputs = scriptInputs()
5243
5549
  let name = routeName ?? "screen"
5244
- CraftNativeScriptThread.run { [self] in
5550
+ CraftNativeScriptThread.run(label: "mount \(name)") { [self] in
5245
5551
  pacing.measure("mount \(name) setup") { setupJavaScript(inputs) }
5246
5552
  pacing.measure("mount \(name) bundle") { loadBundle() }
5247
5553
  }
@@ -5302,8 +5608,9 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5302
5608
  screenVisibilityChanged(false)
5303
5609
  stopCelebrations(in: view)
5304
5610
  // Release only this screen's accessory. A retained or prepared screen
5305
- // can disappear after the destination has already published its own.
5306
- CraftChrome.shared.clearTabAccessory(ownedBy: self)
5611
+ // can disappear after the destination has already published its own;
5612
+ // released, it stays drawn until the destination says what it wants.
5613
+ CraftChrome.shared.releaseTabAccessory(ownedBy: self)
5307
5614
  // Popped (or its stack dismissed) while the bar was away: it comes
5308
5615
  // back for whatever shows next, a web page included.
5309
5616
  if tabBarHidden, isMovingFromParent || isBeingDismissed || navigationController?.isBeingDismissed == true {
@@ -5348,7 +5655,11 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5348
5655
  private func restoreShellChrome() {
5349
5656
  guard !isAppScript else { return }
5350
5657
  CraftChrome.shared.setHiddenByScreen(tabBarHidden)
5351
- CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
5658
+ // A screen not yet asked about the accessory (one shown for the first
5659
+ // time, before its script has run) leaves the bar as it is; one that
5660
+ // has drawn without asking for it takes off what nobody claims.
5661
+ if tabAccessoryDeclared { CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self) }
5662
+ else if hybridRendered { CraftChrome.shared.clearUnclaimedTabAccessory() }
5352
5663
  }
5353
5664
 
5354
5665
  /// `requestAnimationFrame`: the next display frame, for a screen on display
@@ -5365,7 +5676,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5365
5676
  guard frameWanted, !runtimeDisposed else { return }
5366
5677
  frameWanted = false
5367
5678
  let context = jsContext
5368
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: []) }
5679
+ let route = routeName ?? "screen"
5680
+ CraftNativeScriptThread.run(label: "frame \(route)") { context.objectForKeyedSubscript("__craftNativeFireFrame")?.call(withArguments: []) }
5369
5681
  }
5370
5682
 
5371
5683
  /// The screen came on screen or went off it: a screen pushed over it was
@@ -5400,7 +5712,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5400
5712
  routeProps = props
5401
5713
  guard isViewLoaded, !runtimeDisposed else { return }
5402
5714
  let context = jsContext
5403
- CraftNativeScriptThread.run {
5715
+ let route = routeName ?? "screen"
5716
+ CraftNativeScriptThread.run(label: "params \(route)") {
5404
5717
  context.setObject(params as NSDictionary, forKeyedSubscript: "__stxNativeParams" as NSString)
5405
5718
  context.setObject(props as NSDictionary, forKeyedSubscript: "__stxNativeProps" as NSString)
5406
5719
  }
@@ -5414,7 +5727,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
5414
5727
  CraftNativeFrameClock.shared.cancel(self)
5415
5728
  disposeRenderedTree()
5416
5729
  let context = jsContext
5417
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
5730
+ let route = routeName ?? "screen"
5731
+ CraftNativeScriptThread.run(label: "unmount \(route)") { context.objectForKeyedSubscript("__stxNativeUnmount")?.call(withArguments: []) }
5418
5732
  audio.close()
5419
5733
  sounds.close()
5420
5734
  streams.close()
@@ -6215,6 +6529,10 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6215
6529
  /// The app's tab bar away while this screen shows (`tabBarHidden`).
6216
6530
  private var tabBarHidden = false
6217
6531
  private var tabAccessory: [String: Any]?
6532
+ /// Whether the screen has said anything about the accessory yet
6533
+ /// (`tabAccessory`, null included): until it has, the bar keeps showing
6534
+ /// the one it had (CraftChrome.releaseTabAccessory).
6535
+ private var tabAccessoryDeclared = false
6218
6536
  /// Whether a back-swipe may leave this screen at all, the edge's
6219
6537
  /// included (`swipeBack: false`): a screen that steps through its own
6220
6538
  /// pages with a Back of its own (a first-run tour's questions) would
@@ -6263,6 +6581,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6263
6581
  guard !isAppScript else { return }
6264
6582
  if options.keys.contains("tabAccessory") {
6265
6583
  tabAccessory = options["tabAccessory"] as? [String: Any]
6584
+ tabAccessoryDeclared = true
6266
6585
  if navigationController?.topViewController === self, view.window != nil {
6267
6586
  CraftChrome.shared.updateTabAccessory(tabAccessory, owner: self)
6268
6587
  }
@@ -6652,7 +6971,7 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6652
6971
  let previous = info["previous"] as? String
6653
6972
  self.send(type: "SESSION_CHANGED", payload: ["key": key, "token": token ?? NSNull(), "previous": previous ?? NSNull()])
6654
6973
  let context = self.jsContext
6655
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
6974
+ CraftNativeScriptThread.run(label: "session \(self.routeName ?? "screen")") { context.objectForKeyedSubscript("__craftSessionChanged")?.call(withArguments: [token ?? NSNull(), previous ?? NSNull()]) }
6656
6975
  })
6657
6976
  }
6658
6977
  if config.enablePushNotifications {
@@ -6937,8 +7256,15 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
6937
7256
  }
6938
7257
  let module = payload["module"] as? String ?? ""
6939
7258
  let method = payload["method"] as? String ?? ""
7259
+ // With frame pacing on: how long the capability took to answer,
7260
+ // which a screen's script waits on (a request, the keychain).
7261
+ let pacing = CraftFramePacing.shared
7262
+ let asked = pacing.enabled ? CACurrentMediaTime() : 0
7263
+ let route = routeName ?? "screen"
7264
+ let what = module == "Network" ? "\(module).\(method) \(((args.first as? [String: Any])?["url"] as? String).flatMap { URL(string: $0)?.path } ?? "")" : "\(module).\(method)"
6940
7265
  let reply: (Result<Any, CraftNativeActionError>) -> Void = { [weak self] answer in
6941
7266
  guard let self = self, self.pendingCapabilityRequests.remove(requestToken) != nil else { return }
7267
+ if pacing.enabled { pacing.capability("\(what) \(route)", took: CACurrentMediaTime() - asked) }
6942
7268
  self.pendingCapabilityDeadlines.removeValue(forKey: requestToken)?.cancel()
6943
7269
  self.screenRequestCancellers.removeValue(forKey: requestToken)
6944
7270
  switch answer {
@@ -7167,6 +7493,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
7167
7493
  hybridRendered = true
7168
7494
  // The app script's drawing is never on screen: not a first frame.
7169
7495
  if !isAppScript { hybridEvents?.nativeScreenDidRender(self) }
7496
+ // Drawn without a word about the accessory: it wants none.
7497
+ if !isAppScript, !tabAccessoryDeclared, isShownOnScreen { CraftChrome.shared.clearUnclaimedTabAccessory() }
7170
7498
  let waiting = renderWaiters
7171
7499
  renderWaiters.removeAll()
7172
7500
  waiting.forEach { $0() }
@@ -8486,8 +8814,12 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
8486
8814
  /// Views that take their own touches whatever handlers the screen gives
8487
8815
  /// them: native controls, a scroll view, a modal, and a video's controls (with
8488
8816
  /// interaction off, its play, scrubber and full screen never answered).
8817
+ /// A map and a wheel picker too: neither is a UIControl, so a map with no
8818
+ /// long-press handler - every map - was made non-interactive, and could
8819
+ /// not be panned, zoomed, or have a pin tapped; a picker could not be spun.
8489
8820
  private func ownsTouches(_ view: UIView) -> Bool {
8490
8821
  view is UIControl || view is UIScrollView || view is CraftNativeModalView || view is CraftNativeVideoView
8822
+ || view is MKMapView || view is UIPickerView
8491
8823
  }
8492
8824
 
8493
8825
  private func updatePressHandler(_ handler: String?, for view: UIView) {
@@ -9832,7 +10164,8 @@ final class CraftNativeScreenController: UIViewController, UIScrollViewDelegate,
9832
10164
  guard let data = try? JSONSerialization.data(withJSONObject: message),
9833
10165
  let json = String(data: data, encoding: .utf8) else { return }
9834
10166
  let context = jsContext
9835
- CraftNativeScriptThread.run { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
10167
+ let route = routeName ?? "screen"
10168
+ CraftNativeScriptThread.run(label: "\(type) \(route)") { context.objectForKeyedSubscript("__stxNativeCallback")?.call(withArguments: [json]) }
9836
10169
  }
9837
10170
 
9838
10171
  private func number(_ value: Any?) -> CGFloat? {