craft-native 0.0.245 → 0.0.247
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/dist/android/src/index.d.ts +23 -0
- package/dist/android/src/index.js +15 -3
- package/dist/android/templates/AndroidManifest.xml.template +1 -0
- package/dist/android/templates/CraftBridge.kt.template +136 -1
- package/dist/android/templates/CraftHeartRate.kt.template +938 -0
- package/dist/android/templates/CraftHeartRateCore.kt.template +485 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +34 -1
- package/dist/android/templates/CraftNativeServices.kt.template +34 -1
- package/dist/android/templates/CraftNativeShell.kt.template +20 -6
- package/dist/api/mobile.d.ts +2 -2
- package/dist/cli.js +1 -1
- package/dist/index.cjs +1 -1
- package/dist/index.js +1 -1
- package/dist/ios/src/index.d.ts +24 -1
- package/dist/ios/src/index.js +6 -0
- package/dist/ios/templates/CraftApp.swift +4 -0
- package/dist/ios/templates/CraftHybrid.swift +151 -14
- package/dist/ios/templates/CraftNativeFlatList.swift +13 -6
- package/dist/ios/templates/CraftNativeScreen.swift +353 -68
- package/dist/ios/templates/CraftWebAudio.swift +21 -13
- package/dist/mobile-testing.js +19 -1
- package/dist/testing/media-cache/media-cache.kt +4 -2
- package/package.json +1 -1
|
@@ -1258,8 +1258,15 @@ internal object CraftNativeMedia {
|
|
|
1258
1258
|
.addTag(TAG)
|
|
1259
1259
|
.addTag("$TAG:${digest(address)}")
|
|
1260
1260
|
.build()
|
|
1261
|
-
//
|
|
1262
|
-
|
|
1261
|
+
// A transfer running for this address is joined (KEEP), not started again. One
|
|
1262
|
+
// only queued, or waiting out a retry's backoff, is replaced: asked for again,
|
|
1263
|
+
// it runs now and resumes the part the last attempt left.
|
|
1264
|
+
val name = workName(address, wifiOnly)
|
|
1265
|
+
background.execute {
|
|
1266
|
+
val running = runCatching { work.getWorkInfosForUniqueWork(name).get() }.getOrDefault(emptyList())
|
|
1267
|
+
.any { it.state == androidx.work.WorkInfo.State.RUNNING }
|
|
1268
|
+
work.enqueueUniqueWork(name, if (running) androidx.work.ExistingWorkPolicy.KEEP else androidx.work.ExistingWorkPolicy.REPLACE, request)
|
|
1269
|
+
}
|
|
1263
1270
|
val reason = when {
|
|
1264
1271
|
!online(app) -> "offline"
|
|
1265
1272
|
wifiOnly && metered(app) -> "cellular"
|
|
@@ -1298,8 +1305,13 @@ internal object CraftNativeMedia {
|
|
|
1298
1305
|
val target = file(context, address)
|
|
1299
1306
|
val staging = part(context, address)
|
|
1300
1307
|
target.parentFile?.mkdirs()
|
|
1308
|
+
val download = CraftResumableDownload()
|
|
1309
|
+
val asked = headers.keys().asSequence().associateWith { headers.optString(it) }
|
|
1301
1310
|
val fetched = try {
|
|
1302
|
-
|
|
1311
|
+
// A part that no longer lines up was dropped: the whole file, now, not after a backoff.
|
|
1312
|
+
download.fetch(address, asked, staging, stopped).let { first ->
|
|
1313
|
+
if (first is CraftResumableDownload.Fetched.Restart) download.fetch(address, asked, staging, stopped) else first
|
|
1314
|
+
}
|
|
1303
1315
|
} catch (error: Exception) {
|
|
1304
1316
|
val full = generateSequence<Throwable>(error) { it.cause }.take(16).any {
|
|
1305
1317
|
it is android.system.ErrnoException && it.errno == android.system.OsConstants.ENOSPC
|
|
@@ -1360,9 +1372,9 @@ internal object CraftNativeMedia {
|
|
|
1360
1372
|
fun prune(context: Context, keep: List<String>): Int {
|
|
1361
1373
|
val wanted = keep.map { digest(it) }.toSet()
|
|
1362
1374
|
var removed = 0
|
|
1375
|
+
// A keep still waiting owns its address's files and its transfer until it is answered.
|
|
1376
|
+
val active = synchronized(lock) { waiting.keys.map { digest(it) }.toSet() }
|
|
1363
1377
|
synchronized(lock) {
|
|
1364
|
-
// A keep still waiting owns its address's files until it is answered.
|
|
1365
|
-
val active = waiting.keys.map { digest(it) }.toSet()
|
|
1366
1378
|
root(context).listFiles()?.forEach { file ->
|
|
1367
1379
|
val owner = file.name.substringBefore('.')
|
|
1368
1380
|
// Part files from before transfers resumed (`<sha>.<ext>.<id>.part`) are never picked up again.
|
|
@@ -1375,7 +1387,7 @@ internal object CraftNativeMedia {
|
|
|
1375
1387
|
runCatching {
|
|
1376
1388
|
androidx.work.WorkManager.getInstance(app).getWorkInfosByTag(TAG).get()
|
|
1377
1389
|
.filter { !it.state.isFinished }
|
|
1378
|
-
.filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:") in wanted } }
|
|
1390
|
+
.filter { info -> info.tags.none { it.startsWith("$TAG:") && it.removePrefix("$TAG:").let { owner -> owner in wanted || owner in active } } }
|
|
1379
1391
|
.forEach { androidx.work.WorkManager.getInstance(app).cancelWorkById(it.id) }
|
|
1380
1392
|
}
|
|
1381
1393
|
}
|
|
@@ -1435,6 +1447,8 @@ internal class CraftResumableDownload(
|
|
|
1435
1447
|
if (have > 0) connection.setRequestProperty("Range", "bytes=$have-")
|
|
1436
1448
|
try {
|
|
1437
1449
|
val status = connection.responseCode
|
|
1450
|
+
// Part of a file nobody asked a part of: not something to keep.
|
|
1451
|
+
if (status == 206 && have == 0L) return Fetched.Refused(status)
|
|
1438
1452
|
val resumed = status == 206 && have > 0 && rangeStart(connection.getHeaderField("Content-Range")) == have
|
|
1439
1453
|
if (status == 416 || (status == 206 && !resumed)) {
|
|
1440
1454
|
part.delete()
|
package/dist/api/mobile.d.ts
CHANGED
|
@@ -1011,8 +1011,8 @@ export declare const heartRate: {
|
|
|
1011
1011
|
/** Bluetooth, the connection, the strap and its latest reading, without asking anything. */
|
|
1012
1012
|
getState: () => Promise<HeartRateState>;
|
|
1013
1013
|
/**
|
|
1014
|
-
* Ask for Bluetooth:
|
|
1015
|
-
* after that. Scanning and connecting ask too; call this from the person's
|
|
1014
|
+
* Ask for Bluetooth: the system's prompt the first time (iOS's, or
|
|
1015
|
+
* Android's Nearby devices), its remembered answer after that. Scanning and connecting ask too; call this from the person's
|
|
1016
1016
|
* tap to ask first.
|
|
1017
1017
|
*/
|
|
1018
1018
|
requestAuthorization: () => Promise<HeartRateAuthorization>;
|
package/dist/cli.js
CHANGED
|
@@ -3424,7 +3424,7 @@ function pinCraftNativeDependency(packagePath, craftVersion) {
|
|
|
3424
3424
|
`);
|
|
3425
3425
|
}
|
|
3426
3426
|
// package.json
|
|
3427
|
-
var version = "0.0.
|
|
3427
|
+
var version = "0.0.247";
|
|
3428
3428
|
|
|
3429
3429
|
// bin/cli.ts
|
|
3430
3430
|
var spawnedFrom = process6.env[CRAFT_CLI_SPAWN_MARKER];
|
package/dist/index.cjs
CHANGED
package/dist/index.js
CHANGED
package/dist/ios/src/index.d.ts
CHANGED
|
@@ -240,6 +240,15 @@ export interface CraftConfig {
|
|
|
240
240
|
* `nativeScreens` or `renderer: "native"`, and the native bundle.
|
|
241
241
|
*/
|
|
242
242
|
nativeApp?: string;
|
|
243
|
+
/**
|
|
244
|
+
* Where native screens run their JavaScript. `"main"` (the default) runs it
|
|
245
|
+
* on the main thread; `"background"` gives screens a serial thread of their
|
|
246
|
+
* own, so a screen's script, its timers and its renders never hold the
|
|
247
|
+
* main thread while it scrolls or animates. UIKit still runs on main: the
|
|
248
|
+
* shell hands each render to it, and a pushed screen waits for its first
|
|
249
|
+
* render (at most 0.3 s) so it never slides in empty.
|
|
250
|
+
*/
|
|
251
|
+
nativeScriptThread?: 'main' | 'background';
|
|
243
252
|
/**
|
|
244
253
|
* The native tab bar, shown from the first frame, before the page loads.
|
|
245
254
|
* The page's own `tabBar.set` replaces it later. Each `id` is the tab's
|
|
@@ -319,13 +328,27 @@ export declare function usesNativeBundle(config: Pick<CraftConfig, 'renderer' |
|
|
|
319
328
|
*/
|
|
320
329
|
export declare function nativeStartPath(config: Pick<CraftConfig, 'devServerURL'>): string;
|
|
321
330
|
/** What is wrong with a config's hybrid and tab settings, one sentence each. */
|
|
322
|
-
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp'>): string[];
|
|
331
|
+
export declare function hybridConfigProblems(config: Pick<CraftConfig, 'renderer' | 'nativeScreens' | 'tabs' | 'shareStorage' | 'devServerURL' | 'nativeApp' | 'nativeScriptThread'>): string[];
|
|
323
332
|
/**
|
|
324
333
|
* The builder installs a directory of route bundles (stx's `routesDir`) as
|
|
325
334
|
* well as the one-file bundle, and the app runs each screen from its own
|
|
326
335
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
327
336
|
*/
|
|
328
337
|
export declare const nativeRouteBundles = true;
|
|
338
|
+
/**
|
|
339
|
+
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
340
|
+
* `sharedRuntime`) in each screen's context before the route's own file.
|
|
341
|
+
* Stacks asks stx for it only when this is true: a route file built so
|
|
342
|
+
* refuses to run without it.
|
|
343
|
+
*/
|
|
344
|
+
export declare const nativeRouteRuntime = true;
|
|
345
|
+
/**
|
|
346
|
+
* The app gives a screen its messages a language at a time (stx's
|
|
347
|
+
* `messages`, `native-screens/__messages/`): read when the screen first
|
|
348
|
+
* looks inside them (`__stxNativeReadMessages`). Stacks leaves the languages
|
|
349
|
+
* out of the screens' code only when this is true.
|
|
350
|
+
*/
|
|
351
|
+
export declare const nativeMessages = true;
|
|
329
352
|
export interface CraftBackgroundRefresh {
|
|
330
353
|
enabled: boolean;
|
|
331
354
|
/** The BGTaskScheduler identifier. Default `<bundleId>.refresh`. */
|
package/dist/ios/src/index.js
CHANGED
|
@@ -482,9 +482,13 @@ function hybridConfigProblems(config) {
|
|
|
482
482
|
else if (!usesNativeBundle(config))
|
|
483
483
|
problems.push('nativeApp runs in the native bundle; it needs nativeScreens or renderer: "native".');
|
|
484
484
|
}
|
|
485
|
+
if (config.nativeScriptThread !== undefined && config.nativeScriptThread !== "main" && config.nativeScriptThread !== "background")
|
|
486
|
+
problems.push(`nativeScriptThread "${String(config.nativeScriptThread)}" must be "main" or "background".`);
|
|
485
487
|
return problems;
|
|
486
488
|
}
|
|
487
489
|
var nativeRouteBundles = true;
|
|
490
|
+
var nativeRouteRuntime = true;
|
|
491
|
+
var nativeMessages = true;
|
|
488
492
|
function installNativeBundle(source, output) {
|
|
489
493
|
if (!existsSync(source))
|
|
490
494
|
throw new Error(`Native bundle not found: ${source}`);
|
|
@@ -1568,7 +1572,9 @@ export {
|
|
|
1568
1572
|
open,
|
|
1569
1573
|
normalizeNativePath,
|
|
1570
1574
|
nativeStartPath,
|
|
1575
|
+
nativeRouteRuntime,
|
|
1571
1576
|
nativeRouteBundles,
|
|
1577
|
+
nativeMessages,
|
|
1572
1578
|
matchNativePath,
|
|
1573
1579
|
isHybrid,
|
|
1574
1580
|
installRuntime,
|
|
@@ -1626,6 +1626,10 @@ struct CraftConfig: Codable {
|
|
|
1626
1626
|
/// The bundle route of the app script: a screen-less script that runs for
|
|
1627
1627
|
/// the app's whole life and hears the app's events (CraftHybrid.swift).
|
|
1628
1628
|
var nativeApp: String? = nil
|
|
1629
|
+
/// Where native screens run their JavaScript: `"main"` (the default) or
|
|
1630
|
+
/// `"background"`, a thread of their own, so a screen's script and its
|
|
1631
|
+
/// renders never hold the main thread (CraftNativeScriptThread).
|
|
1632
|
+
var nativeScriptThread: String? = nil
|
|
1629
1633
|
/// Crash and error reporting to bughq (CraftCrashReporter.swift). Off
|
|
1630
1634
|
/// unless the config names a project key.
|
|
1631
1635
|
var crashReporting: CrashReporting? = nil
|
|
@@ -354,6 +354,7 @@ final class CraftHybrid: NSObject {
|
|
|
354
354
|
guard !isConfigured else { return }
|
|
355
355
|
isConfigured = true
|
|
356
356
|
self.config = config
|
|
357
|
+
CraftNativeScriptThread.isBackground = config.nativeScriptThread == "background"
|
|
357
358
|
hasWeb = config.renderer != "native"
|
|
358
359
|
siteURL = config.devServerURL.flatMap(URL.init(string:)).flatMap { url in
|
|
359
360
|
["http", "https"].contains((url.scheme ?? "").lowercased()) ? url : nil
|
|
@@ -1104,6 +1105,21 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1104
1105
|
/// this separate from the live frame: deriving it after the drag begins
|
|
1105
1106
|
/// makes the target jump when the translation crosses the midpoint.
|
|
1106
1107
|
private var tabSwipeFrom: CGFloat = 0
|
|
1108
|
+
/// A native screen waiting for its first content before it is pushed.
|
|
1109
|
+
private var pendingPush: String?
|
|
1110
|
+
/// Where the current screen already was when this gesture took over: a
|
|
1111
|
+
/// swipe that caught the last one while it settled starts from there.
|
|
1112
|
+
private var tabSwipeBase: CGFloat = 0
|
|
1113
|
+
/// A screen still partly on screen when this gesture caught a settling
|
|
1114
|
+
/// swipe and went the other way: it keeps its distance from the current
|
|
1115
|
+
/// screen (`tabSwipeTrailingGap`) until it has left.
|
|
1116
|
+
private var tabSwipeTrailing: UINavigationController?
|
|
1117
|
+
private var tabSwipeTrailingGap: CGFloat = 0
|
|
1118
|
+
/// The end of the settle under way, run at once when a swipe catches it.
|
|
1119
|
+
private var settleNow: (() -> Void)?
|
|
1120
|
+
private var settleCompleting = false
|
|
1121
|
+
/// A caught settle's screens, where they were, for the gesture that caught it.
|
|
1122
|
+
private var caughtSettle: (other: String, otherX: CGFloat, currentX: CGFloat)?
|
|
1107
1123
|
|
|
1108
1124
|
init(config: CraftConfig) {
|
|
1109
1125
|
self.config = config
|
|
@@ -1160,6 +1176,10 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1160
1176
|
}
|
|
1161
1177
|
|
|
1162
1178
|
@objc private func tabSwipeRecognized(_ recognizer: UIPanGestureRecognizer) {
|
|
1179
|
+
// A finger that lands while the last swipe settles takes the screens
|
|
1180
|
+
// from where they are, as a paging scroll view does. This swipe used
|
|
1181
|
+
// to be ignored until the settle ended.
|
|
1182
|
+
if recognizer.state == .began, tabSwipeSettling { catchSettlingSwipe() }
|
|
1163
1183
|
guard !tabSwipeSettling else { return }
|
|
1164
1184
|
let tabs = hybrid.tabIds
|
|
1165
1185
|
guard let index = tabs.firstIndex(of: currentTab), tabs.count > 1 else { return }
|
|
@@ -1177,6 +1197,9 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1177
1197
|
updateInteractiveTabSwipe(translation: translation)
|
|
1178
1198
|
case .ended, .cancelled, .failed:
|
|
1179
1199
|
guard let target = activeTabSwipeTarget else {
|
|
1200
|
+
// Caught and let go without a direction (or toward the edge):
|
|
1201
|
+
// the caught screens finish where they were going.
|
|
1202
|
+
if caughtSettle != nil { settleCaughtScreens(); return }
|
|
1180
1203
|
guard recognizer.state == .ended else { return }
|
|
1181
1204
|
let delta = abs(translation) >= 32 ? translation : velocity
|
|
1182
1205
|
guard abs(delta) >= 32 else { return }
|
|
@@ -1191,7 +1214,7 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1191
1214
|
// jumping straight to their destination.
|
|
1192
1215
|
tabSwipeSettling = true
|
|
1193
1216
|
let direction: CGFloat = tabSwipeFrom > 0 ? -1 : 1
|
|
1194
|
-
let progress = translation * direction
|
|
1217
|
+
let progress = (translation + tabSwipeBase) * direction
|
|
1195
1218
|
let speed = velocity * direction
|
|
1196
1219
|
let complete = recognizer.state == .ended && speed > -700
|
|
1197
1220
|
&& (progress >= max(32, view.bounds.width * 0.28) || speed > 700)
|
|
@@ -1252,7 +1275,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1252
1275
|
target.view.isHidden = false
|
|
1253
1276
|
let width = view.bounds.width
|
|
1254
1277
|
tabSwipeFrom = -direction * width
|
|
1255
|
-
|
|
1278
|
+
// Caught from a settle: the current screen is still where the last
|
|
1279
|
+
// swipe left it, and the other screen it showed either is this
|
|
1280
|
+
// target (the finger went back to it) or trails behind.
|
|
1281
|
+
tabSwipeBase = 0
|
|
1282
|
+
tabSwipeTrailing = nil
|
|
1283
|
+
if let caught = caughtSettle {
|
|
1284
|
+
caughtSettle = nil
|
|
1285
|
+
tabSwipeBase = caught.currentX
|
|
1286
|
+
if caught.other != id, let other = stacks[caught.other] {
|
|
1287
|
+
tabSwipeTrailing = other
|
|
1288
|
+
tabSwipeTrailingGap = caught.otherX - caught.currentX
|
|
1289
|
+
}
|
|
1290
|
+
}
|
|
1291
|
+
target.view.frame = view.bounds.offsetBy(dx: tabSwipeFrom + tabSwipeBase, dy: 0)
|
|
1256
1292
|
view.bringSubviewToFront(target.view)
|
|
1257
1293
|
CraftChrome.shared.beginTabSwipe(to: id, from: currentTab)
|
|
1258
1294
|
lastNavigation = CACurrentMediaTime()
|
|
@@ -1265,13 +1301,16 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1265
1301
|
let target = stacks[targetId] else { return }
|
|
1266
1302
|
let width = max(1, view.bounds.width)
|
|
1267
1303
|
// Reversing past the start stays at zero progress, rather than
|
|
1268
|
-
// pulling both screens away and exposing the background
|
|
1304
|
+
// pulling both screens away and exposing the background; past where
|
|
1305
|
+
// a caught swipe left the screens, when it left them further out.
|
|
1306
|
+
let raw = translation + tabSwipeBase
|
|
1269
1307
|
let offset = tabSwipeFrom > 0
|
|
1270
|
-
? min(0, max(-width,
|
|
1271
|
-
: max(0, min(width,
|
|
1308
|
+
? min(max(0, tabSwipeBase), max(-width, raw))
|
|
1309
|
+
: max(min(0, tabSwipeBase), min(width, raw))
|
|
1272
1310
|
target.view.frame.origin.x = tabSwipeFrom + offset
|
|
1273
1311
|
current.view.frame.origin.x = offset
|
|
1274
|
-
|
|
1312
|
+
tabSwipeTrailing?.view.frame.origin.x = offset + tabSwipeTrailingGap
|
|
1313
|
+
CraftChrome.shared.updateTabSwipe(progress: max(0, tabSwipeFrom > 0 ? -offset : offset) / width)
|
|
1275
1314
|
}
|
|
1276
1315
|
|
|
1277
1316
|
private func finishInteractiveTabSwipe(completed: Bool, velocity: CGFloat, completion: @escaping () -> Void) {
|
|
@@ -1286,13 +1325,20 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1286
1325
|
CraftChrome.shared.settleTabSwipe(completed: completed, duration: duration)
|
|
1287
1326
|
let remaining = end - target.view.frame.minX
|
|
1288
1327
|
let initialVelocity = abs(remaining) > 1 ? max(0, min(12, velocity / remaining)) : 0
|
|
1328
|
+
let trailing = tabSwipeTrailing
|
|
1329
|
+
let gap = tabSwipeTrailingGap
|
|
1289
1330
|
let move = {
|
|
1290
1331
|
target.view.frame.origin.x = end
|
|
1291
1332
|
current.view.frame.origin.x = completed ? -direction * width : 0
|
|
1333
|
+
trailing?.view.frame.origin.x = current.view.frame.origin.x + gap
|
|
1292
1334
|
}
|
|
1293
|
-
let
|
|
1294
|
-
|
|
1295
|
-
|
|
1335
|
+
let finish = {
|
|
1336
|
+
if let trailing {
|
|
1337
|
+
trailing.view.isHidden = true
|
|
1338
|
+
trailing.view.frame = self.view.bounds
|
|
1339
|
+
}
|
|
1340
|
+
self.tabSwipeTrailing = nil
|
|
1341
|
+
self.tabSwipeBase = 0
|
|
1296
1342
|
if completed {
|
|
1297
1343
|
current.view.isHidden = true
|
|
1298
1344
|
current.view.frame = self.view.bounds
|
|
@@ -1309,15 +1355,81 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1309
1355
|
}
|
|
1310
1356
|
completion()
|
|
1311
1357
|
}
|
|
1358
|
+
settleNow = finish
|
|
1359
|
+
settleCompleting = completed
|
|
1360
|
+
let settled: (Bool) -> Void = { _ in
|
|
1361
|
+
guard self.tabSwipeGeneration == generation,
|
|
1362
|
+
self.activeTabSwipeTarget == targetId else { return }
|
|
1363
|
+
self.settleNow = nil
|
|
1364
|
+
finish()
|
|
1365
|
+
}
|
|
1312
1366
|
UIView.animate(withDuration: duration, delay: 0, usingSpringWithDamping: 1,
|
|
1313
1367
|
initialSpringVelocity: initialVelocity,
|
|
1314
1368
|
options: [.beginFromCurrentState, .allowUserInteraction], animations: move, completion: settled)
|
|
1315
1369
|
}
|
|
1316
1370
|
|
|
1371
|
+
/// A finger landed while the last swipe settled. Its end happens now,
|
|
1372
|
+
/// commit or cancel, chrome and events included, and the two screens go
|
|
1373
|
+
/// back exactly where they were on screen (no frame is drawn between),
|
|
1374
|
+
/// for this gesture to carry on from.
|
|
1375
|
+
private func catchSettlingSwipe() {
|
|
1376
|
+
guard let finish = settleNow, let targetId = activeTabSwipeTarget,
|
|
1377
|
+
let current = visibleStack, let target = stacks[targetId] else { return }
|
|
1378
|
+
let outgoing = currentTab
|
|
1379
|
+
let currentX = current.view.layer.presentation()?.frame.minX ?? current.view.frame.minX
|
|
1380
|
+
let targetX = target.view.layer.presentation()?.frame.minX ?? target.view.frame.minX
|
|
1381
|
+
tabSwipeGeneration &+= 1
|
|
1382
|
+
for nav in [current, target] + (tabSwipeTrailing.map { [$0] } ?? []) { nav.view.layer.removeAllAnimations() }
|
|
1383
|
+
settleNow = nil
|
|
1384
|
+
let completing = settleCompleting
|
|
1385
|
+
CraftFramePacing.shared.end("tab swipe")
|
|
1386
|
+
finish()
|
|
1387
|
+
let (stay, stayX, other, otherId, otherX) = completing
|
|
1388
|
+
? (target, targetX, current, outgoing, currentX)
|
|
1389
|
+
: (current, currentX, target, targetId, targetX)
|
|
1390
|
+
guard abs(stayX) > 0.5 else { return }
|
|
1391
|
+
stay.view.frame = view.bounds.offsetBy(dx: stayX, dy: 0)
|
|
1392
|
+
other.view.isHidden = false
|
|
1393
|
+
other.view.frame = view.bounds.offsetBy(dx: otherX, dy: 0)
|
|
1394
|
+
caughtSettle = (otherId, otherX, stayX)
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
/// A caught settle that no new swipe took on: its screens finish where
|
|
1398
|
+
/// they were going.
|
|
1399
|
+
private func settleCaughtScreens(animated: Bool = true) {
|
|
1400
|
+
guard let caught = caughtSettle else { return }
|
|
1401
|
+
caughtSettle = nil
|
|
1402
|
+
guard let current = visibleStack else { return }
|
|
1403
|
+
let other = stacks[caught.other]
|
|
1404
|
+
let gap = caught.otherX - caught.currentX
|
|
1405
|
+
let generation = tabSwipeGeneration
|
|
1406
|
+
let done = {
|
|
1407
|
+
guard self.tabSwipeGeneration == generation else { return }
|
|
1408
|
+
current.view.frame = self.view.bounds
|
|
1409
|
+
other?.view.frame = self.view.bounds
|
|
1410
|
+
if other !== current { other?.view.isHidden = true }
|
|
1411
|
+
}
|
|
1412
|
+
guard animated, CraftChrome.motion(.easeOut(duration: 0.18)) != nil else { done(); return }
|
|
1413
|
+
UIView.animate(withDuration: 0.18, delay: 0, usingSpringWithDamping: 1, initialSpringVelocity: 0,
|
|
1414
|
+
options: [.beginFromCurrentState, .allowUserInteraction], animations: {
|
|
1415
|
+
current.view.frame.origin.x = 0
|
|
1416
|
+
other?.view.frame.origin.x = gap
|
|
1417
|
+
}, completion: { _ in done() })
|
|
1418
|
+
}
|
|
1419
|
+
|
|
1317
1420
|
/// A newer navigation action wins immediately, including a tap back on
|
|
1318
1421
|
/// the outgoing tab. Invalidate the old animation before removing it:
|
|
1319
1422
|
/// UIKit may deliver its completion while the replacement is starting.
|
|
1320
1423
|
private func interruptTabSwipe(restoringSelection: Bool) {
|
|
1424
|
+
if caughtSettle != nil { settleCaughtScreens(animated: false) }
|
|
1425
|
+
if let trailing = tabSwipeTrailing {
|
|
1426
|
+
trailing.view.layer.removeAllAnimations()
|
|
1427
|
+
trailing.view.frame = view.bounds
|
|
1428
|
+
trailing.view.isHidden = trailing !== visibleStack
|
|
1429
|
+
tabSwipeTrailing = nil
|
|
1430
|
+
}
|
|
1431
|
+
tabSwipeBase = 0
|
|
1432
|
+
settleNow = nil
|
|
1321
1433
|
guard let targetId = activeTabSwipeTarget else { return }
|
|
1322
1434
|
tabSwipeGeneration &+= 1
|
|
1323
1435
|
activeTabSwipeTarget = nil
|
|
@@ -1568,6 +1680,15 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1568
1680
|
}
|
|
1569
1681
|
(nav.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(false)
|
|
1570
1682
|
(self.visibleStack?.topViewController as? CraftNativeScreenController)?.screenVisibilityChanged(true)
|
|
1683
|
+
// A script on its own thread draws a moment later: its first
|
|
1684
|
+
// content is drawn ahead too, once it is there.
|
|
1685
|
+
if CraftNativeScriptThread.isBackground, let screen = nav.topViewController as? CraftNativeScreenController {
|
|
1686
|
+
screen.whenFirstRendered(within: 1) { [weak nav] in
|
|
1687
|
+
guard let nav, nav.view.isHidden else { return }
|
|
1688
|
+
nav.view.layoutIfNeeded()
|
|
1689
|
+
Self.drawContents(of: nav.view.layer)
|
|
1690
|
+
}
|
|
1691
|
+
}
|
|
1571
1692
|
self.prepareAdjacentNativeTabs()
|
|
1572
1693
|
}
|
|
1573
1694
|
preparation = timer
|
|
@@ -1679,10 +1800,25 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1679
1800
|
return
|
|
1680
1801
|
}
|
|
1681
1802
|
let screen = entry(for: raw, isTabRoot: false, depth: nil)
|
|
1682
|
-
|
|
1683
|
-
nav
|
|
1684
|
-
|
|
1685
|
-
|
|
1803
|
+
let show = { [weak nav] in
|
|
1804
|
+
guard let nav else { return }
|
|
1805
|
+
if replace, !nav.viewControllers.isEmpty {
|
|
1806
|
+
nav.setViewControllers(Array(nav.viewControllers.dropLast()) + [screen], animated: true)
|
|
1807
|
+
} else {
|
|
1808
|
+
nav.pushViewController(screen, animated: true)
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
guard let native = screen as? CraftNativeScreenController, CraftNativeScriptThread.isBackground else { show(); return }
|
|
1812
|
+
// Its script runs on its own thread: it slides in with its content,
|
|
1813
|
+
// and a second tap meanwhile does not push it twice.
|
|
1814
|
+
guard pendingPush != match.path else { return }
|
|
1815
|
+
pendingPush = match.path
|
|
1816
|
+
native.whenFirstRendered(within: 0.3) { [weak self, weak nav] in
|
|
1817
|
+
guard let self, self.pendingPush == match.path else { return }
|
|
1818
|
+
self.pendingPush = nil
|
|
1819
|
+
// Not into a tab the person has left meanwhile.
|
|
1820
|
+
guard let nav, nav === self.visibleStack else { return }
|
|
1821
|
+
show()
|
|
1686
1822
|
}
|
|
1687
1823
|
}
|
|
1688
1824
|
|
|
@@ -1945,7 +2081,8 @@ final class CraftHybridController: UIViewController, UINavigationControllerDeleg
|
|
|
1945
2081
|
|
|
1946
2082
|
func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
|
|
1947
2083
|
if tabSwipeRecognizers.contains(where: { $0 === gestureRecognizer }) {
|
|
1948
|
-
|
|
2084
|
+
// A swipe may begin while the last one settles: it catches it.
|
|
2085
|
+
guard activeTabSwipeTarget == nil || tabSwipeSettling,
|
|
1949
2086
|
hybrid.tabIds.count > 1, let nav = visibleStack, let top = nav.topViewController else { return false }
|
|
1950
2087
|
guard CraftChrome.shared.tabSwipeEnabled else { return false }
|
|
1951
2088
|
// Only between the tabs' own first screens, as the bar shows them:
|
|
@@ -52,11 +52,19 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
52
52
|
var onRecycle: ((String) -> Void)?
|
|
53
53
|
override func prepareForReuse() {
|
|
54
54
|
super.prepareForReuse()
|
|
55
|
-
|
|
55
|
+
recycle()
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/// The row this cell showed is gone; its views stay in the cell, and
|
|
59
|
+
/// the next row it shows reuses them when it has the same shape.
|
|
60
|
+
func recycle() {
|
|
61
|
+
if let identity = representedIdentity { onRecycle?(identity) }
|
|
62
|
+
representedIdentity = nil
|
|
63
|
+
onRecycle = nil
|
|
56
64
|
}
|
|
57
65
|
|
|
58
66
|
func host(_ view: UIView, identity: String) {
|
|
59
|
-
if representedIdentity != identity {
|
|
67
|
+
if let shown = representedIdentity, shown != identity { recycle() }
|
|
60
68
|
representedIdentity = identity
|
|
61
69
|
guard hostedView !== view else { return }
|
|
62
70
|
hostedView?.removeFromSuperview()
|
|
@@ -72,11 +80,9 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
72
80
|
}
|
|
73
81
|
|
|
74
82
|
func releaseHostedView() {
|
|
75
|
-
|
|
83
|
+
recycle()
|
|
76
84
|
hostedView?.removeFromSuperview()
|
|
77
85
|
hostedView = nil
|
|
78
|
-
representedIdentity = nil
|
|
79
|
-
onRecycle = nil
|
|
80
86
|
}
|
|
81
87
|
|
|
82
88
|
override func preferredLayoutAttributesFitting(
|
|
@@ -354,7 +360,8 @@ final class CraftNativeFlatList: UICollectionView, UICollectionViewDelegateFlowL
|
|
|
354
360
|
|
|
355
361
|
@objc private func refreshTriggered() {
|
|
356
362
|
refreshHandler?()
|
|
357
|
-
|
|
363
|
+
// Once the script has answered the pull, on its own thread or not.
|
|
364
|
+
CraftNativeScriptThread.afterScript { [weak self] in
|
|
358
365
|
guard let self, !self.refreshingProp, self.nativeRefreshControl.isRefreshing else { return }
|
|
359
366
|
self.nativeRefreshControl.endRefreshing()
|
|
360
367
|
}
|