@rootnative/inertia 0.0.3 → 0.0.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/CHANGELOG.md +92 -5
- package/README.md +9 -3
- package/dist/{chunk-OQV66TBQ.mjs → chunk-27MKBTSG.mjs} +1 -1
- package/dist/{chunk-3XTVY34H.js → chunk-27U6766G.js} +2 -2
- package/dist/{chunk-4PEHWDAZ.js → chunk-4NAMD62I.js} +7 -7
- package/dist/chunk-67GHRCF6.js +8 -0
- package/dist/{chunk-SGUHE5CX.mjs → chunk-COEJVWRZ.mjs} +1 -1
- package/dist/chunk-GPOMFIIU.js +8 -0
- package/dist/{chunk-BQQTHG2V.mjs → chunk-GTJ6VAH5.mjs} +1 -1
- package/dist/chunk-K63LXGKS.js +8 -0
- package/dist/{chunk-46P57VMY.mjs → chunk-OAUWLPQH.mjs} +1 -1
- package/dist/{chunk-7AOERN53.js → chunk-OBRGJAST.js} +3 -3
- package/dist/{chunk-RGNX6UZN.mjs → chunk-OW5XTGVN.mjs} +2 -2
- package/dist/{chunk-PTRF47DA.js → chunk-PM6CVGXJ.js} +11 -3
- package/dist/{chunk-W5MC3P4N.js → chunk-QQVDZKSD.js} +2 -2
- package/dist/{chunk-CY7Y64C3.mjs → chunk-REYL77RE.mjs} +11 -4
- package/dist/{chunk-CSODMRJ7.mjs → chunk-RIVVBABB.mjs} +597 -134
- package/dist/{chunk-BP3Y2SHQ.js → chunk-VQ5D35UA.js} +617 -152
- package/dist/{chunk-O22NXXCZ.mjs → chunk-XSK5MNUH.mjs} +1 -1
- package/dist/{chunk-R63GIUNU.mjs → chunk-Z3HCJ43H.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-BQgLJeQG.d.mts} +85 -14
- package/dist/{types-G1dBU2IQ.d.ts → types-BQgLJeQG.d.ts} +85 -14
- package/dist/{useGesture-D1e4ixJo.d.ts → useGesture-B0_CzOUz.d.ts} +1 -1
- package/dist/{useGesture-RFY6kTL3.d.mts → useGesture-DfaTx-3t.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 +497 -74
- package/src/motion/installCheck.ts +1 -1
- package/src/transitions/index.ts +1 -1
- package/src/transitions/resolveSequence.ts +12 -0
- package/src/types.ts +155 -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
|
@@ -144,17 +144,24 @@ export function parseBoxShadow(input: string): ResolvedBoxShadowLayer[] {
|
|
|
144
144
|
})
|
|
145
145
|
}
|
|
146
146
|
|
|
147
|
-
/**
|
|
147
|
+
/**
|
|
148
|
+
* Resolve either input form (CSS string or structured layers) to layers.
|
|
149
|
+
*
|
|
150
|
+
* Lengths may arrive as numbers (what `useShadow`'s `BoxShadowLayer` declares)
|
|
151
|
+
* or as px strings (what RN's own `BoxShadowValue` permits, so what the
|
|
152
|
+
* `animate` surface has to accept). `coerceLength` normalizes both and rejects
|
|
153
|
+
* anything else, so downstream only ever sees concrete numbers.
|
|
154
|
+
*/
|
|
148
155
|
export function resolveBoxShadowInput(
|
|
149
|
-
input:
|
|
156
|
+
input: BoxShadowInput | undefined,
|
|
150
157
|
): ResolvedBoxShadowLayer[] {
|
|
151
158
|
if (input === undefined) return []
|
|
152
159
|
if (typeof input === 'string') return parseBoxShadow(input)
|
|
153
160
|
return input.map((layer) => ({
|
|
154
|
-
offsetX: layer.offsetX,
|
|
155
|
-
offsetY: layer.offsetY,
|
|
156
|
-
blurRadius: layer.blurRadius
|
|
157
|
-
spreadDistance: layer.spreadDistance
|
|
161
|
+
offsetX: coerceLength(layer.offsetX, 'offsetX'),
|
|
162
|
+
offsetY: coerceLength(layer.offsetY, 'offsetY'),
|
|
163
|
+
blurRadius: coerceLength(layer.blurRadius, 'blurRadius'),
|
|
164
|
+
spreadDistance: coerceLength(layer.spreadDistance, 'spreadDistance'),
|
|
158
165
|
color: layer.color ?? 'black',
|
|
159
166
|
inset: layer.inset ?? false,
|
|
160
167
|
}))
|
|
@@ -202,3 +209,167 @@ function invisibleLayer(inset: boolean): ResolvedBoxShadowLayer {
|
|
|
202
209
|
inset,
|
|
203
210
|
}
|
|
204
211
|
}
|
|
212
|
+
|
|
213
|
+
/* ------------------------------------------------------------------ *
|
|
214
|
+
* Declarative `animate={{ boxShadow }}` support
|
|
215
|
+
*
|
|
216
|
+
* The `useShadow` path above interpolates two endpoints itself, by
|
|
217
|
+
* progress. The `animate` path instead hands the target to Reanimated's
|
|
218
|
+
* own animation drivers, which have their own rules — the helpers below
|
|
219
|
+
* exist to satisfy them on the JS thread so the worklet never does string
|
|
220
|
+
* or structural work at frame time (Principle 8).
|
|
221
|
+
* ------------------------------------------------------------------ */
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* The interpolable half of a box-shadow layer: every field Reanimated can
|
|
225
|
+
* drive, and nothing it can't.
|
|
226
|
+
*
|
|
227
|
+
* `inset` is deliberately absent. `withTiming` / `withSpring` recurse into
|
|
228
|
+
* arrays and objects and animate each leaf, and a boolean leaf falls through
|
|
229
|
+
* to the plain numeric path — `false + (false - false) * p` evaluates to `0`,
|
|
230
|
+
* so an in-flight frame hands the native shadow a number where it expects a
|
|
231
|
+
* boolean. Inset is instead carried alongside as a static per-layer flag (see
|
|
232
|
+
* `SplitBoxShadow.insets`) and reattached when the style is emitted.
|
|
233
|
+
*/
|
|
234
|
+
export interface AnimatedBoxShadowLayer {
|
|
235
|
+
offsetX: number
|
|
236
|
+
offsetY: number
|
|
237
|
+
blurRadius: number
|
|
238
|
+
spreadDistance: number
|
|
239
|
+
color: string
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/**
|
|
243
|
+
* Resolved layers split into the part Reanimated animates and the part it
|
|
244
|
+
* must not touch.
|
|
245
|
+
*
|
|
246
|
+
* `insets` is `null` — not an array of `false` — when no layer is inset, which
|
|
247
|
+
* is the overwhelmingly common case. That lets the emitting worklet skip
|
|
248
|
+
* reassembly entirely and pass the animated array straight through with zero
|
|
249
|
+
* per-frame allocation.
|
|
250
|
+
*/
|
|
251
|
+
export interface SplitBoxShadow {
|
|
252
|
+
layers: AnimatedBoxShadowLayer[]
|
|
253
|
+
insets: boolean[] | null
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/**
|
|
257
|
+
* Accepted target shape on the `animate` surface: a CSS string or RN's own
|
|
258
|
+
* `BoxShadowValue[]`, whose lengths may be numbers or px strings.
|
|
259
|
+
*
|
|
260
|
+
* Structurally this is RN's `boxShadow` style value. It is restated here
|
|
261
|
+
* rather than imported so this module stays dependency-free and testable as a
|
|
262
|
+
* pure function; `types.ts` is where the public-facing alias lives.
|
|
263
|
+
*/
|
|
264
|
+
export type BoxShadowInput =
|
|
265
|
+
| string
|
|
266
|
+
| ReadonlyArray<{
|
|
267
|
+
offsetX: number | string
|
|
268
|
+
offsetY: number | string
|
|
269
|
+
// `unknown` because RN types this as `ColorValue | number` — a quirk of
|
|
270
|
+
// its own declarations rather than a real capability. Coerced (and
|
|
271
|
+
// rejected) by `coerceLength` like every other length.
|
|
272
|
+
blurRadius?: unknown
|
|
273
|
+
spreadDistance?: number | string | undefined
|
|
274
|
+
color?: string | undefined
|
|
275
|
+
inset?: boolean | undefined
|
|
276
|
+
}>
|
|
277
|
+
|
|
278
|
+
/**
|
|
279
|
+
* Coerce one RN length field to a number. RN's `BoxShadowValue` types its
|
|
280
|
+
* lengths as `number | string` so `{ offsetX: '4px' }` is legal input; the
|
|
281
|
+
* animated slot needs a plain number to interpolate. Unit handling matches
|
|
282
|
+
* `parseBoxShadow` — px and unitless only, and anything else throws rather
|
|
283
|
+
* than silently animating from a `NaN`.
|
|
284
|
+
*/
|
|
285
|
+
function coerceLength(value: unknown, field: string): number {
|
|
286
|
+
if (value === undefined) return 0
|
|
287
|
+
if (typeof value === 'number') return value
|
|
288
|
+
const trimmed = typeof value === 'string' ? value.trim() : ''
|
|
289
|
+
if (!LENGTH.test(trimmed)) {
|
|
290
|
+
throw new Error(
|
|
291
|
+
`[inertia] boxShadow: ${field} must be a number or a px length, got ` +
|
|
292
|
+
`${JSON.stringify(value)}.`,
|
|
293
|
+
)
|
|
294
|
+
}
|
|
295
|
+
return parseFloat(trimmed)
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
/** Drop `inset`, leaving only the fields Reanimated may drive. */
|
|
299
|
+
function strip(layer: ResolvedBoxShadowLayer): AnimatedBoxShadowLayer {
|
|
300
|
+
const { inset: _inset, ...rest } = layer
|
|
301
|
+
return rest
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Record layer `i` as inset, materializing the flag list on first use so the
|
|
306
|
+
* common all-outset case keeps `null` and costs the worklet nothing.
|
|
307
|
+
*/
|
|
308
|
+
function markInset(
|
|
309
|
+
insets: boolean[] | null,
|
|
310
|
+
i: number,
|
|
311
|
+
count: number,
|
|
312
|
+
): boolean[] {
|
|
313
|
+
const list = insets ?? new Array<boolean>(count).fill(false)
|
|
314
|
+
list[i] = true
|
|
315
|
+
return list
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
/**
|
|
319
|
+
* Normalize a target into the shared-value seed shape. Used when the slot is
|
|
320
|
+
* first populated (mount seed, static-style resting value).
|
|
321
|
+
*/
|
|
322
|
+
export function normalizeBoxShadow(input: BoxShadowInput): SplitBoxShadow {
|
|
323
|
+
const resolved = resolveBoxShadowInput(input)
|
|
324
|
+
const layers: AnimatedBoxShadowLayer[] = []
|
|
325
|
+
let insets: boolean[] | null = null
|
|
326
|
+
for (let i = 0; i < resolved.length; i++) {
|
|
327
|
+
const layer = resolved[i]!
|
|
328
|
+
layers.push(strip(layer))
|
|
329
|
+
if (layer.inset) insets = markInset(insets, i, resolved.length)
|
|
330
|
+
}
|
|
331
|
+
return { layers, insets }
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
/**
|
|
335
|
+
* Prepare a from/to pair for a Reanimated-driven `boxShadow` animation.
|
|
336
|
+
*
|
|
337
|
+
* Both sides come back padded to the same layer count, because Reanimated's
|
|
338
|
+
* `arrayOnStart` walks the **current** value's indices and reads `toValue[i]`
|
|
339
|
+
* for each — a target with fewer layers leaves the surplus leaves with
|
|
340
|
+
* `toValue: undefined`, and a target with more never animates the extras at
|
|
341
|
+
* all. `pairBoxShadowLayers` supplies the padding (a transparent zero layer,
|
|
342
|
+
* matching CSS transition semantics) and rejects a genuine inset mismatch.
|
|
343
|
+
*
|
|
344
|
+
* The returned `from` is only meaningful when it differs from what the slot
|
|
345
|
+
* already holds; callers snap the slot to it before starting the animation so
|
|
346
|
+
* the interpolation has a same-shaped base to run from.
|
|
347
|
+
*/
|
|
348
|
+
export function prepareBoxShadowAnimation(
|
|
349
|
+
current: SplitBoxShadow,
|
|
350
|
+
target: BoxShadowInput,
|
|
351
|
+
): {
|
|
352
|
+
from: AnimatedBoxShadowLayer[]
|
|
353
|
+
to: AnimatedBoxShadowLayer[]
|
|
354
|
+
insets: boolean[] | null
|
|
355
|
+
} {
|
|
356
|
+
const pairs = pairBoxShadowLayers(
|
|
357
|
+
current.layers.map((layer, i) => ({
|
|
358
|
+
...layer,
|
|
359
|
+
inset: current.insets?.[i] ?? false,
|
|
360
|
+
})),
|
|
361
|
+
resolveBoxShadowInput(target),
|
|
362
|
+
)
|
|
363
|
+
const from: AnimatedBoxShadowLayer[] = []
|
|
364
|
+
const to: AnimatedBoxShadowLayer[] = []
|
|
365
|
+
let insets: boolean[] | null = null
|
|
366
|
+
for (let i = 0; i < pairs.length; i++) {
|
|
367
|
+
const pair = pairs[i]!
|
|
368
|
+
from.push(strip(pair.from))
|
|
369
|
+
to.push(strip(pair.to))
|
|
370
|
+
// `pairBoxShadowLayers` has already established that the two sides agree
|
|
371
|
+
// on inset, so reading either describes the pair.
|
|
372
|
+
if (pair.to.inset) insets = markInset(insets, i, pairs.length)
|
|
373
|
+
}
|
|
374
|
+
return { from, to, insets }
|
|
375
|
+
}
|
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { __resetWarnOnceForTests, warnOnce } from './warnOnce'
|
|
2
|
+
|
|
1
3
|
declare const __DEV__: boolean
|
|
2
4
|
|
|
3
5
|
// The core package intentionally has no Node types — declare the minimal
|
|
@@ -5,8 +7,6 @@ declare const __DEV__: boolean
|
|
|
5
7
|
// environments without `process` are fine).
|
|
6
8
|
declare const process: { env?: Record<string, string | undefined> } | undefined
|
|
7
9
|
|
|
8
|
-
const warned = new Set<string>()
|
|
9
|
-
|
|
10
10
|
/**
|
|
11
11
|
* Dev-only, once-per-key warning for plain (non-worklet) functions handed to
|
|
12
12
|
* surfaces that need real worklets — `useTransform` transformers and custom
|
|
@@ -30,12 +30,10 @@ const warned = new Set<string>()
|
|
|
30
30
|
export function warnNonWorkletOnce(key: string, message: string): void {
|
|
31
31
|
if (!__DEV__) return
|
|
32
32
|
if (typeof process !== 'undefined' && process.env?.JEST_WORKER_ID) return
|
|
33
|
-
|
|
34
|
-
warned.add(key)
|
|
35
|
-
console.warn(message)
|
|
33
|
+
warnOnce(key, message)
|
|
36
34
|
}
|
|
37
35
|
|
|
38
36
|
/** @internal — test-only hook to reset the once-per-key state. */
|
|
39
37
|
export function __resetNonWorkletWarningsForTests(): void {
|
|
40
|
-
|
|
38
|
+
__resetWarnOnceForTests()
|
|
41
39
|
}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
declare const __DEV__: boolean
|
|
2
|
+
|
|
3
|
+
const warned = new Set<string>()
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Dev-only warning that fires at most once per `key` for the lifetime of the
|
|
7
|
+
* module.
|
|
8
|
+
*
|
|
9
|
+
* Inertia's warnings mostly fire from render or from a per-frame-adjacent
|
|
10
|
+
* resolver, so an unguarded `console.warn` would repeat on every render and
|
|
11
|
+
* bury the signal. Keying the guard (rather than using a single boolean)
|
|
12
|
+
* keeps two genuinely different misconfigurations from silencing each other.
|
|
13
|
+
*
|
|
14
|
+
* Unlike `warnNonWorkletOnce`, this is **not** suppressed under Jest — these
|
|
15
|
+
* warnings describe consumer-authored prop combinations, which a test suite
|
|
16
|
+
* should be able to assert on.
|
|
17
|
+
*/
|
|
18
|
+
export function warnOnce(key: string, message: string): void {
|
|
19
|
+
if (!__DEV__) return
|
|
20
|
+
if (warned.has(key)) return
|
|
21
|
+
warned.add(key)
|
|
22
|
+
console.warn(message)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** @internal — test-only hook to reset the once-per-key state. */
|
|
26
|
+
export function __resetWarnOnceForTests(): void {
|
|
27
|
+
warned.clear()
|
|
28
|
+
}
|
package/src/layout/index.ts
CHANGED
|
@@ -8,10 +8,20 @@ export {
|
|
|
8
8
|
SHARED_LAYOUT_TTL_MS,
|
|
9
9
|
__setSharedLayoutClock,
|
|
10
10
|
__sharedRegistrySize,
|
|
11
|
+
type CoordinateSpace,
|
|
12
|
+
type SharedLayoutSource,
|
|
11
13
|
type SharedRect,
|
|
14
|
+
type SharedStyleSnapshot,
|
|
12
15
|
} from './sharedRegistry'
|
|
16
|
+
export {
|
|
17
|
+
__setSharedLayoutMeasurer,
|
|
18
|
+
measureWindowRect,
|
|
19
|
+
type MeasuredRect,
|
|
20
|
+
type WindowMeasurer,
|
|
21
|
+
} from './measureWindow'
|
|
13
22
|
export {
|
|
14
23
|
useSharedLayout,
|
|
15
24
|
type SharedLayoutBindings,
|
|
25
|
+
type SharedLayoutStyleValues,
|
|
16
26
|
type SharedLayoutValues,
|
|
17
27
|
} from './useSharedLayout'
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import { type CoordinateSpace, type SharedRect } from './sharedRegistry'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Window-coordinate measurement for shared-element rects.
|
|
5
|
+
*
|
|
6
|
+
* ## Synchronous or not at all
|
|
7
|
+
*
|
|
8
|
+
* `measureInWindow` is callback-shaped but not reliably asynchronous, and the
|
|
9
|
+
* difference decides this module's design:
|
|
10
|
+
*
|
|
11
|
+
* - **Fabric** (the default from RN 0.76, and our 0.81+ baseline) resolves it
|
|
12
|
+
* through a synchronous JSI call — the callback runs before
|
|
13
|
+
* `measureInWindow` returns.
|
|
14
|
+
* - **Paper** goes over the bridge, so the callback lands a tick later.
|
|
15
|
+
* - **A detached node calls back never at all.** Not an edge case and not a
|
|
16
|
+
* platform quirk: `ReactFabricHostComponent.measureInWindow` looks the node
|
|
17
|
+
* up and simply returns when it is missing. The Jest host mock behaves the
|
|
18
|
+
* same way.
|
|
19
|
+
*
|
|
20
|
+
* So this module reads the result **synchronously or treats it as
|
|
21
|
+
* unavailable**, and ignores a callback that arrives late. That is not a
|
|
22
|
+
* limitation to work around — it is what keeps the coordinate space coherent.
|
|
23
|
+
* An implementation that awaited the callback would put Fabric in window space
|
|
24
|
+
* and Paper in parent space *for the same element at different moments*, and
|
|
25
|
+
* since the two spaces aren't comparable, a source stored in one and a target
|
|
26
|
+
* measured in the other silently skips the transition. Better to be
|
|
27
|
+
* consistently parent-relative on a platform that can't measure synchronously —
|
|
28
|
+
* which is exactly how this worked before window coordinates existed.
|
|
29
|
+
*
|
|
30
|
+
* Net effect: nested-parent shared elements are fixed on Fabric, and on Paper
|
|
31
|
+
* the behavior is unchanged rather than intermittently broken.
|
|
32
|
+
*
|
|
33
|
+
* ## Why the result is validated rather than trusted
|
|
34
|
+
*
|
|
35
|
+
* A node that is off-screen or laid out to nothing reports zeros (or `NaN` on
|
|
36
|
+
* some platforms) instead of failing. Feeding that into a FLIP would fling the
|
|
37
|
+
* element in from the top-left corner — far worse than not animating. Anything
|
|
38
|
+
* unusable is reported as `undefined` so the caller falls back to the
|
|
39
|
+
* parent-relative rect it already has.
|
|
40
|
+
*/
|
|
41
|
+
|
|
42
|
+
/** Raw measurement, before it is tagged with a coordinate space. */
|
|
43
|
+
export interface MeasuredRect {
|
|
44
|
+
x: number
|
|
45
|
+
y: number
|
|
46
|
+
width: number
|
|
47
|
+
height: number
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Measures `node` in window coordinates, or returns `undefined` when it can't
|
|
52
|
+
* be measured synchronously. Swappable for tests via
|
|
53
|
+
* `__setSharedLayoutMeasurer`.
|
|
54
|
+
*/
|
|
55
|
+
export type WindowMeasurer = (node: unknown) => MeasuredRect | undefined
|
|
56
|
+
|
|
57
|
+
type MeasureInWindowNode = {
|
|
58
|
+
measureInWindow?: (
|
|
59
|
+
callback: (x: number, y: number, width: number, height: number) => void,
|
|
60
|
+
) => void
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const defaultMeasurer: WindowMeasurer = (node) => {
|
|
64
|
+
const measure = (node as MeasureInWindowNode | null)?.measureInWindow
|
|
65
|
+
if (typeof measure !== 'function') return undefined
|
|
66
|
+
|
|
67
|
+
let result: MeasuredRect | undefined
|
|
68
|
+
let returned = false
|
|
69
|
+
try {
|
|
70
|
+
measure.call(node, (x, y, width, height) => {
|
|
71
|
+
// A callback that arrives after we've returned is Paper's asynchronous
|
|
72
|
+
// bridge answering too late to use. Dropping it is deliberate: accepting
|
|
73
|
+
// it would upgrade this element to window space while its counterpart
|
|
74
|
+
// stays parent-relative, and mixed spaces cancel the transition.
|
|
75
|
+
if (returned) return
|
|
76
|
+
result = { x, y, width, height }
|
|
77
|
+
})
|
|
78
|
+
} catch {
|
|
79
|
+
// Some hosts throw rather than no-op on a detached node.
|
|
80
|
+
returned = true
|
|
81
|
+
return undefined
|
|
82
|
+
}
|
|
83
|
+
returned = true
|
|
84
|
+
return result
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
let measurer: WindowMeasurer = defaultMeasurer
|
|
88
|
+
|
|
89
|
+
function usable(rect: MeasuredRect): boolean {
|
|
90
|
+
const { x, y, width, height } = rect
|
|
91
|
+
for (const v of [x, y, width, height]) {
|
|
92
|
+
if (typeof v !== 'number' || !Number.isFinite(v)) return false
|
|
93
|
+
}
|
|
94
|
+
// Zero-sized means "not really measurable" in practice — detached, or laid
|
|
95
|
+
// out to nothing. Either way there is no FLIP to compute from it.
|
|
96
|
+
return width !== 0 || height !== 0
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Measure `node` in window coordinates and tag the result, or return
|
|
101
|
+
* `undefined` when no usable synchronous measurement is available.
|
|
102
|
+
*
|
|
103
|
+
* Validation lives here rather than in the default measurer so a swapped-in
|
|
104
|
+
* measurer is held to the same contract — an unusable rect can never reach a
|
|
105
|
+
* FLIP, however it was produced.
|
|
106
|
+
*/
|
|
107
|
+
export function measureWindowRect(node: unknown): SharedRect | undefined {
|
|
108
|
+
const measured = measurer(node)
|
|
109
|
+
if (!measured || !usable(measured)) return undefined
|
|
110
|
+
return toSharedRect(measured, 'window')
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/** Tag a raw measurement with its coordinate space. */
|
|
114
|
+
export function toSharedRect(
|
|
115
|
+
rect: MeasuredRect,
|
|
116
|
+
space: CoordinateSpace,
|
|
117
|
+
): SharedRect {
|
|
118
|
+
return { ...rect, space }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Test hook: swap the measurement implementation. Pass `undefined` to restore
|
|
123
|
+
* the real `measureInWindow` path. Not exported from the package root —
|
|
124
|
+
* reachable only from inside the workspace.
|
|
125
|
+
*
|
|
126
|
+
* Needed because the Jest host mock's `measureInWindow` never invokes its
|
|
127
|
+
* callback, so without an override every test would exercise only the
|
|
128
|
+
* parent-relative fallback — which is also, deliberately, what a consumer's
|
|
129
|
+
* test suite sees.
|
|
130
|
+
*/
|
|
131
|
+
export function __setSharedLayoutMeasurer(
|
|
132
|
+
fn: WindowMeasurer | undefined,
|
|
133
|
+
): void {
|
|
134
|
+
measurer = fn ?? defaultMeasurer
|
|
135
|
+
}
|
|
@@ -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. */
|