@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 +1 -1
- package/templates/CraftApp.swift +19 -3
- package/templates/CraftHybrid.swift +25 -2
- package/templates/CraftNativeActions.swift +108 -21
- package/templates/CraftNativeFlatList.swift +156 -16
- package/templates/CraftNativeMutation.swift +3 -1
- package/templates/CraftNativeScreen.swift +362 -29
package/package.json
CHANGED
package/templates/CraftApp.swift
CHANGED
|
@@ -797,10 +797,21 @@ final class CraftChrome: ObservableObject {
|
|
|
797
797
|
tabAccessory = next
|
|
798
798
|
}
|
|
799
799
|
|
|
800
|
-
/// A
|
|
801
|
-
|
|
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
|
-
|
|
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
|
|
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: "%@
|
|
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
|
-
|
|
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()
|
|
@@ -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
|
-
|
|
87
|
+
hostedConstraints = [
|
|
86
88
|
view.topAnchor.constraint(equalTo: contentView.topAnchor),
|
|
87
|
-
|
|
89
|
+
contentView.bottomAnchor.constraint(equalTo: view.bottomAnchor),
|
|
88
90
|
view.leadingAnchor.constraint(equalTo: contentView.leadingAnchor),
|
|
89
|
-
|
|
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:
|
|
147
|
+
height: inner,
|
|
128
148
|
maxWidth: CraftNativeFlexLayout.unbounded,
|
|
129
|
-
maxHeight:
|
|
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:
|
|
157
|
+
width: inner,
|
|
137
158
|
height: nil,
|
|
138
|
-
maxWidth:
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
1433
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
1698
|
-
overlay.canReplaceMapContent =
|
|
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 ? "
|
|
1889
|
+
let key = "\(direction ? "chevron" : "pin"):\(color.description)" as NSString
|
|
1775
1890
|
if let cached = markerImages.object(forKey: key) { return cached }
|
|
1776
|
-
|
|
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:
|
|
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
|
-
??
|
|
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
|
-
|
|
4492
|
+
final class CraftNativeSvgView: UIView {
|
|
4332
4493
|
var drawing: [String: Any] = [:] {
|
|
4333
|
-
didSet {
|
|
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((
|
|
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
|
-
|
|
4864
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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? {
|