craft-native 0.0.245 → 0.0.246
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 +14 -0
- package/dist/android/src/index.js +4 -0
- package/dist/android/templates/CraftNativeBackground.kt.template +34 -1
- 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/package.json +1 -1
|
@@ -11,6 +11,20 @@ export type { AndroidMapProvider } from './native-map.js';
|
|
|
11
11
|
* route's file. Stacks asks for route bundles only when this is true.
|
|
12
12
|
*/
|
|
13
13
|
export declare const nativeRouteBundles = true;
|
|
14
|
+
/**
|
|
15
|
+
* The app evaluates stx's shared runtime (`__runtime.js`, stx's
|
|
16
|
+
* `sharedRuntime`) in each screen's context before the route's own file.
|
|
17
|
+
* Stacks asks stx for it only when this is true: a route file built so
|
|
18
|
+
* refuses to run without it.
|
|
19
|
+
*/
|
|
20
|
+
export declare const nativeRouteRuntime = true;
|
|
21
|
+
/**
|
|
22
|
+
* The app gives a screen its messages a language at a time (stx's
|
|
23
|
+
* `messages`, `native-screens/__messages/`): the areas its route uses,
|
|
24
|
+
* evaluated ahead of it (an isolate cannot read a file when asked). Stacks leaves the languages
|
|
25
|
+
* out of the screens' code only when this is true.
|
|
26
|
+
*/
|
|
27
|
+
export declare const nativeMessages = true;
|
|
14
28
|
/** Providers this published builder can generate as real Android map views. */
|
|
15
29
|
export declare const nativeMapProviders: readonly ['google', 'maplibre'];
|
|
16
30
|
export { runKotlinFixture } from './testing.js';
|
|
@@ -360,6 +360,8 @@ async function createAndroidEmulator(options) {
|
|
|
360
360
|
|
|
361
361
|
// ../android/src/index.ts
|
|
362
362
|
var nativeRouteBundles = true;
|
|
363
|
+
var nativeRouteRuntime = true;
|
|
364
|
+
var nativeMessages = true;
|
|
363
365
|
var nativeMapProviders = ["google", "maplibre"];
|
|
364
366
|
function syncNativeFonts(fonts, output) {
|
|
365
367
|
const destination = join3(output, "app/src/main/assets/NativeFonts");
|
|
@@ -1380,7 +1382,9 @@ export {
|
|
|
1380
1382
|
renderAndroidPermissions,
|
|
1381
1383
|
renderAndroidDeepLinks,
|
|
1382
1384
|
open,
|
|
1385
|
+
nativeRouteRuntime,
|
|
1383
1386
|
nativeRouteBundles,
|
|
1387
|
+
nativeMessages,
|
|
1384
1388
|
nativeMapProviders,
|
|
1385
1389
|
installRuntime,
|
|
1386
1390
|
init,
|
|
@@ -71,13 +71,46 @@ internal object CraftNativeBundle {
|
|
|
71
71
|
private fun read(context: Context, asset: String): String? = sources.get(asset)
|
|
72
72
|
?: runCatching { context.assets.open(asset).bufferedReader().use { it.readText() } }.getOrNull()?.also { sources.put(asset, it) }
|
|
73
73
|
|
|
74
|
+
/**
|
|
75
|
+
* A route's file runs on the runtime stx writes beside it
|
|
76
|
+
* (`native-screens/__runtime.js`), so the two go to the isolate together,
|
|
77
|
+
* runtime first, then the route's messages. A one-file bundle carries its
|
|
78
|
+
* runtime itself.
|
|
79
|
+
*/
|
|
74
80
|
fun source(context: Context, route: String?): String? {
|
|
75
81
|
if (!route.isNullOrBlank() && !route.contains('/') && !route.startsWith('.')) {
|
|
76
|
-
read(context, "native-screens/$route.js")?.let {
|
|
82
|
+
read(context, "native-screens/$route.js")?.let { own ->
|
|
83
|
+
val runtime = read(context, "native-screens/__runtime.js") ?: return own
|
|
84
|
+
val messages = messages(context, route)
|
|
85
|
+
return runtime + "\n;\n" + (if (messages != null) messages + "\n;\n" else "") + own
|
|
86
|
+
}
|
|
77
87
|
}
|
|
78
88
|
return read(context, "native-screen.js")
|
|
79
89
|
}
|
|
80
90
|
|
|
91
|
+
/**
|
|
92
|
+
* The messages of the areas a route uses (stx's `messages`:
|
|
93
|
+
* `native-screens/__messages/routes.json` names them), in every language.
|
|
94
|
+
* On iOS a screen reads one language's area the first time it looks
|
|
95
|
+
* inside it; an isolate cannot call back into the app and wait for an
|
|
96
|
+
* answer, so here they are evaluated ahead of the route, as
|
|
97
|
+
* `__stxNativeMessages`, which the runtime reads first.
|
|
98
|
+
*/
|
|
99
|
+
private fun messages(context: Context, route: String): String? {
|
|
100
|
+
val listing = read(context, "native-screens/__messages/routes.json") ?: return null
|
|
101
|
+
val areas = runCatching { org.json.JSONObject(listing).optJSONArray(route) }.getOrNull() ?: return null
|
|
102
|
+
if (areas.length() == 0) return null
|
|
103
|
+
val languages = context.assets.list("native-screens/__messages")?.filter { !it.contains('.') }?.sorted() ?: return null
|
|
104
|
+
val tables = languages.joinToString(",") { language ->
|
|
105
|
+
val entries = (0 until areas.length()).mapNotNull { index ->
|
|
106
|
+
val area = areas.optString(index)
|
|
107
|
+
read(context, "native-screens/__messages/$language/$area.json")?.let { "${org.json.JSONObject.quote(area)}:$it" }
|
|
108
|
+
}
|
|
109
|
+
"${org.json.JSONObject.quote(language)}:{${entries.joinToString(",")}}"
|
|
110
|
+
}
|
|
111
|
+
return "globalThis.__stxNativeMessages={$tables};"
|
|
112
|
+
}
|
|
113
|
+
|
|
81
114
|
fun isAvailable(context: Context): Boolean =
|
|
82
115
|
runCatching { context.assets.open("native-screen.js").close(); true }.getOrDefault(false)
|
|
83
116
|
|| (context.assets.list("native-screens")?.isNotEmpty() == true)
|
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.246";
|
|
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
|
}
|