@sigx/lynx-navigation 0.4.4 → 0.4.5
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/components/Layer.d.ts +18 -11
- package/dist/components/Layer.d.ts.map +1 -1
- package/dist/components/Layer.js +33 -21
- package/dist/components/Layer.js.map +1 -1
- package/dist/components/NavigationRoot.d.ts +9 -1
- package/dist/components/NavigationRoot.d.ts.map +1 -1
- package/dist/components/NavigationRoot.js +12 -1
- package/dist/components/NavigationRoot.js.map +1 -1
- package/dist/components/Stack.d.ts +19 -7
- package/dist/components/Stack.d.ts.map +1 -1
- package/dist/components/Stack.js +28 -15
- package/dist/components/Stack.js.map +1 -1
- package/dist/hooks/use-hardware-back.d.ts +30 -12
- package/dist/hooks/use-hardware-back.d.ts.map +1 -1
- package/dist/hooks/use-hardware-back.js +99 -54
- package/dist/hooks/use-hardware-back.js.map +1 -1
- package/dist/internal/layer-plan.d.ts +56 -30
- package/dist/internal/layer-plan.d.ts.map +1 -1
- package/dist/internal/layer-plan.js +84 -48
- package/dist/internal/layer-plan.js.map +1 -1
- package/dist/navigator/core.d.ts.map +1 -1
- package/dist/navigator/core.js +41 -21
- package/dist/navigator/core.js.map +1 -1
- package/package.json +10 -10
- package/src/components/Layer.tsx +41 -22
- package/src/components/NavigationRoot.tsx +21 -1
- package/src/components/Stack.tsx +58 -14
- package/src/hooks/use-hardware-back.ts +102 -54
- package/src/internal/layer-plan.ts +128 -75
- package/src/navigator/core.ts +42 -21
|
@@ -13,32 +13,38 @@
|
|
|
13
13
|
*
|
|
14
14
|
* Rules:
|
|
15
15
|
*
|
|
16
|
-
* - **
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
16
|
+
* - **Visible region.** Each branch first computes its *visible*
|
|
17
|
+
* region — the layers the user can actually see:
|
|
18
|
+
* - **Idle (no transition).** The topmost non-overlay entry as
|
|
19
|
+
* the base, plus every overlay entry above it.
|
|
20
|
+
* - **Card transition.** The underneath entry (parallax) + the
|
|
21
|
+
* top entry (slide), both animated.
|
|
22
|
+
* - **Overlay transition.** The static base..underneath run plus
|
|
23
|
+
* the animated overlay top.
|
|
21
24
|
*
|
|
22
|
-
* - **
|
|
23
|
-
*
|
|
24
|
-
* (
|
|
25
|
-
*
|
|
26
|
-
*
|
|
25
|
+
* - **Retention (card-stack screen retention).** Every entry *below*
|
|
26
|
+
* the visible region's base is kept mounted as a `hidden: true`
|
|
27
|
+
* static layer (`display: none`), instead of unmounting. So a card
|
|
28
|
+
* push leaves the covered card mounted, and a pop reveals the
|
|
29
|
+
* already-mounted underneath — no rebuild, no lost scroll/UI state
|
|
30
|
+
* (matching native stacks). Overlays already preserved their
|
|
31
|
+
* underneath; this extends the same retain-below-base behaviour to
|
|
32
|
+
* cards, and to entries deeper than the immediate underneath during
|
|
33
|
+
* a deep-stack transition.
|
|
27
34
|
*
|
|
28
|
-
* - **
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
32
|
-
* (pop).
|
|
35
|
+
* - **Bounds.** `maxRetained` (the `<Stack maxRetainedScreens>` prop)
|
|
36
|
+
* caps how many covered cards stay mounted; `MAX_LAYERS` is the hard
|
|
37
|
+
* renderer-slot cap. Both trim the *deepest* (front) layers, so the
|
|
38
|
+
* visible region at the end of the list is never truncated.
|
|
33
39
|
*
|
|
34
|
-
* The Layer.key for the Stack render is
|
|
35
|
-
*
|
|
36
|
-
*
|
|
37
|
-
*
|
|
38
|
-
*
|
|
39
|
-
*
|
|
40
|
-
*
|
|
41
|
-
*
|
|
40
|
+
* The Layer.key for the Stack render is `layer-${entry.key}` — stable
|
|
41
|
+
* across animation phases. `<Layer>` rebinds its transform reactively
|
|
42
|
+
* (via the reactive form of `useAnimatedStyle`) as `animation` flips
|
|
43
|
+
* between a spec and `null`, so the layer never remounts just to change
|
|
44
|
+
* its animation state and screen subtrees survive the transition. The
|
|
45
|
+
* reactive binding dedupes its own register/unregister by signature
|
|
46
|
+
* internally (see `useAnimatedStyle`), so no per-layer variant key is
|
|
47
|
+
* computed here.
|
|
42
48
|
*/
|
|
43
49
|
import type { SharedValue } from '@sigx/lynx';
|
|
44
50
|
import { SCREEN_HEIGHT, SCREEN_WIDTH } from './screen-width.js';
|
|
@@ -51,6 +57,17 @@ import type {
|
|
|
51
57
|
|
|
52
58
|
const PARALLAX_FACTOR = 0.3;
|
|
53
59
|
|
|
60
|
+
/**
|
|
61
|
+
* Hard cap on how many layers `<Stack>` renders at once. The Stack body
|
|
62
|
+
* emits exactly this many position-stable slots (unrolled), so
|
|
63
|
+
* `computeLayers` must never return more — excess deepest (hidden,
|
|
64
|
+
* retained) layers are trimmed off the front. The slots are mechanical
|
|
65
|
+
* (just verbose), so this can be raised if an app legitimately stacks
|
|
66
|
+
* more; 24 is high enough that normal card apps never hit the trim
|
|
67
|
+
* boundary, while still bounding retained-screen memory.
|
|
68
|
+
*/
|
|
69
|
+
export const MAX_LAYERS = 24;
|
|
70
|
+
|
|
54
71
|
export type LayerAnimation = {
|
|
55
72
|
axis: 'translateX' | 'translateY';
|
|
56
73
|
inputRange: readonly [number, number];
|
|
@@ -63,25 +80,20 @@ export interface Layer {
|
|
|
63
80
|
readonly entry: StackEntry;
|
|
64
81
|
/** When non-null, the layer's host view binds a `useAnimatedStyle` mapper. */
|
|
65
82
|
readonly animation: LayerAnimation | null;
|
|
83
|
+
/**
|
|
84
|
+
* Retained-but-covered layer: mounted (so its screen subtree, signals,
|
|
85
|
+
* and scroll offset survive), rendered with `display: none` so it costs
|
|
86
|
+
* no paint/layout while a higher opaque card covers it. Always paired
|
|
87
|
+
* with `animation: null` — a hidden layer never animates. Falsy/omitted
|
|
88
|
+
* = visible.
|
|
89
|
+
*/
|
|
90
|
+
readonly hidden?: boolean;
|
|
66
91
|
}
|
|
67
92
|
|
|
68
93
|
export function isOverlayPresentation(p: Presentation): boolean {
|
|
69
94
|
return p === 'modal' || p === 'fullScreen' || p === 'transparent-modal';
|
|
70
95
|
}
|
|
71
96
|
|
|
72
|
-
/**
|
|
73
|
-
* Suffix used in a layer's render key. Stable for the layer's
|
|
74
|
-
* lifetime (same entry, same animation kind) and changes when the
|
|
75
|
-
* animation transitions on/off so the Layer remounts and rebinds.
|
|
76
|
-
*/
|
|
77
|
-
export function animationVariant(animation: LayerAnimation | null): string {
|
|
78
|
-
if (!animation) return 'static';
|
|
79
|
-
// Output range alone identifies the transition shape — different
|
|
80
|
-
// animations (card-top vs card-underneath vs overlay-top, push vs
|
|
81
|
-
// pop) all land on different range tuples.
|
|
82
|
-
return `${animation.axis}:${animation.outputRange[0]}->${animation.outputRange[1]}`;
|
|
83
|
-
}
|
|
84
|
-
|
|
85
97
|
/**
|
|
86
98
|
* Card-presentation transition transforms. `role='top'` is the entry
|
|
87
99
|
* being pushed/popped; `role='underneath'` is the one parallaxing.
|
|
@@ -120,68 +132,109 @@ function overlayTopAnimation(
|
|
|
120
132
|
return { axis: 'translateY', inputRange: [0, 1], outputRange: [0, SCREEN_HEIGHT], progress };
|
|
121
133
|
}
|
|
122
134
|
|
|
135
|
+
/** Walk back from `from` past overlay entries to the topmost non-overlay. */
|
|
136
|
+
function nonOverlayBaseIdx(stack: readonly StackEntry[], from: number): number {
|
|
137
|
+
let baseIdx = from;
|
|
138
|
+
while (baseIdx > 0 && isOverlayPresentation(stack[baseIdx].presentation)) {
|
|
139
|
+
baseIdx -= 1;
|
|
140
|
+
}
|
|
141
|
+
return baseIdx;
|
|
142
|
+
}
|
|
143
|
+
|
|
123
144
|
/**
|
|
124
145
|
* Compute the visible-layer list for one render of `<Stack>`. Pure —
|
|
125
146
|
* unit-testable independently of the renderer.
|
|
147
|
+
*
|
|
148
|
+
* Each branch produces `{ visBaseIdx, visible }`: `visible` is the
|
|
149
|
+
* animated/painted region, and `visBaseIdx` is the stack index of that
|
|
150
|
+
* region's lowest layer. Everything in `stack` below `visBaseIdx` is
|
|
151
|
+
* then retained as hidden static layers (see the module docstring).
|
|
152
|
+
*
|
|
153
|
+
* `maxRetained` caps the retained (hidden) layers; `undefined` means
|
|
154
|
+
* "retain all, bounded only by `MAX_LAYERS`".
|
|
126
155
|
*/
|
|
127
156
|
export function computeLayers(
|
|
128
157
|
stack: readonly StackEntry[],
|
|
129
158
|
transition: TransitionState | null,
|
|
130
159
|
progress: SharedValue<number> | null,
|
|
160
|
+
maxRetained?: number,
|
|
131
161
|
): Layer[] {
|
|
162
|
+
let visBaseIdx: number;
|
|
163
|
+
let visible: Layer[];
|
|
164
|
+
|
|
132
165
|
if (!transition) {
|
|
133
166
|
// Idle: topmost non-overlay base + any overlays above it.
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
167
|
+
visBaseIdx = nonOverlayBaseIdx(stack, stack.length - 1);
|
|
168
|
+
visible = stack
|
|
169
|
+
.slice(visBaseIdx)
|
|
170
|
+
.map((entry) => ({ entry, animation: null, hidden: false }));
|
|
171
|
+
} else if (!isOverlayPresentation(transition.topEntry.presentation)) {
|
|
172
|
+
// Card transition: the two participating entries, both animated
|
|
173
|
+
// (parallax underneath + slide top). `progress` may be null when
|
|
174
|
+
// animations are disabled — produce static layers in that case.
|
|
175
|
+
const underneathIdx = stack.findIndex(
|
|
176
|
+
(e) => e.key === transition.underneathEntry.key,
|
|
177
|
+
);
|
|
178
|
+
// Underneath is the visible base. If it isn't on the stack (e.g.
|
|
179
|
+
// mid-pop where the mutation already ran), retain nothing rather
|
|
180
|
+
// than slicing with a negative index.
|
|
181
|
+
visBaseIdx = underneathIdx >= 0 ? underneathIdx : 0;
|
|
182
|
+
visible = [
|
|
149
183
|
{
|
|
150
184
|
entry: transition.underneathEntry,
|
|
151
185
|
animation: progress ? cardAnimation('underneath', transition.kind, progress) : null,
|
|
186
|
+
hidden: false,
|
|
152
187
|
},
|
|
153
188
|
{
|
|
154
189
|
entry: transition.topEntry,
|
|
155
190
|
animation: progress ? cardAnimation('top', transition.kind, progress) : null,
|
|
191
|
+
hidden: false,
|
|
192
|
+
},
|
|
193
|
+
];
|
|
194
|
+
} else {
|
|
195
|
+
// Overlay transition: the full layer stack up through the
|
|
196
|
+
// underneath entry stays static (no transform) plus the animated
|
|
197
|
+
// top.
|
|
198
|
+
const underneathIdx = stack.findIndex(
|
|
199
|
+
(e) => e.key === transition.underneathEntry.key,
|
|
200
|
+
);
|
|
201
|
+
// If the underneath isn't in the stack (e.g. mid-pop where the
|
|
202
|
+
// stack mutation already removed an entry), fall back to the
|
|
203
|
+
// current top of the stack.
|
|
204
|
+
const lastStaticIdx = underneathIdx >= 0 ? underneathIdx : stack.length - 1;
|
|
205
|
+
visBaseIdx = nonOverlayBaseIdx(stack, lastStaticIdx);
|
|
206
|
+
const staticLayers: Layer[] = stack
|
|
207
|
+
.slice(visBaseIdx, lastStaticIdx + 1)
|
|
208
|
+
.map((entry) => ({ entry, animation: null, hidden: false }));
|
|
209
|
+
visible = [
|
|
210
|
+
...staticLayers,
|
|
211
|
+
{
|
|
212
|
+
entry: transition.topEntry,
|
|
213
|
+
animation: progress ? overlayTopAnimation(transition.kind, progress) : null,
|
|
214
|
+
hidden: false,
|
|
156
215
|
},
|
|
157
216
|
];
|
|
158
217
|
}
|
|
159
218
|
|
|
160
|
-
//
|
|
161
|
-
//
|
|
162
|
-
//
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
// stack mutation already removed an entry), fall back to the
|
|
168
|
-
// current top of the stack.
|
|
169
|
-
const lastStaticIdx = underneathIdx >= 0 ? underneathIdx : stack.length - 1;
|
|
219
|
+
// Retention: every entry below the visible base stays mounted as a
|
|
220
|
+
// hidden static layer. Ordered deepest-first so each surviving entry
|
|
221
|
+
// keeps a stable slot index across pushes/pops (the Stack renderer's
|
|
222
|
+
// fixed slots remount keyed children whose slot index shifts).
|
|
223
|
+
const retained: Layer[] = stack
|
|
224
|
+
.slice(0, visBaseIdx)
|
|
225
|
+
.map((entry) => ({ entry, animation: null, hidden: true }));
|
|
170
226
|
|
|
171
|
-
let
|
|
172
|
-
while (baseIdx > 0 && isOverlayPresentation(stack[baseIdx].presentation)) {
|
|
173
|
-
baseIdx -= 1;
|
|
174
|
-
}
|
|
227
|
+
let result = [...retained, ...visible];
|
|
175
228
|
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
229
|
+
// Apply the user's retention window, then the hard renderer cap.
|
|
230
|
+
// Both trim the deepest (front) layers, so the visible region at the
|
|
231
|
+
// tail is never truncated.
|
|
232
|
+
if (maxRetained != null && retained.length > maxRetained) {
|
|
233
|
+
result = result.slice(retained.length - maxRetained);
|
|
234
|
+
}
|
|
235
|
+
if (result.length > MAX_LAYERS) {
|
|
236
|
+
result = result.slice(result.length - MAX_LAYERS);
|
|
237
|
+
}
|
|
179
238
|
|
|
180
|
-
return
|
|
181
|
-
...staticLayers,
|
|
182
|
-
{
|
|
183
|
-
entry: transition.topEntry,
|
|
184
|
-
animation: progress ? overlayTopAnimation(transition.kind, progress) : null,
|
|
185
|
-
},
|
|
186
|
-
];
|
|
239
|
+
return result;
|
|
187
240
|
}
|
package/src/navigator/core.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import {
|
|
2
|
+
batch,
|
|
2
3
|
runOnMainThread,
|
|
3
4
|
signal,
|
|
4
5
|
untrack,
|
|
@@ -294,21 +295,30 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
294
295
|
const cur = getStack();
|
|
295
296
|
const prevTop = cur[cur.length - 1];
|
|
296
297
|
|
|
297
|
-
// Append eagerly — UX-wise the user just initiated a forward nav, so
|
|
298
|
-
// the new entry should be queryable immediately (`nav.current` =
|
|
299
|
-
// newEntry). The slide animation overlays the visual transition.
|
|
300
|
-
setStack([...cur, newEntry]);
|
|
301
|
-
|
|
302
298
|
const animated = options?.animated !== false && !!progress;
|
|
303
|
-
if (!animated) return;
|
|
304
299
|
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
300
|
+
// Commit the stack append and the transition in a single batch so the
|
|
301
|
+
// Stack renders once with both screens already present. Without the
|
|
302
|
+
// batch, `@sigx/reactivity` flushes the stack write eagerly, producing
|
|
303
|
+
// an intermediate render where only the new top is on the stack and
|
|
304
|
+
// no transition is in flight — `computeLayers` would drop the
|
|
305
|
+
// underneath and the Stack would remount it on the next render.
|
|
306
|
+
// Append eagerly so the new entry is queryable immediately
|
|
307
|
+
// (`nav.current` = newEntry); the slide animation overlays the visual.
|
|
308
|
+
batch(() => {
|
|
309
|
+
setStack([...cur, newEntry]);
|
|
310
|
+
if (animated) {
|
|
311
|
+
setTransition({
|
|
312
|
+
kind: 'push',
|
|
313
|
+
topEntry: newEntry,
|
|
314
|
+
underneathEntry: prevTop,
|
|
315
|
+
progress,
|
|
316
|
+
});
|
|
317
|
+
}
|
|
310
318
|
});
|
|
311
319
|
|
|
320
|
+
if (!animated) return;
|
|
321
|
+
|
|
312
322
|
animateProgress(1, TRANSITION_DURATION_SEC).then(
|
|
313
323
|
() => setTransition(null),
|
|
314
324
|
() => setTransition(null), // best-effort cleanup on animation rejection
|
|
@@ -358,15 +368,22 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
358
368
|
|
|
359
369
|
animateProgress(1, TRANSITION_DURATION_SEC).then(
|
|
360
370
|
() => {
|
|
361
|
-
|
|
362
|
-
|
|
371
|
+
// Batch so the commit (drop the popped entry) and clearing the
|
|
372
|
+
// transition land in one render — no intermediate frame where
|
|
373
|
+
// the stack has mutated but the transition is still in flight.
|
|
374
|
+
batch(() => {
|
|
375
|
+
setStack(cur.slice(0, cur.length - 1));
|
|
376
|
+
setTransition(null);
|
|
377
|
+
});
|
|
363
378
|
},
|
|
364
379
|
() => {
|
|
365
380
|
// On animation failure, snap to the destination state anyway —
|
|
366
381
|
// leaving the popped entry rendered would be more confusing
|
|
367
382
|
// than skipping the animation.
|
|
368
|
-
|
|
369
|
-
|
|
383
|
+
batch(() => {
|
|
384
|
+
setStack(cur.slice(0, cur.length - 1));
|
|
385
|
+
setTransition(null);
|
|
386
|
+
});
|
|
370
387
|
},
|
|
371
388
|
);
|
|
372
389
|
}
|
|
@@ -394,8 +411,10 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
394
411
|
if (state.stack.length === 0) {
|
|
395
412
|
throw new Error('[lynx-navigation] reset() called with empty stack.');
|
|
396
413
|
}
|
|
397
|
-
|
|
398
|
-
|
|
414
|
+
batch(() => {
|
|
415
|
+
setStack([...state.stack]);
|
|
416
|
+
setTransition(null);
|
|
417
|
+
});
|
|
399
418
|
}
|
|
400
419
|
|
|
401
420
|
function dismiss(): void {
|
|
@@ -433,10 +452,12 @@ export function createNavigatorState(opts: CreateNavigatorOptions): NavigatorSta
|
|
|
433
452
|
|
|
434
453
|
function commitBackGesture(): void {
|
|
435
454
|
const cur = getStack();
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
455
|
+
batch(() => {
|
|
456
|
+
if (cur.length >= 2) {
|
|
457
|
+
setStack(cur.slice(0, cur.length - 1));
|
|
458
|
+
}
|
|
459
|
+
setTransition(null);
|
|
460
|
+
});
|
|
440
461
|
}
|
|
441
462
|
|
|
442
463
|
function cancelBackGesture(): void {
|