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.
- package/README.md +22 -0
- package/dist/android/src/index.d.ts +4 -2
- package/dist/android/src/index.js +65 -4
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftHealthServices.kt.template +132 -0
- package/dist/android/templates/CraftHealthServicesService.kt.template +217 -0
- package/dist/android/templates/CraftHealthServicesStub.kt.template +16 -0
- package/dist/android/templates/CraftNativeHealth.kt.template +115 -0
- package/dist/android/templates/CraftNativeHealthStub.kt.template +1 -0
- package/dist/android/templates/CraftNativeMutation.kt.template +2 -0
- package/dist/android/templates/CraftNativeServices.kt.template +31 -8
- package/dist/android/templates/CraftNativeShell.kt.template +5 -0
- package/dist/android/templates/MainActivityNative.kt.template +343 -33
- package/dist/android/templates/build.gradle.kts.app.template +5 -0
- package/dist/api/mobile.d.ts +9 -1
- package/dist/browser/lifecycle.d.ts +7 -0
- package/dist/browser.js +126 -24
- package/dist/cli.js +1 -1
- package/dist/components/index.d.ts +19 -10
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/templates/CraftApp.swift +55 -7
- package/dist/ios/templates/CraftHealth.swift +38 -0
- package/dist/ios/templates/CraftHybrid.swift +50 -7
- package/dist/ios/templates/CraftNativeActions.swift +109 -22
- package/dist/ios/templates/CraftNativeFlatList.swift +187 -18
- package/dist/ios/templates/CraftNativeMutation.swift +3 -1
- package/dist/ios/templates/CraftNativeScreen.swift +466 -24
- package/dist/macos/templates/CraftNativeMutation.swift +3 -1
- package/dist/mobile.js +1 -1
- 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
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
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
|
|
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 =
|
|
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
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
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(
|
|
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
|
-
|
|
1504
|
-
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
1510
|
-
|
|
1511
|
-
|
|
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
|
-
|
|
70
|
-
|
|
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
|
-
|
|
87
|
+
hostedConstraints = [
|
|
75
88
|
view.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
76
|
-
|
|
89
|
+
contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
77
90
|
view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
78
|
-
|
|
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
|
|
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:
|
|
147
|
+
height: inner,
|
|
111
148
|
maxWidth: CraftNativeFlexLayout.unbounded,
|
|
112
|
-
maxHeight:
|
|
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:
|
|
157
|
+
width: inner,
|
|
120
158
|
height: nil,
|
|
121
|
-
maxWidth:
|
|
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
|
-
|
|
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:
|
|
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
|
|