craft-native 0.0.254 → 0.0.272

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.
Files changed (31) hide show
  1. package/README.md +22 -0
  2. package/dist/android/src/index.d.ts +4 -2
  3. package/dist/android/src/index.js +65 -4
  4. package/dist/android/templates/AndroidManifest.xml.template +1 -0
  5. package/dist/android/templates/CraftHealthServices.kt.template +132 -0
  6. package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
  7. package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
  8. package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
  9. package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
  10. package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
  11. package/dist/android/templates/CraftNativeServices.kt.template +31 -8
  12. package/dist/android/templates/CraftNativeShell.kt.template +5 -0
  13. package/dist/android/templates/MainActivityNative.kt.template +343 -33
  14. package/dist/android/templates/build.gradle.kts.app.template +5 -0
  15. package/dist/api/mobile.d.ts +9 -1
  16. package/dist/browser/lifecycle.d.ts +7 -0
  17. package/dist/browser.js +126 -24
  18. package/dist/cli.js +1 -1
  19. package/dist/components/index.d.ts +19 -10
  20. package/dist/index.cjs +1 -1
  21. package/dist/index.js +1 -1
  22. package/dist/ios/templates/CraftApp.swift +55 -7
  23. package/dist/ios/templates/CraftHealth.swift +38 -0
  24. package/dist/ios/templates/CraftHybrid.swift +50 -7
  25. package/dist/ios/templates/CraftNativeActions.swift +109 -22
  26. package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
  27. package/dist/ios/templates/CraftNativeMutation.swift +3 -1
  28. package/dist/ios/templates/CraftNativeScreen.swift +466 -24
  29. package/dist/macos/templates/CraftNativeMutation.swift +3 -1
  30. package/dist/mobile.js +1 -1
  31. package/package.json +1 -1
@@ -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] = [
@@ -1076,6 +1078,18 @@ final class CraftHybridWebSlot: UIViewController {
1076
1078
  }
1077
1079
  }
1078
1080
 
1081
+ /// A native stack lets the top screen consume Back for its presented modal
1082
+ /// before UIKit removes the screen from the navigation stack.
1083
+ final class CraftNativeNavigationController: UINavigationController {
1084
+ override func popViewController(animated: Bool) -> UIViewController? {
1085
+ if let screen = topViewController as? CraftNativeScreenController,
1086
+ screen.handleBackForModal() {
1087
+ return nil
1088
+ }
1089
+ return super.popViewController(animated: animated)
1090
+ }
1091
+ }
1092
+
1079
1093
  final class CraftHybridController: UIViewController, UINavigationControllerDelegate, UIGestureRecognizerDelegate, CraftHybridScreenEvents {
1080
1094
  private let config: CraftConfig
1081
1095
  private let hybrid = CraftHybrid.shared
@@ -1175,6 +1189,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1175
1189
  tabSwipeRecognizers.append(recognizer)
1176
1190
  }
1177
1191
 
1192
+ private func shouldCompleteTabSwipe(progress: CGFloat, speed: CGFloat) -> Bool {
1193
+ speed > -700 && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1194
+ }
1195
+
1178
1196
  @objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
1179
1197
  // A finger that lands while the last swipe settles takes the screens
1180
1198
  // from where they are, as a paging scroll view does. This swipe used
@@ -1186,7 +1204,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1186
1204
  let translation = recognizer.translation(in: view).x
1187
1205
  let velocity = recognizer.velocity(in: view).x
1188
1206
  switch recognizer.state {
1189
- case .changed:
1207
+ case .began, .changed:
1208
+ // Catching a settle can begin at zero translation. Keep its
1209
+ // committed selection until the new finger actually moves.
1210
+ if recognizer.state == .began, caughtSettle != nil, abs(translation) <= 1 { return }
1190
1211
  if activeTabSwipeTarget == nil {
1191
1212
  let direction = abs(translation) > 1 ? translation : velocity
1192
1213
  guard abs(direction) > 1 else { return }
@@ -1201,9 +1222,14 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1201
1222
  // the caught screens finish where they were going.
1202
1223
  if caughtSettle != nil { settleCaughtScreens(); return }
1203
1224
  guard recognizer.state == .ended else { return }
1204
- let delta = abs(translation) >= 32 ? translation : velocity
1205
- guard abs(delta) >= 32 else { return }
1206
- let next = delta < 0 ? index + 1 : index - 1
1225
+ // UIKit can coalesce a short gesture without a changed
1226
+ // callback. Keep the same completion rule as the animated
1227
+ // path instead of treating any 32-point drag as a full page.
1228
+ let direction = abs(translation) > 1 ? translation : velocity
1229
+ guard abs(direction) > 1 else { return }
1230
+ let sign: CGFloat = direction < 0 ? -1 : 1
1231
+ guard shouldCompleteTabSwipe(progress: translation * sign, speed: velocity * sign) else { return }
1232
+ let next = direction < 0 ? index + 1 : index - 1
1207
1233
  guard tabs.indices.contains(next) else { return }
1208
1234
  CraftChrome.shared.swipe(to: tabs[next])
1209
1235
  return
@@ -1216,8 +1242,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1216
1242
  let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
1217
1243
  let progress = (translation + tabSwipeBase) * direction
1218
1244
  let speed = velocity * direction
1219
- let complete = recognizer.state == .ended && speed > -700
1220
- && (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
1245
+ let complete = recognizer.state == .ended && shouldCompleteTabSwipe(progress: progress, speed: speed)
1221
1246
  finishInteractiveTabSwipe(completed: complete, velocity: velocity) { [weak self] in
1222
1247
  CraftFramePacing.shared.end("tab swipe")
1223
1248
  self?.activeTabSwipeTarget = nil
@@ -1566,7 +1591,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
1566
1591
  // Constructing the navigation controller can load the root's view and
1567
1592
  // mount its script immediately. Set its initial focus before that.
1568
1593
  if hidden { (root as? CraftNativeScreenController)?.screenVisibilityChanged(false) }
1569
- let nav = UINavigationController(rootViewController: root)
1594
+ let nav = CraftNativeNavigationController(rootViewController: root)
1570
1595
  nav.delegate = self
1571
1596
  nav.view.backgroundColor = config.resolvedBackgroundColor
1572
1597
  nav.additionalSafeAreaInsets.bottom = safeAreaForTabBar()
@@ -2011,6 +2036,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
2011
2036
  func navigationController(_ navigationController: UINavigationController, didShow viewController: UIViewController, animated: Bool) {
2012
2037
  lastNavigation = CACurrentMediaTime()
2013
2038
  CraftFramePacing.shared.end("navigation")
2039
+ // A page shows no native accessory; one a screen gave up is taken off.
2040
+ if viewController is CraftHybridWebSlot { CraftChrome.shared.clearUnclaimedTabAccessory() }
2014
2041
  let key = ObjectIdentifier(navigationController)
2015
2042
  let before = shownStacks[key] ?? []
2016
2043
  let now = navigationController.viewControllers
@@ -2278,6 +2305,22 @@ final class CraftFramePacing: NSObject {
2278
2305
  return result
2279
2306
  }
2280
2307
 
2308
+ /// One piece of the screens' JavaScript (CraftNativeScriptThread): how
2309
+ /// long it waited behind other screens' work, and how long it ran. The
2310
+ /// screens share one serial script thread, so a screen in front can sit
2311
+ /// behind hidden tabs reloading; `waited` is where that shows.
2312
+ func scriptWork(_ name: String, waited: CFTimeInterval, ran: CFTimeInterval) {
2313
+ guard enabled else { return }
2314
+ write(String(format: "script %@ waited=%.1fms ran=%.1fms", name, waited * 1000, ran * 1000))
2315
+ }
2316
+
2317
+ /// A capability a screen asked for (a request, the keychain), from the
2318
+ /// ask to the answer. Logged from 4ms, as script work is.
2319
+ func capability(_ name: String, took: CFTimeInterval) {
2320
+ guard enabled, took >= 0.004 else { return }
2321
+ write(String(format: "capability %@ took=%.1fms", name, took * 1000))
2322
+ }
2323
+
2281
2324
  /// Each line to the log and to `Library/Caches/craft-frame-pacing.log`
2282
2325
  /// in the app's container, which a test run reads back whole (a
2283
2326
  /// simulator's unified log can drop an app's lines under load). Written
@@ -42,7 +42,7 @@ enum CraftNativeActions {
42
42
  static func capabilities(config: CraftConfig) -> [String] {
43
43
  // `speech`, `audio` (background sound) and `eventStream` (a fetch read
44
44
  // as it arrives) need nothing the app has to declare.
45
- var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent"]
45
+ var values = ["device", "clipboard", "haptics", "storage", "lifecycle", "fetch", "dialog", "speech", "audio", "eventStream", "linking", "badge", "animation", "viewFocus", "viewMeasure", "viewReparent", "viewNonModalPresentation"]
46
46
  values.append("storageCompareAndSet")
47
47
  values.append("storageSetIfAbsent")
48
48
  values.append("viewScroll")
@@ -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()
@@ -63,32 +65,66 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
63
65
  onRecycle = nil
64
66
  }
65
67
 
68
+ /// Whether the row view this cell is meant to show is in it. UIKit
69
+ /// can prepare a second cell for a row (a prefetch, an update), and
70
+ /// the row's one view then moves there: the first cell keeps its
71
+ /// height and draws nothing.
72
+ var showsHostedView: Bool { hostedView.map { $0.superview === contentView } ?? true }
73
+
66
74
  func host(_ view: UIView, identity: String) {
67
75
  if let shown = representedIdentity, shown != identity { recycle() }
68
76
  representedIdentity = identity
69
- guard hostedView !== view else { return }
70
- hostedView?.removeFromSuperview()
77
+ // The same view, already here. A view that left for another cell
78
+ // and is asked for again is put back below.
79
+ guard hostedView !== view || view.superview !== contentView else { return applyHostedMargins() }
80
+ // Only a view still in this cell is taken out of it: one that has
81
+ // since moved to another cell is that cell's now, and pulling it
82
+ // from there blanked a row on screen.
83
+ if let shown = hostedView, shown !== view, shown.superview === contentView { shown.removeFromSuperview() }
71
84
  hostedView = view
72
85
  view.translatesAutoresizingMaskIntoConstraints = false
73
86
  contentView.addSubview(view)
74
- NSLayoutConstraint.activate([
87
+ hostedConstraints = [
75
88
  view.topAnchor.constraint(equalTo: contentView.topAnchor),
76
- view.bottomAnchor.constraint(equalTo: contentView.bottomAnchor),
89
+ contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
77
90
  view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
78
- view.trailingAnchor.constraint(equalTo: contentView.trailingAnchor),
79
- ])
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
+ }
80
108
  }
81
109
 
82
110
  func releaseHostedView() {
83
111
  recycle()
84
- hostedView?.removeFromSuperview()
112
+ if let shown = hostedView, shown.superview === contentView { shown.removeFromSuperview() }
85
113
  hostedView = nil
86
114
  }
87
115
 
116
+ /// Puts this cell's row view back when another cell took it.
117
+ func reclaimHostedView() {
118
+ guard let identity = representedIdentity, let view = hostedView, view.superview !== contentView else { return }
119
+ host(view, identity: identity)
120
+ }
121
+
88
122
  override func preferredLayoutAttributesFitting(
89
123
  _ layoutAttributes: UICollectionViewLayoutAttributes
90
124
  ) -> UICollectionViewLayoutAttributes {
91
125
  guard let hostedView = hostedView else { return layoutAttributes }
126
+ applyHostedMargins()
127
+ let margins = hostedView.craftLayoutMargins
92
128
  let attributes = layoutAttributes.copy() as! UICollectionViewLayoutAttributes
93
129
  // A cell has no collectionView property; the list is its nearest
94
130
  // collection view ancestor.
@@ -104,24 +140,26 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
104
140
  let flowLayout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout,
105
141
  flowLayout.scrollDirection == .horizontal {
106
142
  let height = fittingExtent ?? layoutAttributes.size.height
143
+ let inner = max(0, height - margins.top - margins.bottom)
107
144
  let measured = CraftNativeFlexLayout.measure(
108
145
  hostedView,
109
146
  width: nil,
110
- height: height,
147
+ height: inner,
111
148
  maxWidth: CraftNativeFlexLayout.unbounded,
112
- maxHeight: height
149
+ maxHeight: inner
113
150
  )
114
- 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))
115
152
  } else {
116
153
  let width = fittingExtent ?? layoutAttributes.size.width
154
+ let inner = max(0, width - margins.left - margins.right)
117
155
  let measured = CraftNativeFlexLayout.measure(
118
156
  hostedView,
119
- width: width,
157
+ width: inner,
120
158
  height: nil,
121
- maxWidth: width,
159
+ maxWidth: inner,
122
160
  maxHeight: CraftNativeFlexLayout.unbounded
123
161
  )
124
- 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)))
125
163
  }
126
164
  return attributes
127
165
  }
@@ -167,6 +205,14 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
167
205
  private var keyboardShouldPersistTaps = "never"
168
206
  private var scrollTargetKey: String?
169
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
170
216
 
171
217
  init() {
172
218
  let layout = CraftNativeFlatListLayout()
@@ -279,10 +325,22 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
279
325
  override func layoutSubviews() {
280
326
  updateItemEstimate()
281
327
  super.layoutSubviews()
328
+ reclaimVisibleRows()
282
329
  craftApplyCornerRadius()
283
330
  scrollToPendingTarget()
284
331
  }
285
332
 
333
+ /// A row on screen whose view went to a cell UIKit prepared for the same
334
+ /// row and then did not show gets it back, rather than staying blank.
335
+ private func reclaimVisibleRows() {
336
+ for case let cell as Cell in visibleCells where !cell.showsHostedView { cell.reclaimHostedView() }
337
+ }
338
+
339
+ /// Every row on screen draws its own view (simulator-hosted XCTest).
340
+ var visibleRowsShowTheirViews: Bool {
341
+ visibleCells.allSatisfy { ($0 as? Cell)?.showsHostedView ?? true }
342
+ }
343
+
286
344
  /// Scroll to a virtualized row identified by its native testID, id, or key.
287
345
  /// The row need not be mounted yet; the data snapshot is enough to find its
288
346
  /// index, and UIKit will mount it as part of the scroll.
@@ -310,6 +368,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
310
368
  guard key != scrollTargetKey else { return }
311
369
  scrollTargetKey = key
312
370
  pendingScrollTarget = (id, animated, 0)
371
+ cancelEndSettle()
313
372
  scrollToPendingTarget()
314
373
  }
315
374
 
@@ -509,6 +568,7 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
509
568
  pendingThemeRefresh = false
510
569
  scrollTargetKey = nil
511
570
  pendingScrollTarget = nil
571
+ cancelEndSettle()
512
572
  }
513
573
 
514
574
  /// Releases callbacks and UIKit ownership that belong to a permanently
@@ -540,15 +600,28 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
540
600
  }
541
601
 
542
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 }
543
606
  onScrollEvent?(scrollView)
544
607
  onLayoutChanged?()
545
608
  evaluateEndReached()
546
609
  }
547
610
 
548
611
  func scrollViewWillBeginDragging(_ scrollView: UIScrollView) {
612
+ // The finger has the list now: stop holding it to its end.
613
+ cancelEndSettle()
549
614
  onScrollBeginDrag?(scrollView)
550
615
  }
551
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
+
552
625
  func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) {
553
626
  onScrollEndDrag?(scrollView)
554
627
  }
@@ -607,12 +680,16 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
607
680
 
608
681
  private func scrollToPendingTarget() {
609
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 }
610
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.
611
691
  if target.attempts >= 4 {
612
- // A diffable snapshot may still be applying. Keep the target
613
- // for finishApplyingSnapshot(), but discard an invalid target
614
- // once the list is otherwise idle.
615
- if !isApplyingSnapshot { pendingScrollTarget = nil }
692
+ pendingScrollTarget = nil
616
693
  return
617
694
  }
618
695
  pendingScrollTarget = (target.id, target.animated, target.attempts + 1)
@@ -620,8 +697,100 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
620
697
  return
621
698
  }
622
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
+ }
623
706
  let position: UICollectionView.ScrollPosition = flowLayout.scrollDirection == .horizontal ? .left : .top
624
- 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
625
794
  }
626
795
 
627
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