@rootnative/inertia 0.0.3 → 0.0.4
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/CHANGELOG.md +41 -5
- package/README.md +5 -3
- package/dist/{chunk-SGUHE5CX.mjs → chunk-4ASOELLA.mjs} +1 -1
- package/dist/{chunk-CSODMRJ7.mjs → chunk-55W7BNO2.mjs} +475 -83
- package/dist/chunk-5SRD2WCO.js +8 -0
- package/dist/{chunk-PTRF47DA.js → chunk-7A6WTXNK.js} +10 -3
- package/dist/{chunk-3XTVY34H.js → chunk-CENTULW6.js} +2 -2
- package/dist/{chunk-4PEHWDAZ.js → chunk-D5KAHWMC.js} +7 -7
- package/dist/{chunk-O22NXXCZ.mjs → chunk-J5Q46OPW.mjs} +1 -1
- package/dist/{chunk-RGNX6UZN.mjs → chunk-K3CLP74S.mjs} +2 -2
- package/dist/{chunk-BP3Y2SHQ.js → chunk-MYE5NMXW.js} +495 -101
- package/dist/{chunk-OQV66TBQ.mjs → chunk-NAOXQJ7U.mjs} +1 -1
- package/dist/{chunk-R63GIUNU.mjs → chunk-QKC3APP4.mjs} +1 -1
- package/dist/{chunk-46P57VMY.mjs → chunk-RF7J5FRM.mjs} +1 -1
- package/dist/{chunk-CY7Y64C3.mjs → chunk-TU2XFPAK.mjs} +10 -4
- package/dist/chunk-TVFNJ25Z.js +8 -0
- package/dist/chunk-VLDIFL3F.js +8 -0
- package/dist/{chunk-7AOERN53.js → chunk-WVRCKOPO.js} +3 -3
- package/dist/{chunk-W5MC3P4N.js → chunk-XMEVPEMH.js} +2 -2
- package/dist/{chunk-BQQTHG2V.mjs → chunk-YKGY22SB.mjs} +1 -1
- package/dist/gestureLayer/index.d.mts +2 -2
- package/dist/gestureLayer/index.d.ts +2 -2
- package/dist/gestureLayer/index.js +10 -10
- package/dist/gestureLayer/index.mjs +3 -3
- package/dist/index.d.mts +4 -4
- package/dist/index.d.ts +4 -4
- package/dist/index.js +48 -169
- package/dist/index.mjs +17 -137
- package/dist/motion/Image.d.mts +1 -1
- package/dist/motion/Image.d.ts +1 -1
- package/dist/motion/Image.js +5 -5
- package/dist/motion/Image.mjs +4 -4
- package/dist/motion/Pressable.d.mts +1 -1
- package/dist/motion/Pressable.d.ts +1 -1
- package/dist/motion/Pressable.js +5 -5
- package/dist/motion/Pressable.mjs +4 -4
- package/dist/motion/ScrollView.d.mts +1 -1
- package/dist/motion/ScrollView.d.ts +1 -1
- package/dist/motion/ScrollView.js +5 -5
- package/dist/motion/ScrollView.mjs +4 -4
- package/dist/motion/Text.d.mts +1 -1
- package/dist/motion/Text.d.ts +1 -1
- package/dist/motion/Text.js +5 -5
- package/dist/motion/Text.mjs +4 -4
- package/dist/motion/View.d.mts +1 -1
- package/dist/motion/View.d.ts +1 -1
- package/dist/motion/View.js +5 -5
- package/dist/motion/View.mjs +4 -4
- package/dist/touch/index.d.mts +1 -1
- package/dist/touch/index.d.ts +1 -1
- package/dist/touch/index.js +3 -3
- package/dist/touch/index.mjs +1 -1
- package/dist/{types-G1dBU2IQ.d.mts → types-37pN82yh.d.mts} +49 -14
- package/dist/{types-G1dBU2IQ.d.ts → types-37pN82yh.d.ts} +49 -14
- package/dist/{useGesture-D1e4ixJo.d.ts → useGesture-Cl4uzZIr.d.ts} +1 -1
- package/dist/{useGesture-RFY6kTL3.d.mts → useGesture-_IKkS8Dv.d.mts} +1 -1
- package/llms.txt +3 -1
- package/package.json +1 -1
- package/src/index.ts +2 -1
- package/src/internal/boxShadow.ts +177 -6
- package/src/internal/nonWorkletWarning.ts +4 -6
- package/src/internal/warnOnce.ts +28 -0
- package/src/layout/index.ts +10 -0
- package/src/layout/measureWindow.ts +135 -0
- package/src/layout/sharedRegistry.ts +107 -18
- package/src/layout/useSharedLayout.ts +256 -91
- package/src/motion/createMotionComponent.tsx +378 -39
- package/src/motion/installCheck.ts +1 -1
- package/src/transitions/index.ts +1 -1
- package/src/types.ts +52 -12
- package/src/values/useColorTransition.ts +1 -1
- package/dist/chunk-FNVFV4EY.js +0 -8
- package/dist/chunk-FWQOXA43.js +0 -8
- package/dist/chunk-KBP4LR75.js +0 -8
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Module-level registry of last-known on-screen rects
|
|
3
|
-
* elements, indexed by `layoutId`.
|
|
2
|
+
* Module-level registry of last-known on-screen rects — and the style values
|
|
3
|
+
* the rect can't express — for shared-layout elements, indexed by `layoutId`.
|
|
4
|
+
* Backs `<Motion.* layoutId="..." />` —
|
|
4
5
|
* Reanimated 4 dropped the `sharedTransitionTag` API the previous design
|
|
5
6
|
* relied on, so the cross-screen shared-element transition lives in
|
|
6
7
|
* userland now.
|
|
@@ -14,25 +15,86 @@
|
|
|
14
15
|
* it becomes the FLIP source rect; the entry is removed so a third
|
|
15
16
|
* mount with the same id doesn't re-animate from a stale snapshot.
|
|
16
17
|
*
|
|
17
|
-
* Rects are stored in **
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
*
|
|
22
|
-
*
|
|
18
|
+
* Rects are stored in **window coordinates** when the host node can be
|
|
19
|
+
* measured synchronously, and in the parent-relative coordinates `onLayout`
|
|
20
|
+
* reports when it can't (see `measureWindow.ts`). Which one an entry holds is
|
|
21
|
+
* recorded on the rect itself, because the two are not comparable: a FLIP
|
|
22
|
+
* computed from a parent-relative source against a window-space target is off
|
|
23
|
+
* by the parent's window offset. Consumers check the space and skip the
|
|
24
|
+
* animation rather than play a wrong one.
|
|
25
|
+
*
|
|
26
|
+
* Window coordinates are what make a shared element work when the source and
|
|
27
|
+
* target sit under containers at different screen offsets — the case the
|
|
28
|
+
* original parent-relative implementation got wrong. They cost something in
|
|
29
|
+
* return: `onLayout` does not fire when an *ancestor* scrolls, so a stored
|
|
30
|
+
* window rect goes stale as the user scrolls, where a parent-relative rect
|
|
31
|
+
* would not have. That is why an entry also carries `remeasure` while its
|
|
32
|
+
* owner is mounted; see `SharedLayoutSource`.
|
|
23
33
|
*/
|
|
24
34
|
|
|
25
|
-
/**
|
|
35
|
+
/** Which coordinate system a rect's `x` / `y` are expressed in. */
|
|
36
|
+
export type CoordinateSpace = 'window' | 'parent'
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* The source element's values for the style keys a shared-element transition
|
|
40
|
+
* carries across (colors, `opacity`, `borderRadius`) — everything the rect
|
|
41
|
+
* FLIP can't express. Keys are `AnimatableKey`s, but typed loosely here so the
|
|
42
|
+
* registry stays independent of the factory's key vocabulary.
|
|
43
|
+
*
|
|
44
|
+
* Only keys the source actually had a value for appear. Absent means "the
|
|
45
|
+
* source had nothing to say about this" — the target then keeps its own value
|
|
46
|
+
* rather than crossfading from an invented one.
|
|
47
|
+
*/
|
|
48
|
+
export type SharedStyleSnapshot = Record<string, number | string>
|
|
49
|
+
|
|
50
|
+
/** Measured rect of an element, tagged with its coordinate space. */
|
|
26
51
|
export interface SharedRect {
|
|
27
52
|
x: number
|
|
28
53
|
y: number
|
|
29
54
|
width: number
|
|
30
55
|
height: number
|
|
56
|
+
space: CoordinateSpace
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* What `consumeLayout` hands back: the last recorded rect, plus — only while
|
|
61
|
+
* the source element is *still mounted* — a way to ask it for a fresh one.
|
|
62
|
+
*
|
|
63
|
+
* The re-measure hook is what keeps window coordinates honest in the dominant
|
|
64
|
+
* case. In a stack navigator the source screen stays mounted underneath the
|
|
65
|
+
* pushed one, so at the moment the target first lays out the source is still
|
|
66
|
+
* there and can be measured *now* rather than trusted from whenever its last
|
|
67
|
+
* layout happened. Without it, scrolling a list before tapping a row would
|
|
68
|
+
* offset every shared-element transition by the scroll distance.
|
|
69
|
+
*
|
|
70
|
+
* Returns `undefined` when the source can no longer be measured, in which case
|
|
71
|
+
* the caller keeps the stored rect. Absent entirely once `releaseLayout` has
|
|
72
|
+
* run, since by then there is no node left to measure.
|
|
73
|
+
*/
|
|
74
|
+
export interface SharedLayoutSource {
|
|
75
|
+
rect: SharedRect
|
|
76
|
+
remeasure?: () => SharedRect | undefined
|
|
77
|
+
/**
|
|
78
|
+
* The source's carried style values, as of its last layout. Present only
|
|
79
|
+
* once `releaseLayout` has run — while the owner is still mounted,
|
|
80
|
+
* `readStyles` supersedes this for the same reason `remeasure` supersedes
|
|
81
|
+
* the stored rect.
|
|
82
|
+
*/
|
|
83
|
+
styles?: SharedStyleSnapshot
|
|
84
|
+
/**
|
|
85
|
+
* Read the source's carried style values *now*. Offered only while the
|
|
86
|
+
* source is mounted. Its values move without a layout pass (an animation
|
|
87
|
+
* settling, a theme swap), so a stored snapshot is a floor, not the truth.
|
|
88
|
+
*/
|
|
89
|
+
readStyles?: () => SharedStyleSnapshot | undefined
|
|
31
90
|
}
|
|
32
91
|
|
|
33
92
|
interface Entry {
|
|
34
93
|
rect: SharedRect
|
|
35
94
|
expiresAt: number
|
|
95
|
+
remeasure?: () => SharedRect | undefined
|
|
96
|
+
styles?: SharedStyleSnapshot
|
|
97
|
+
readStyles?: () => SharedStyleSnapshot | undefined
|
|
36
98
|
}
|
|
37
99
|
|
|
38
100
|
const REGISTRY = new Map<string, Entry>()
|
|
@@ -87,37 +149,64 @@ function sweepExpired(at: number): void {
|
|
|
87
149
|
* Motion primitive with `layoutId` set so the registry always holds a
|
|
88
150
|
* current measurement if that primitive becomes the source of a future
|
|
89
151
|
* transition. Resets the TTL each call.
|
|
152
|
+
*
|
|
153
|
+
* `remeasure` and `readStyles` are the still-mounted owner's offer to be read
|
|
154
|
+
* again on demand — pass them while the element is live so a consumer can
|
|
155
|
+
* prefer fresh values over whatever this call recorded.
|
|
90
156
|
*/
|
|
91
|
-
export function registerLayout(
|
|
157
|
+
export function registerLayout(
|
|
158
|
+
id: string,
|
|
159
|
+
rect: SharedRect,
|
|
160
|
+
remeasure?: () => SharedRect | undefined,
|
|
161
|
+
readStyles?: () => SharedStyleSnapshot | undefined,
|
|
162
|
+
): void {
|
|
92
163
|
const at = now()
|
|
93
164
|
sweepExpired(at)
|
|
94
|
-
REGISTRY.set(id, {
|
|
165
|
+
REGISTRY.set(id, {
|
|
166
|
+
rect,
|
|
167
|
+
expiresAt: at + SHARED_LAYOUT_TTL_MS,
|
|
168
|
+
remeasure,
|
|
169
|
+
readStyles,
|
|
170
|
+
})
|
|
95
171
|
}
|
|
96
172
|
|
|
97
173
|
/**
|
|
98
174
|
* Record the rect for `id` on unmount so the next mount can consume it as
|
|
99
|
-
* the FLIP source.
|
|
100
|
-
*
|
|
175
|
+
* the FLIP source.
|
|
176
|
+
*
|
|
177
|
+
* Deliberately drops any `remeasure` / `readStyles` hook the mounted entry
|
|
178
|
+
* carried: the node is on its way out, and measuring a detached view yields
|
|
179
|
+
* zeros. From here the values recorded by this call are all a consumer gets —
|
|
180
|
+
* which is why `styles` is passed by value here and by callback above.
|
|
101
181
|
*/
|
|
102
|
-
export function releaseLayout(
|
|
182
|
+
export function releaseLayout(
|
|
183
|
+
id: string,
|
|
184
|
+
rect: SharedRect,
|
|
185
|
+
styles?: SharedStyleSnapshot,
|
|
186
|
+
): void {
|
|
103
187
|
const at = now()
|
|
104
188
|
sweepExpired(at)
|
|
105
|
-
REGISTRY.set(id, { rect, expiresAt: at + SHARED_LAYOUT_TTL_MS })
|
|
189
|
+
REGISTRY.set(id, { rect, expiresAt: at + SHARED_LAYOUT_TTL_MS, styles })
|
|
106
190
|
}
|
|
107
191
|
|
|
108
192
|
/**
|
|
109
|
-
* Take the recorded
|
|
193
|
+
* Take the recorded source for `id` if it exists and hasn't expired. The
|
|
110
194
|
* entry is removed in either case — at most one incoming mount consumes
|
|
111
195
|
* a given release, and an expired entry is dropped so it can't poison a
|
|
112
196
|
* later transition. Returns `undefined` when no fresh source is available,
|
|
113
197
|
* in which case the caller should mount without a layout animation.
|
|
114
198
|
*/
|
|
115
|
-
export function consumeLayout(id: string):
|
|
199
|
+
export function consumeLayout(id: string): SharedLayoutSource | undefined {
|
|
116
200
|
const entry = REGISTRY.get(id)
|
|
117
201
|
if (!entry) return undefined
|
|
118
202
|
REGISTRY.delete(id)
|
|
119
203
|
if (entry.expiresAt < now()) return undefined
|
|
120
|
-
return
|
|
204
|
+
return {
|
|
205
|
+
rect: entry.rect,
|
|
206
|
+
remeasure: entry.remeasure,
|
|
207
|
+
styles: entry.styles,
|
|
208
|
+
readStyles: entry.readStyles,
|
|
209
|
+
}
|
|
121
210
|
}
|
|
122
211
|
|
|
123
212
|
/** Drop all entries. Tests use this to isolate between cases. */
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
} from 'react'
|
|
9
9
|
import { type LayoutChangeEvent } from 'react-native'
|
|
10
10
|
import {
|
|
11
|
+
cancelAnimation,
|
|
11
12
|
type SharedValue,
|
|
12
13
|
useSharedValue,
|
|
13
14
|
withSequence,
|
|
@@ -16,11 +17,14 @@ import {
|
|
|
16
17
|
} from 'react-native-reanimated'
|
|
17
18
|
import { DEFAULT_SPRING, springToReanimated } from '../transitions/spring'
|
|
18
19
|
import { type SpringTransition, type TransitionConfig } from '../types'
|
|
20
|
+
import { measureWindowRect } from './measureWindow'
|
|
19
21
|
import {
|
|
20
22
|
consumeLayout,
|
|
21
23
|
registerLayout,
|
|
22
24
|
releaseLayout,
|
|
25
|
+
type SharedLayoutSource,
|
|
23
26
|
type SharedRect,
|
|
27
|
+
type SharedStyleSnapshot,
|
|
24
28
|
} from './sharedRegistry'
|
|
25
29
|
|
|
26
30
|
/**
|
|
@@ -43,9 +47,29 @@ export interface SharedLayoutValues {
|
|
|
43
47
|
sy: SharedValue<number>
|
|
44
48
|
}
|
|
45
49
|
|
|
50
|
+
/**
|
|
51
|
+
* Shared values carrying the *style* half of a shared-element transition — the
|
|
52
|
+
* part the rect FLIP can't express.
|
|
53
|
+
*
|
|
54
|
+
* `snapshot` parks the source element's values for the carried keys; `progress`
|
|
55
|
+
* runs 1 → 0 on the same transition as the FLIP. The host's worklet reads both
|
|
56
|
+
* and pulls each carried key from the snapshot toward its own resolved value,
|
|
57
|
+
* so at the first frame the arriving element wears the source's colors and by
|
|
58
|
+
* the last it wears its own.
|
|
59
|
+
*
|
|
60
|
+
* At rest `snapshot` is `null` and `progress` is 0, so the worklet's whole
|
|
61
|
+
* contribution is one comparison.
|
|
62
|
+
*/
|
|
63
|
+
export interface SharedLayoutStyleValues {
|
|
64
|
+
snapshot: SharedValue<SharedStyleSnapshot | null>
|
|
65
|
+
progress: SharedValue<number>
|
|
66
|
+
}
|
|
67
|
+
|
|
46
68
|
/** What the host component needs to wire into its rendered tree. */
|
|
47
69
|
export interface SharedLayoutBindings {
|
|
48
70
|
flip: SharedLayoutValues
|
|
71
|
+
/** Source-style carry for the same transition. See `SharedLayoutStyleValues`. */
|
|
72
|
+
carry: SharedLayoutStyleValues
|
|
49
73
|
/**
|
|
50
74
|
* Composite ref the consumer attaches to the rendered animated
|
|
51
75
|
* component. Forwards the underlying ref to the user-supplied `ref`
|
|
@@ -72,14 +96,32 @@ export interface SharedLayoutBindings {
|
|
|
72
96
|
* visually places the new element at the source position, then
|
|
73
97
|
* animate back to identity. `withSequence(snap, animate)` keeps the
|
|
74
98
|
* animation starting from the snapped delta rather than from zero.
|
|
99
|
+
* 5. Carry the source's style values for the keys the rect can't express
|
|
100
|
+
* (colors, `opacity`, `borderRadius`) by parking them in a snapshot and
|
|
101
|
+
* running a crossfade progress 1 → 0 on the same transition. The blend
|
|
102
|
+
* happens in the host's worklet; see `SharedLayoutStyleValues`.
|
|
103
|
+
*
|
|
104
|
+
* Coordinate space: rects are recorded in **window** coordinates when the host
|
|
105
|
+
* can be measured synchronously (Fabric), and in the parent-relative
|
|
106
|
+
* coordinates `onLayout` reports when it can't (Paper, or a detached node).
|
|
107
|
+
* Window coordinates are what let a source and target sitting under containers
|
|
108
|
+
* at different screen offsets FLIP correctly — the case the original
|
|
109
|
+
* parent-relative implementation got wrong. A source and target that ended up
|
|
110
|
+
* in *different* spaces are not comparable, so the transition is skipped rather
|
|
111
|
+
* than played wrongly. See `measureWindow.ts` for why there is no third,
|
|
112
|
+
* asynchronous option.
|
|
113
|
+
*
|
|
114
|
+
* The source is re-measured at *consume* time when it is still mounted (the
|
|
115
|
+
* usual case — a stack navigator keeps the previous screen alive underneath).
|
|
116
|
+
* That is what stops a scrolled list from offsetting every transition by its
|
|
117
|
+
* scroll distance: `onLayout` never fires for an ancestor's scroll, so the
|
|
118
|
+
* stored rect drifts while the element itself has not moved relative to its
|
|
119
|
+
* parent. See `SharedLayoutSource`.
|
|
75
120
|
*
|
|
76
|
-
*
|
|
77
|
-
*
|
|
78
|
-
*
|
|
79
|
-
*
|
|
80
|
-
* and target screens sit under containers at different screen offsets
|
|
81
|
-
* will be off by that offset; v1 documents this and leaves a precise
|
|
82
|
-
* window-coords path for v2.
|
|
121
|
+
* Known limitation: if the navigator has already begun translating the outgoing
|
|
122
|
+
* screen by the time the target first lays out, re-measuring catches the source
|
|
123
|
+
* mid-transition. The error is bounded by however far the transition has
|
|
124
|
+
* progressed, which is far smaller than an unbounded scroll offset.
|
|
83
125
|
*
|
|
84
126
|
* When `layoutId` is `undefined`, every callback is a no-op and the FLIP
|
|
85
127
|
* shared values stay at identity — the host's worklet then skips the
|
|
@@ -91,15 +133,33 @@ export function useSharedLayout(options: {
|
|
|
91
133
|
transition: TransitionConfig | undefined
|
|
92
134
|
shouldReduceMotion: boolean
|
|
93
135
|
userOnLayout: ((event: LayoutChangeEvent) => void) | undefined
|
|
136
|
+
/**
|
|
137
|
+
* Read this element's current values for the carried style keys. Supplied by
|
|
138
|
+
* the host (which owns the per-key shared values); `undefined` when the host
|
|
139
|
+
* has nothing to carry. Identity may change every render — it is read through
|
|
140
|
+
* a ref, so it never churns `onLayout`.
|
|
141
|
+
*/
|
|
142
|
+
readStyles?: () => SharedStyleSnapshot | undefined
|
|
94
143
|
}): SharedLayoutBindings {
|
|
95
|
-
const {
|
|
96
|
-
|
|
144
|
+
const {
|
|
145
|
+
layoutId,
|
|
146
|
+
userRef,
|
|
147
|
+
transition,
|
|
148
|
+
shouldReduceMotion,
|
|
149
|
+
userOnLayout,
|
|
150
|
+
readStyles,
|
|
151
|
+
} = options
|
|
97
152
|
|
|
98
153
|
const dx = useSharedValue(0)
|
|
99
154
|
const dy = useSharedValue(0)
|
|
100
155
|
const sx = useSharedValue(1)
|
|
101
156
|
const sy = useSharedValue(1)
|
|
102
157
|
|
|
158
|
+
// Style-carry pair. `null` / 0 at rest means the host worklet skips the
|
|
159
|
+
// whole branch on any element that isn't mid-transition.
|
|
160
|
+
const snapshot = useSharedValue<SharedStyleSnapshot | null>(null)
|
|
161
|
+
const carryProgress = useSharedValue(0)
|
|
162
|
+
|
|
103
163
|
// Most-recent rect for this primitive. Updated on every layout commit;
|
|
104
164
|
// read on unmount to populate the registry as the FLIP source for the
|
|
105
165
|
// next mount with the same id.
|
|
@@ -110,55 +170,100 @@ export function useSharedLayout(options: {
|
|
|
110
170
|
// refresh the registry but never re-trigger a FLIP from an old source.
|
|
111
171
|
const consumedRef = useRef(false)
|
|
112
172
|
|
|
173
|
+
// The host node, captured off the composite ref so this element can be
|
|
174
|
+
// measured in window coordinates — both for its own registry entry and when
|
|
175
|
+
// a later mount asks it to re-measure as a FLIP source.
|
|
176
|
+
const nodeRef = useRef<unknown>(null)
|
|
177
|
+
|
|
113
178
|
const transitionRef = useRef(transition)
|
|
114
179
|
transitionRef.current = transition
|
|
115
180
|
const reducedMotionRef = useRef(shouldReduceMotion)
|
|
116
181
|
reducedMotionRef.current = shouldReduceMotion
|
|
182
|
+
const readStylesRef = useRef(readStyles)
|
|
183
|
+
readStylesRef.current = readStyles
|
|
117
184
|
|
|
118
185
|
const setRef = useCallback(
|
|
119
186
|
(node: unknown) => {
|
|
187
|
+
nodeRef.current = node
|
|
120
188
|
if (typeof userRef === 'function') userRef(node)
|
|
121
189
|
else if (userRef) (userRef as MutableRefObject<unknown>).current = node
|
|
122
190
|
},
|
|
123
191
|
[userRef],
|
|
124
192
|
)
|
|
125
193
|
|
|
194
|
+
// Offered to the registry while mounted so a later mount can ask for a
|
|
195
|
+
// current measurement instead of trusting the one from our last layout.
|
|
196
|
+
const remeasure = useCallback(() => measureWindowRect(nodeRef.current), [])
|
|
197
|
+
|
|
198
|
+
// Same idea for the carried style keys: the host's values move without a
|
|
199
|
+
// layout pass, so a consumer should read them now rather than trust the
|
|
200
|
+
// snapshot from whenever we last laid out. Stable identity — the host's
|
|
201
|
+
// reader is reached through a ref.
|
|
202
|
+
const snapshotStyles = useCallback(() => readStylesRef.current?.(), [])
|
|
203
|
+
|
|
204
|
+
const startTransition = useCallback(
|
|
205
|
+
(
|
|
206
|
+
source: SharedLayoutSource,
|
|
207
|
+
sourceRect: SharedRect,
|
|
208
|
+
target: SharedRect,
|
|
209
|
+
) => {
|
|
210
|
+
// Mixed coordinate spaces are not comparable — the delta would be off by
|
|
211
|
+
// the parent's window offset. Skipping is the honest outcome; playing
|
|
212
|
+
// the animation anyway would fling the element in from the wrong place.
|
|
213
|
+
// The style carry goes with it: half a shared-element transition reads
|
|
214
|
+
// as a glitch, not as a graceful degradation.
|
|
215
|
+
if (sourceRect.space !== target.space) return
|
|
216
|
+
const shared = {
|
|
217
|
+
transition: transitionRef.current,
|
|
218
|
+
shouldReduceMotion: reducedMotionRef.current,
|
|
219
|
+
}
|
|
220
|
+
applyFlip({ source: sourceRect, target, dx, dy, sx, sy, ...shared })
|
|
221
|
+
applyStyleCarry({
|
|
222
|
+
snapshot,
|
|
223
|
+
progress: carryProgress,
|
|
224
|
+
// Prefer a live read for the same reason the rect is re-measured: a
|
|
225
|
+
// still-mounted source may have moved on since its last layout.
|
|
226
|
+
styles: source.readStyles?.() ?? source.styles,
|
|
227
|
+
...shared,
|
|
228
|
+
})
|
|
229
|
+
},
|
|
230
|
+
[dx, dy, sx, sy, snapshot, carryProgress],
|
|
231
|
+
)
|
|
232
|
+
|
|
126
233
|
const onLayout = useCallback(
|
|
127
234
|
(event: LayoutChangeEvent) => {
|
|
128
235
|
userOnLayout?.(event)
|
|
129
236
|
if (!layoutId) return
|
|
130
237
|
|
|
131
238
|
const { x, y, width, height } = event.nativeEvent.layout
|
|
132
|
-
const rect: SharedRect = { x, y, width, height }
|
|
133
|
-
lastRectRef.current = rect
|
|
134
239
|
|
|
135
240
|
// First-layout-only: read the registry BEFORE writing our own rect
|
|
136
241
|
// so a previously-released source rect can be consumed cleanly
|
|
137
242
|
// without being overwritten by the current rect first.
|
|
138
|
-
let source:
|
|
243
|
+
let source: SharedLayoutSource | undefined
|
|
139
244
|
if (!consumedRef.current) {
|
|
140
245
|
consumedRef.current = true
|
|
141
246
|
source = consumeLayout(layoutId)
|
|
142
247
|
}
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
248
|
+
|
|
249
|
+
// Window coordinates when the host can supply them synchronously,
|
|
250
|
+
// otherwise the parent-relative rect this event carries. See
|
|
251
|
+
// `measureWindow.ts` for why there is no third, asynchronous option.
|
|
252
|
+
const rect: SharedRect =
|
|
253
|
+
measureWindowRect(nodeRef.current) ??
|
|
254
|
+
({ x, y, width, height, space: 'parent' } as SharedRect)
|
|
255
|
+
|
|
256
|
+
lastRectRef.current = rect
|
|
257
|
+
registerLayout(layoutId, rect, remeasure, snapshotStyles)
|
|
258
|
+
|
|
259
|
+
if (!source) return
|
|
260
|
+
// Prefer a fresh measurement of the source over its stored rect: it may
|
|
261
|
+
// have scrolled since its last layout, and `onLayout` never reports an
|
|
262
|
+
// ancestor's scroll. Falls back to the stored rect once the source has
|
|
263
|
+
// unmounted or when it can't be measured.
|
|
264
|
+
startTransition(source, source.remeasure?.() ?? source.rect, rect)
|
|
157
265
|
},
|
|
158
|
-
|
|
159
|
-
// exhaustive-deps would flag them — including them is harmless and
|
|
160
|
-
// silences the warning.
|
|
161
|
-
[layoutId, userOnLayout, dx, dy, sx, sy],
|
|
266
|
+
[layoutId, userOnLayout, remeasure, snapshotStyles, startTransition],
|
|
162
267
|
)
|
|
163
268
|
|
|
164
269
|
// Reset the first-layout latch when the id changes — a new id is logically
|
|
@@ -167,33 +272,104 @@ export function useSharedLayout(options: {
|
|
|
167
272
|
consumedRef.current = false
|
|
168
273
|
}, [layoutId])
|
|
169
274
|
|
|
170
|
-
// On unmount, hand the latest rect
|
|
171
|
-
//
|
|
275
|
+
// On unmount, hand the latest rect — and a final snapshot of the carried
|
|
276
|
+
// style keys — to the registry under this id so the next mount can consume
|
|
277
|
+
// them. The styles are read by value here rather than left behind as a
|
|
278
|
+
// callback: after this the node is gone, and a later read would be measuring
|
|
279
|
+
// a component nobody owns.
|
|
172
280
|
useEffect(() => {
|
|
173
281
|
return () => {
|
|
174
282
|
if (!layoutId) return
|
|
175
283
|
const rect = lastRectRef.current
|
|
176
284
|
if (!rect) return
|
|
177
|
-
releaseLayout(layoutId, rect)
|
|
285
|
+
releaseLayout(layoutId, rect, snapshotStyles())
|
|
178
286
|
}
|
|
179
|
-
}, [layoutId])
|
|
287
|
+
}, [layoutId, snapshotStyles])
|
|
288
|
+
|
|
289
|
+
// Mark unmounted and stop any in-flight FLIP. The mounted flag is what keeps
|
|
290
|
+
// an asynchronous measurement callback from writing to shared values the
|
|
291
|
+
// component no longer owns; cancelling matches the guard the factory's own
|
|
292
|
+
// per-key values and the value-layer hooks got in `0.0.2` — these four were
|
|
293
|
+
// missed at the time.
|
|
294
|
+
// Stop any in-flight FLIP on unmount. Matches the guard the factory's own
|
|
295
|
+
// per-key values and the value-layer hooks got in `0.0.2` — these four were
|
|
296
|
+
// missed at the time.
|
|
297
|
+
useEffect(
|
|
298
|
+
() => () => {
|
|
299
|
+
cancelAnimation(dx)
|
|
300
|
+
cancelAnimation(dy)
|
|
301
|
+
cancelAnimation(sx)
|
|
302
|
+
cancelAnimation(sy)
|
|
303
|
+
// `snapshot` is assigned, never animated, so there is nothing to cancel
|
|
304
|
+
// on it — only the progress value drives.
|
|
305
|
+
cancelAnimation(carryProgress)
|
|
306
|
+
},
|
|
307
|
+
// The FLIP shared values are identity-stable per hook instance.
|
|
308
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
309
|
+
[],
|
|
310
|
+
)
|
|
180
311
|
|
|
181
312
|
return useMemo<SharedLayoutBindings>(
|
|
182
313
|
() => ({
|
|
183
314
|
flip: { dx, dy, sx, sy },
|
|
315
|
+
carry: { snapshot, progress: carryProgress },
|
|
184
316
|
setRef,
|
|
185
317
|
onLayout,
|
|
186
318
|
}),
|
|
187
|
-
[dx, dy, sx, sy, setRef, onLayout],
|
|
319
|
+
[dx, dy, sx, sy, snapshot, carryProgress, setRef, onLayout],
|
|
188
320
|
)
|
|
189
321
|
}
|
|
190
322
|
|
|
323
|
+
/**
|
|
324
|
+
* Build the `withSequence(snap, animate)` pair every leg of a shared-element
|
|
325
|
+
* transition uses: jump to `from` instantly, then animate to `to`. The snap leg
|
|
326
|
+
* is what makes the animation start from the source — a plain `withSpring(to)`
|
|
327
|
+
* off the resting base would animate from wherever the value already sat.
|
|
328
|
+
*
|
|
329
|
+
* Returned as a closure so the spring params are converted once and reused
|
|
330
|
+
* across all five legs (four transforms plus the style crossfade), which also
|
|
331
|
+
* guarantees the rect and the style land together.
|
|
332
|
+
*/
|
|
333
|
+
function legBuilder(
|
|
334
|
+
transition: TransitionConfig | undefined,
|
|
335
|
+
): (from: number, to: number) => number {
|
|
336
|
+
if (transition?.type === 'timing') {
|
|
337
|
+
const duration = transition.duration ?? 300
|
|
338
|
+
return (from, to) =>
|
|
339
|
+
withSequence(
|
|
340
|
+
withTiming(from, { duration: 0 }),
|
|
341
|
+
withTiming(to, { duration }),
|
|
342
|
+
)
|
|
343
|
+
}
|
|
344
|
+
// Spring path (default, and the fallback for `'decay'` which doesn't have a
|
|
345
|
+
// meaningful target value for a FLIP transition).
|
|
346
|
+
const springCfg: SpringTransition =
|
|
347
|
+
transition?.type === 'spring'
|
|
348
|
+
? { ...DEFAULT_SPRING, ...transition }
|
|
349
|
+
: { type: 'spring', ...DEFAULT_SPRING }
|
|
350
|
+
const springParams = springToReanimated(springCfg)
|
|
351
|
+
return (from, to) =>
|
|
352
|
+
withSequence(
|
|
353
|
+
withTiming(from, { duration: 0 }),
|
|
354
|
+
withSpring(to, springParams),
|
|
355
|
+
)
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* Whether a shared-element transition should play at all, or snap. Reduced
|
|
360
|
+
* motion and an explicit `'no-animation'` transition are the same answer for
|
|
361
|
+
* both the rect and the style halves.
|
|
362
|
+
*/
|
|
363
|
+
function shouldSnap(
|
|
364
|
+
transition: TransitionConfig | undefined,
|
|
365
|
+
shouldReduceMotion: boolean,
|
|
366
|
+
): boolean {
|
|
367
|
+
return shouldReduceMotion || transition?.type === 'no-animation'
|
|
368
|
+
}
|
|
369
|
+
|
|
191
370
|
/**
|
|
192
371
|
* Snap the FLIP shared values so the new element visually overlays its
|
|
193
|
-
* source rect, then animate back to identity.
|
|
194
|
-
* animate)` shape is what makes the spring start from the snapped delta —
|
|
195
|
-
* a plain `withSpring(0)` from a zero base would animate from-zero, not
|
|
196
|
-
* from-source.
|
|
372
|
+
* source rect, then animate back to identity.
|
|
197
373
|
*/
|
|
198
374
|
function applyFlip(args: {
|
|
199
375
|
source: SharedRect
|
|
@@ -223,9 +399,9 @@ function applyFlip(args: {
|
|
|
223
399
|
const scaleX = target.width > 0 ? source.width / target.width : 1
|
|
224
400
|
const scaleY = target.height > 0 ? source.height / target.height : 1
|
|
225
401
|
|
|
226
|
-
if (shouldReduceMotion) {
|
|
227
|
-
//
|
|
228
|
-
//
|
|
402
|
+
if (shouldSnap(transition, shouldReduceMotion)) {
|
|
403
|
+
// Skip the visual transition entirely. The element appears at its natural
|
|
404
|
+
// position; the source rect is discarded.
|
|
229
405
|
dx.value = 0
|
|
230
406
|
dy.value = 0
|
|
231
407
|
sx.value = 1
|
|
@@ -233,57 +409,46 @@ function applyFlip(args: {
|
|
|
233
409
|
return
|
|
234
410
|
}
|
|
235
411
|
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
}
|
|
412
|
+
const leg = legBuilder(transition)
|
|
413
|
+
dx.value = leg(deltaX, 0)
|
|
414
|
+
dy.value = leg(deltaY, 0)
|
|
415
|
+
sx.value = leg(scaleX, 1)
|
|
416
|
+
sy.value = leg(scaleY, 1)
|
|
417
|
+
}
|
|
243
418
|
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
419
|
+
/**
|
|
420
|
+
* Park the source element's carried style values and run the crossfade
|
|
421
|
+
* progress 1 → 0 on the same transition as the rect FLIP.
|
|
422
|
+
*
|
|
423
|
+
* The blend itself happens in the host's worklet, which pulls each carried key
|
|
424
|
+
* from `snapshot[key]` toward its own resolved value by `progress`. Driving
|
|
425
|
+
* only a scalar here is what keeps this cheap: one shared value moves, however
|
|
426
|
+
* many keys are being carried, and colors go through the same
|
|
427
|
+
* `interpolateColor` branch the gesture cascade already uses.
|
|
428
|
+
*
|
|
429
|
+
* A source with nothing to carry leaves both values alone — an element whose
|
|
430
|
+
* counterpart declared no carried keys behaves exactly as it did before style
|
|
431
|
+
* carry existed.
|
|
432
|
+
*/
|
|
433
|
+
function applyStyleCarry(args: {
|
|
434
|
+
snapshot: SharedValue<SharedStyleSnapshot | null>
|
|
435
|
+
progress: SharedValue<number>
|
|
436
|
+
styles: SharedStyleSnapshot | undefined
|
|
437
|
+
transition: TransitionConfig | undefined
|
|
438
|
+
shouldReduceMotion: boolean
|
|
439
|
+
}): void {
|
|
440
|
+
const { snapshot, progress, styles, transition, shouldReduceMotion } = args
|
|
441
|
+
if (!styles) return
|
|
442
|
+
|
|
443
|
+
if (shouldSnap(transition, shouldReduceMotion)) {
|
|
444
|
+
// Matching the rect path: the element arrives wearing its own style rather
|
|
445
|
+
// than crossfading into it. Clearing the snapshot too means the worklet's
|
|
446
|
+
// branch stays untaken instead of resting on a dead payload.
|
|
447
|
+
snapshot.value = null
|
|
448
|
+
progress.value = 0
|
|
262
449
|
return
|
|
263
450
|
}
|
|
264
451
|
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
const springCfg: SpringTransition =
|
|
268
|
-
transition?.type === 'spring'
|
|
269
|
-
? { ...DEFAULT_SPRING, ...transition }
|
|
270
|
-
: { type: 'spring', ...DEFAULT_SPRING }
|
|
271
|
-
const springParams = springToReanimated(springCfg)
|
|
272
|
-
|
|
273
|
-
dx.value = withSequence(
|
|
274
|
-
withTiming(deltaX, { duration: 0 }),
|
|
275
|
-
withSpring(0, springParams),
|
|
276
|
-
)
|
|
277
|
-
dy.value = withSequence(
|
|
278
|
-
withTiming(deltaY, { duration: 0 }),
|
|
279
|
-
withSpring(0, springParams),
|
|
280
|
-
)
|
|
281
|
-
sx.value = withSequence(
|
|
282
|
-
withTiming(scaleX, { duration: 0 }),
|
|
283
|
-
withSpring(1, springParams),
|
|
284
|
-
)
|
|
285
|
-
sy.value = withSequence(
|
|
286
|
-
withTiming(scaleY, { duration: 0 }),
|
|
287
|
-
withSpring(1, springParams),
|
|
288
|
-
)
|
|
452
|
+
snapshot.value = styles
|
|
453
|
+
progress.value = legBuilder(transition)(1, 0)
|
|
289
454
|
}
|