@hanzogui/animations-reanimated 7.3.0 → 8.0.0
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/cjs/createAnimations.cjs +462 -153
- package/dist/cjs/createAnimations.native.js +620 -220
- package/dist/cjs/createAnimations.native.js.map +1 -1
- package/dist/esm/createAnimations.mjs +462 -153
- package/dist/esm/createAnimations.mjs.map +1 -1
- package/dist/esm/createAnimations.native.js +620 -220
- package/dist/esm/createAnimations.native.js.map +1 -1
- package/package.json +12 -12
- package/src/createAnimations.tsx +803 -226
- package/types/createAnimations.d.ts.map +2 -2
- package/LICENSE +0 -42
package/src/createAnimations.tsx
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
type UniversalAnimatedNumber,
|
|
14
14
|
} from '@hanzogui/core'
|
|
15
15
|
import { ResetPresence, usePresence } from '@hanzogui/use-presence'
|
|
16
|
+
import normalizeColor from '@react-native/normalize-colors'
|
|
16
17
|
import React, { forwardRef, useMemo, useRef } from 'react'
|
|
17
18
|
import type { SharedValue } from 'react-native-reanimated'
|
|
18
19
|
import Animated_, {
|
|
@@ -48,12 +49,50 @@ const getDefaultExport = <T,>(module: T | { default: T }): T => {
|
|
|
48
49
|
|
|
49
50
|
const Animated = getDefaultExport(Animated_)
|
|
50
51
|
|
|
52
|
+
const silenceAnimatedComponentDevCheck = (style: unknown) => {
|
|
53
|
+
if (
|
|
54
|
+
process.env.NODE_ENV !== 'development' ||
|
|
55
|
+
isWeb ||
|
|
56
|
+
!style ||
|
|
57
|
+
typeof style !== 'object'
|
|
58
|
+
) {
|
|
59
|
+
return
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// react fabric's dev performance logger diffs component props and reads the
|
|
63
|
+
// reanimated guard getter even when the style is on an animated component.
|
|
64
|
+
Object.defineProperty(style, '_requiresAnimatedComponent', {
|
|
65
|
+
configurable: true,
|
|
66
|
+
enumerable: false,
|
|
67
|
+
value: true,
|
|
68
|
+
})
|
|
69
|
+
}
|
|
70
|
+
|
|
51
71
|
// =============================================================================
|
|
52
72
|
// Types
|
|
53
73
|
// =============================================================================
|
|
54
74
|
|
|
55
75
|
type ReanimatedAnimatedNumber = SharedValue<number>
|
|
56
76
|
|
|
77
|
+
type AnimationSnapshot = {
|
|
78
|
+
animated: Record<string, unknown>
|
|
79
|
+
statics: Record<string, unknown>
|
|
80
|
+
transforms: Array<Record<string, unknown>>
|
|
81
|
+
gatedKeys: Record<string, boolean>
|
|
82
|
+
/**
|
|
83
|
+
* for each animated key (transform sub-keys as `transform:key`), the value the
|
|
84
|
+
* previous committed render painted for it, when one exists and is animatable.
|
|
85
|
+
* the worklet animates a mapper-fresh key FROM its seed instead of snapping —
|
|
86
|
+
* this is how an enterStyle value painted statically during mount becomes the
|
|
87
|
+
* start point once the key turns animated. keys with no seed paint their target
|
|
88
|
+
* directly (a key appearing from nothing has nothing to animate from).
|
|
89
|
+
*/
|
|
90
|
+
seeds: Record<string, unknown>
|
|
91
|
+
removedKeys: Record<string, boolean>
|
|
92
|
+
removeTransform: boolean
|
|
93
|
+
clearValue: '' | null
|
|
94
|
+
}
|
|
95
|
+
|
|
57
96
|
/** Spring animation configuration */
|
|
58
97
|
type SpringConfig = {
|
|
59
98
|
type?: 'spring'
|
|
@@ -95,32 +134,316 @@ const resolveDynamicValue = (value: unknown, isDark: boolean): unknown => {
|
|
|
95
134
|
/** Animation completion callback type */
|
|
96
135
|
type AnimationCallback = (finished?: boolean) => void
|
|
97
136
|
|
|
137
|
+
type MapperState = {
|
|
138
|
+
emitted: Record<string, boolean>
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const updateMapperState = isWeb
|
|
142
|
+
? (state: SharedValue<MapperState>, emitted: Record<string, boolean>) => {
|
|
143
|
+
state.value.emitted = emitted
|
|
144
|
+
}
|
|
145
|
+
: (state: SharedValue<MapperState>, emitted: Record<string, boolean>) => {
|
|
146
|
+
'worklet'
|
|
147
|
+
if (!globalThis._WORKLET) return
|
|
148
|
+
state.modify((current) => {
|
|
149
|
+
current.emitted = emitted
|
|
150
|
+
return current
|
|
151
|
+
}, false)
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const cloneAnimationValue = (value: unknown): unknown => {
|
|
155
|
+
if (Array.isArray(value)) {
|
|
156
|
+
return value.map(cloneAnimationValue)
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
if (value && typeof value === 'object') {
|
|
160
|
+
const next: Record<string, unknown> = {}
|
|
161
|
+
for (const key in value as Record<string, unknown>) {
|
|
162
|
+
next[key] = cloneAnimationValue((value as Record<string, unknown>)[key])
|
|
163
|
+
}
|
|
164
|
+
return next
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return value
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const cloneStyleRecord = (style: Record<string, unknown>): Record<string, unknown> => {
|
|
171
|
+
const next: Record<string, unknown> = {}
|
|
172
|
+
for (const key in style) {
|
|
173
|
+
next[key] = cloneAnimationValue(style[key])
|
|
174
|
+
}
|
|
175
|
+
return next
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
const cloneTransitionConfig = (config: TransitionConfig): TransitionConfig => {
|
|
179
|
+
return cloneAnimationValue(config) as TransitionConfig
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const getAnimatedTransforms = (value: unknown): Array<Record<string, unknown>> => {
|
|
183
|
+
if (!Array.isArray(value)) return []
|
|
184
|
+
return value.filter(
|
|
185
|
+
(item): item is Record<string, unknown> =>
|
|
186
|
+
item !== null && typeof item === 'object' && !Array.isArray(item)
|
|
187
|
+
)
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
const getRemovedAnimatedKeys = (
|
|
191
|
+
current: Set<string>,
|
|
192
|
+
previous: Set<string>
|
|
193
|
+
): Record<string, boolean> => {
|
|
194
|
+
const removedKeys: Record<string, boolean> = {}
|
|
195
|
+
for (const key of previous) {
|
|
196
|
+
if (!current.has(key)) removedKeys[key] = true
|
|
197
|
+
}
|
|
198
|
+
return removedKeys
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// implicit start values for style keys that were never painted (e.g. a key
|
|
202
|
+
// introduced by exitStyle with no base value). most numerics default to 0.
|
|
203
|
+
const getImplicitDefault = (
|
|
204
|
+
key: string,
|
|
205
|
+
targetValue: number | string
|
|
206
|
+
): number | string => {
|
|
207
|
+
'worklet'
|
|
208
|
+
const value =
|
|
209
|
+
key === 'opacity' || key === 'scale' || key === 'scaleX' || key === 'scaleY' ? 1 : 0
|
|
210
|
+
if (typeof targetValue !== 'string') return value
|
|
211
|
+
|
|
212
|
+
// reanimated derives a string animation's suffix from its start value. keep
|
|
213
|
+
// units on implicit starts so fresh `50%` and `90deg` keys interpolate.
|
|
214
|
+
let suffixIndex = 0
|
|
215
|
+
while (suffixIndex < targetValue.length) {
|
|
216
|
+
const character = targetValue[suffixIndex]
|
|
217
|
+
const code = character.charCodeAt(0)
|
|
218
|
+
if (
|
|
219
|
+
(code >= 48 && code <= 57) ||
|
|
220
|
+
character === '.' ||
|
|
221
|
+
character === '-' ||
|
|
222
|
+
character === '+'
|
|
223
|
+
) {
|
|
224
|
+
suffixIndex++
|
|
225
|
+
} else {
|
|
226
|
+
break
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
return suffixIndex > 0 ? `${value}${targetValue.slice(suffixIndex)}` : value
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const COLOR_STYLE_KEYS: Record<string, boolean> = {
|
|
233
|
+
backgroundColor: true,
|
|
234
|
+
borderColor: true,
|
|
235
|
+
borderLeftColor: true,
|
|
236
|
+
borderRightColor: true,
|
|
237
|
+
borderTopColor: true,
|
|
238
|
+
borderBottomColor: true,
|
|
239
|
+
color: true,
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
// reanimated can pass either its rgba output or a processed numeric color back
|
|
243
|
+
// as animation history. snapshot targets are normalized on the JS side below;
|
|
244
|
+
// this worklet conversion exists only to validate that in-flight history.
|
|
245
|
+
const toReanimatedColor = (value: unknown): unknown => {
|
|
246
|
+
'worklet'
|
|
247
|
+
if (typeof value === 'number') {
|
|
248
|
+
const color = value >>> 0
|
|
249
|
+
const alpha = ((color >>> 24) & 255) / 255
|
|
250
|
+
const red = (color >>> 16) & 255
|
|
251
|
+
const green = (color >>> 8) & 255
|
|
252
|
+
const blue = color & 255
|
|
253
|
+
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
|
254
|
+
}
|
|
255
|
+
if (typeof value !== 'string') return value
|
|
256
|
+
const trimmed = value.trim()
|
|
257
|
+
if (trimmed === 'transparent') return 'rgba(0, 0, 0, 0)'
|
|
258
|
+
return trimmed
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
const isReanimatedColorHistory = (value: unknown): boolean => {
|
|
262
|
+
'worklet'
|
|
263
|
+
const normalized = toReanimatedColor(value)
|
|
264
|
+
return typeof normalized === 'string' && normalized.startsWith('rgba(')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
const normalizeAnimationColor = (value: unknown): string | undefined => {
|
|
268
|
+
if (typeof value === 'number') {
|
|
269
|
+
return toReanimatedColor(value) as string
|
|
270
|
+
}
|
|
271
|
+
if (typeof value !== 'string') return
|
|
272
|
+
|
|
273
|
+
const color = normalizeColor(value)
|
|
274
|
+
if (color == null) return
|
|
275
|
+
const red = Math.round((color & 0xff000000) >>> 24)
|
|
276
|
+
const green = Math.round((color & 0x00ff0000) >>> 16)
|
|
277
|
+
const blue = Math.round((color & 0x0000ff00) >>> 8)
|
|
278
|
+
const alpha = (color & 0x000000ff) / 255
|
|
279
|
+
return `rgba(${red}, ${green}, ${blue}, ${alpha})`
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const buildSnapshot = (
|
|
283
|
+
animated: Record<string, unknown>,
|
|
284
|
+
statics: Record<string, unknown>,
|
|
285
|
+
transforms: Array<Record<string, unknown>>,
|
|
286
|
+
previousKeys: Set<string>,
|
|
287
|
+
lastPainted: Record<string, unknown>
|
|
288
|
+
) => {
|
|
289
|
+
const snapshotAnimated: Record<string, unknown> = {}
|
|
290
|
+
const snapshotStatics = cloneStyleRecord(statics)
|
|
291
|
+
|
|
292
|
+
for (const key in snapshotStatics) {
|
|
293
|
+
if (!COLOR_STYLE_KEYS[key]) continue
|
|
294
|
+
const normalized = normalizeAnimationColor(snapshotStatics[key])
|
|
295
|
+
if (normalized !== undefined) snapshotStatics[key] = normalized
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
for (const key in animated) {
|
|
299
|
+
if (key === 'transform') continue
|
|
300
|
+
const value = animated[key]
|
|
301
|
+
if (COLOR_STYLE_KEYS[key]) {
|
|
302
|
+
const normalized = normalizeAnimationColor(value)
|
|
303
|
+
if (normalized === undefined) {
|
|
304
|
+
// invalid colors must paint plainly. creating a descriptor makes
|
|
305
|
+
// reanimated's string interpolator emit '<letters>NaN'.
|
|
306
|
+
snapshotStatics[key] = cloneAnimationValue(value)
|
|
307
|
+
continue
|
|
308
|
+
}
|
|
309
|
+
snapshotAnimated[key] = normalized
|
|
310
|
+
} else {
|
|
311
|
+
snapshotAnimated[key] = cloneAnimationValue(value)
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const snapshotTransforms = transforms.map(cloneStyleRecord)
|
|
316
|
+
const keys = new Set(Object.keys(snapshotAnimated))
|
|
317
|
+
for (const transform of snapshotTransforms) {
|
|
318
|
+
const key = Object.keys(transform)[0]
|
|
319
|
+
const value = key ? transform[key] : undefined
|
|
320
|
+
if (key && (typeof value === 'number' || typeof value === 'string')) {
|
|
321
|
+
keys.add(`transform:${key}`)
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const seeds: Record<string, unknown> = {}
|
|
326
|
+
for (const key of keys) {
|
|
327
|
+
let value = lastPainted[key]
|
|
328
|
+
if (typeof value !== 'number' && typeof value !== 'string') continue
|
|
329
|
+
if (value === 'auto' || (typeof value === 'string' && value.startsWith('calc'))) {
|
|
330
|
+
continue
|
|
331
|
+
}
|
|
332
|
+
if (COLOR_STYLE_KEYS[key]) value = normalizeAnimationColor(value)
|
|
333
|
+
if (value !== undefined) seeds[key] = value
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
const gatedKeys: Record<string, boolean> = {}
|
|
337
|
+
for (const key of keys) gatedKeys[key] = true
|
|
338
|
+
|
|
339
|
+
const removedKeys = getRemovedAnimatedKeys(keys, previousKeys)
|
|
340
|
+
const painted: Record<string, unknown> = {
|
|
341
|
+
...snapshotStatics,
|
|
342
|
+
...snapshotAnimated,
|
|
343
|
+
}
|
|
344
|
+
const staticTransforms = getAnimatedTransforms(snapshotStatics.transform)
|
|
345
|
+
delete painted.transform
|
|
346
|
+
for (const transform of staticTransforms) {
|
|
347
|
+
const key = Object.keys(transform)[0]
|
|
348
|
+
if (key) painted[`transform:${key}`] = transform[key]
|
|
349
|
+
}
|
|
350
|
+
for (const transform of snapshotTransforms) {
|
|
351
|
+
const key = Object.keys(transform)[0]
|
|
352
|
+
if (key) painted[`transform:${key}`] = transform[key]
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
return {
|
|
356
|
+
value: {
|
|
357
|
+
animated: snapshotAnimated,
|
|
358
|
+
statics: snapshotStatics,
|
|
359
|
+
transforms: snapshotTransforms,
|
|
360
|
+
gatedKeys,
|
|
361
|
+
seeds,
|
|
362
|
+
removedKeys,
|
|
363
|
+
removeTransform: Object.keys(removedKeys).some((key) =>
|
|
364
|
+
key.startsWith('transform:')
|
|
365
|
+
),
|
|
366
|
+
clearValue: isWeb ? '' : null,
|
|
367
|
+
},
|
|
368
|
+
painted,
|
|
369
|
+
keys,
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
const getGatedKeys = (snapshot: AnimationSnapshot): string[] =>
|
|
374
|
+
Object.keys(snapshot.gatedKeys)
|
|
375
|
+
|
|
376
|
+
const createReanimatedConfig = (config: TransitionConfig): Record<string, unknown> => {
|
|
377
|
+
'worklet'
|
|
378
|
+
const next: Record<string, unknown> = {}
|
|
379
|
+
const source = config as Record<string, unknown>
|
|
380
|
+
|
|
381
|
+
for (const key in source) {
|
|
382
|
+
if (key === 'type' || key === 'delay') continue
|
|
383
|
+
const value = source[key]
|
|
384
|
+
if (value !== undefined) {
|
|
385
|
+
next[key] = value
|
|
386
|
+
}
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
return next
|
|
390
|
+
}
|
|
391
|
+
|
|
98
392
|
/**
|
|
99
393
|
* Apply animation to a value based on config, with optional completion callback
|
|
100
394
|
*/
|
|
101
395
|
const applyAnimation = (
|
|
102
396
|
targetValue: number | string,
|
|
103
397
|
config: TransitionConfig,
|
|
104
|
-
callback?: AnimationCallback
|
|
398
|
+
callback?: AnimationCallback,
|
|
399
|
+
seedValue?: number | string,
|
|
400
|
+
validateStartAsColor = false
|
|
105
401
|
): number | string => {
|
|
106
402
|
'worklet'
|
|
107
403
|
const delay = config.delay
|
|
404
|
+
const reanimatedConfig = createReanimatedConfig(config)
|
|
108
405
|
|
|
109
406
|
let animatedValue: any
|
|
110
407
|
if (config.type === 'timing') {
|
|
111
408
|
animatedValue = withTiming(
|
|
112
409
|
targetValue as number,
|
|
113
|
-
|
|
410
|
+
reanimatedConfig as WithTimingConfig,
|
|
114
411
|
callback
|
|
115
412
|
)
|
|
116
413
|
} else {
|
|
117
414
|
animatedValue = withSpring(
|
|
118
415
|
targetValue as number,
|
|
119
|
-
|
|
416
|
+
reanimatedConfig as WithSpringConfig,
|
|
120
417
|
callback
|
|
121
418
|
)
|
|
122
419
|
}
|
|
123
420
|
|
|
421
|
+
// reanimated starts a descriptor from its per-view history for the key — the
|
|
422
|
+
// key's last output value, or the descriptor's own toValue when there is
|
|
423
|
+
// none (an instant snap). both are wrong for a key that turns animated with
|
|
424
|
+
// a painted predecessor: history may hold a stale clear-value ('') from when
|
|
425
|
+
// the key was last removed, which poisons the start value (NaN frames). the
|
|
426
|
+
// wrapper substitutes the seed for whatever start value reanimated passes.
|
|
427
|
+
// color history gets the same treatment only when reanimated's own parser
|
|
428
|
+
// rejects it, preserving valid in-flight values during interruption.
|
|
429
|
+
if (seedValue !== undefined || validateStartAsColor) {
|
|
430
|
+
const innerOnStart = animatedValue.onStart
|
|
431
|
+
animatedValue.onStart = (
|
|
432
|
+
animation: unknown,
|
|
433
|
+
value: unknown,
|
|
434
|
+
timestamp: number,
|
|
435
|
+
previousAnimation: unknown
|
|
436
|
+
) => {
|
|
437
|
+
'worklet'
|
|
438
|
+
const startValue = validateStartAsColor
|
|
439
|
+
? isReanimatedColorHistory(value)
|
|
440
|
+
? toReanimatedColor(value)
|
|
441
|
+
: (seedValue ?? targetValue)
|
|
442
|
+
: seedValue
|
|
443
|
+
innerOnStart(animation, startValue, timestamp, previousAnimation)
|
|
444
|
+
}
|
|
445
|
+
}
|
|
446
|
+
|
|
124
447
|
if (delay && delay > 0) {
|
|
125
448
|
animatedValue = withDelay(delay, animatedValue)
|
|
126
449
|
}
|
|
@@ -128,6 +451,53 @@ const applyAnimation = (
|
|
|
128
451
|
return animatedValue
|
|
129
452
|
}
|
|
130
453
|
|
|
454
|
+
const animateSnapshotValue = (
|
|
455
|
+
key: string,
|
|
456
|
+
implicitKey: string,
|
|
457
|
+
targetValue: number | string,
|
|
458
|
+
config: TransitionConfig,
|
|
459
|
+
previouslyEmitted: boolean,
|
|
460
|
+
seedValue: unknown,
|
|
461
|
+
gated: boolean,
|
|
462
|
+
currentlyExiting: boolean,
|
|
463
|
+
exitCycleId: number,
|
|
464
|
+
currentlyCompletingAnimation: boolean,
|
|
465
|
+
didAnimateCycleId: number,
|
|
466
|
+
markExitKeyDone: (key: string, cycleId: number, finished: boolean) => void,
|
|
467
|
+
markDidAnimateKeyDone: (key: string, cycleId: number, finished: boolean) => void,
|
|
468
|
+
validateStartAsColor = false
|
|
469
|
+
): number | string => {
|
|
470
|
+
'worklet'
|
|
471
|
+
|
|
472
|
+
const cycleGated = gated && (currentlyExiting || currentlyCompletingAnimation)
|
|
473
|
+
if (!previouslyEmitted && seedValue === undefined && !cycleGated) {
|
|
474
|
+
return targetValue
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
let callback: AnimationCallback | undefined
|
|
478
|
+
if (gated && currentlyExiting) {
|
|
479
|
+
callback = (finished) => {
|
|
480
|
+
'worklet'
|
|
481
|
+
runOnJS(markExitKeyDone)(key, exitCycleId, finished ?? false)
|
|
482
|
+
}
|
|
483
|
+
} else if (gated && currentlyCompletingAnimation) {
|
|
484
|
+
callback = (finished) => {
|
|
485
|
+
'worklet'
|
|
486
|
+
runOnJS(markDidAnimateKeyDone)(key, didAnimateCycleId, finished ?? false)
|
|
487
|
+
}
|
|
488
|
+
}
|
|
489
|
+
|
|
490
|
+
return applyAnimation(
|
|
491
|
+
targetValue,
|
|
492
|
+
config,
|
|
493
|
+
callback,
|
|
494
|
+
previouslyEmitted
|
|
495
|
+
? undefined
|
|
496
|
+
: ((seedValue ?? getImplicitDefault(implicitKey, targetValue)) as number | string),
|
|
497
|
+
validateStartAsColor
|
|
498
|
+
)
|
|
499
|
+
}
|
|
500
|
+
|
|
131
501
|
// =============================================================================
|
|
132
502
|
// Animatable Properties
|
|
133
503
|
// =============================================================================
|
|
@@ -215,6 +585,29 @@ const canAnimateProperty = (
|
|
|
215
585
|
return true
|
|
216
586
|
}
|
|
217
587
|
|
|
588
|
+
const splitAnimationStyles = (
|
|
589
|
+
style: Record<string, unknown>,
|
|
590
|
+
isDark: boolean,
|
|
591
|
+
disableAnimation: boolean,
|
|
592
|
+
animateOnly?: string[]
|
|
593
|
+
) => {
|
|
594
|
+
const animated: Record<string, unknown> = {}
|
|
595
|
+
const statics: Record<string, unknown> = {}
|
|
596
|
+
|
|
597
|
+
for (const key in style) {
|
|
598
|
+
const value = resolveDynamicValue(style[key], isDark)
|
|
599
|
+
if (value === undefined) continue
|
|
600
|
+
|
|
601
|
+
if (!disableAnimation && canAnimateProperty(key, value, animateOnly)) {
|
|
602
|
+
animated[key] = cloneAnimationValue(value)
|
|
603
|
+
} else {
|
|
604
|
+
statics[key] = cloneAnimationValue(value)
|
|
605
|
+
}
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
return { animated, statics }
|
|
609
|
+
}
|
|
610
|
+
|
|
218
611
|
// =============================================================================
|
|
219
612
|
// Animated Components (Web)
|
|
220
613
|
// =============================================================================
|
|
@@ -290,17 +683,19 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
|
|
|
290
683
|
const normalized = normalizeTransition(transition)
|
|
291
684
|
const effectiveKey = getEffectiveAnimation(normalized, animationState)
|
|
292
685
|
|
|
293
|
-
let base =
|
|
294
|
-
|
|
295
|
-
(
|
|
296
|
-
|
|
686
|
+
let base = cloneTransitionConfig(
|
|
687
|
+
effectiveKey
|
|
688
|
+
? (animations[effectiveKey as keyof typeof animations] ??
|
|
689
|
+
({ type: 'spring' } as TransitionConfig))
|
|
690
|
+
: ({ type: 'spring' } as TransitionConfig)
|
|
691
|
+
)
|
|
297
692
|
|
|
298
693
|
if (normalized.delay) {
|
|
299
|
-
base = { ...base, delay: normalized.delay }
|
|
694
|
+
base = cloneTransitionConfig({ ...base, delay: normalized.delay })
|
|
300
695
|
}
|
|
301
696
|
|
|
302
697
|
if (normalized.config) {
|
|
303
|
-
base = { ...base, ...normalized.config }
|
|
698
|
+
base = cloneTransitionConfig({ ...base, ...normalized.config })
|
|
304
699
|
// infer type: 'timing' if duration is provided without spring params
|
|
305
700
|
if (
|
|
306
701
|
base.type !== 'timing' &&
|
|
@@ -309,7 +704,7 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
|
|
|
309
704
|
normalized.config.stiffness === undefined &&
|
|
310
705
|
normalized.config.mass === undefined
|
|
311
706
|
) {
|
|
312
|
-
base = { ...base, type: 'timing' }
|
|
707
|
+
base = cloneTransitionConfig({ ...base, type: 'timing' })
|
|
313
708
|
}
|
|
314
709
|
}
|
|
315
710
|
|
|
@@ -319,18 +714,20 @@ function buildTransitionConfig<A extends Record<string, TransitionConfig>>(
|
|
|
319
714
|
for (const key of styleKeys) {
|
|
320
715
|
const propAnimation = normalized.properties[key]
|
|
321
716
|
if (typeof propAnimation === 'string') {
|
|
322
|
-
propertyConfigs[key] =
|
|
717
|
+
propertyConfigs[key] = cloneTransitionConfig(
|
|
718
|
+
animations[propAnimation as keyof typeof animations] ?? base
|
|
719
|
+
)
|
|
323
720
|
} else if (propAnimation && typeof propAnimation === 'object') {
|
|
324
721
|
const configType = (propAnimation as any).type
|
|
325
722
|
const baseForProp = configType
|
|
326
723
|
? (animations[configType as keyof typeof animations] ?? base)
|
|
327
724
|
: base
|
|
328
|
-
propertyConfigs[key] = {
|
|
725
|
+
propertyConfigs[key] = cloneTransitionConfig({
|
|
329
726
|
...baseForProp,
|
|
330
727
|
...propAnimation,
|
|
331
|
-
} as TransitionConfig
|
|
728
|
+
} as TransitionConfig)
|
|
332
729
|
} else {
|
|
333
|
-
propertyConfigs[key] = base
|
|
730
|
+
propertyConfigs[key] = cloneTransitionConfig(base)
|
|
334
731
|
}
|
|
335
732
|
}
|
|
336
733
|
|
|
@@ -383,10 +780,10 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
383
780
|
// This matches behavior of moti and motion drivers
|
|
384
781
|
const animations = {} as A
|
|
385
782
|
for (const key in animationsConfig) {
|
|
386
|
-
animations[key] = {
|
|
783
|
+
animations[key] = cloneTransitionConfig({
|
|
387
784
|
type: 'spring',
|
|
388
785
|
...animationsConfig[key],
|
|
389
|
-
} as A[typeof key]
|
|
786
|
+
}) as A[typeof key]
|
|
390
787
|
}
|
|
391
788
|
|
|
392
789
|
return {
|
|
@@ -424,30 +821,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
424
821
|
sharedValue.value = next
|
|
425
822
|
onFinish?.()
|
|
426
823
|
} else {
|
|
427
|
-
// animations must start on the
|
|
824
|
+
// animations must start on the ui thread in reanimated 4.x -
|
|
428
825
|
// withSpring/withTiming return animation descriptors that only
|
|
429
826
|
// work when assigned to a SharedValue inside the UI runtime
|
|
430
827
|
const cb = onFinish
|
|
431
|
-
? () => {
|
|
828
|
+
? (finished?: boolean) => {
|
|
432
829
|
'worklet'
|
|
433
|
-
|
|
830
|
+
if (finished !== false) {
|
|
831
|
+
runOnJS(onFinish)()
|
|
832
|
+
}
|
|
434
833
|
}
|
|
435
834
|
: undefined
|
|
436
835
|
|
|
836
|
+
const animationConfig = cloneTransitionConfig(config)
|
|
837
|
+
|
|
437
838
|
if (isWeb) {
|
|
438
|
-
// on web there's no UI thread
|
|
439
|
-
sharedValue.value =
|
|
440
|
-
config.type === 'spring'
|
|
441
|
-
? withSpring(next, config as WithSpringConfig, cb)
|
|
442
|
-
: withTiming(next, config as WithTimingConfig, cb)
|
|
839
|
+
// on web there's no UI thread - set directly
|
|
840
|
+
sharedValue.value = applyAnimation(next, animationConfig, cb)
|
|
443
841
|
} else {
|
|
444
|
-
runOnUI(() => {
|
|
842
|
+
runOnUI((targetValue: number, animationConfig: TransitionConfig) => {
|
|
445
843
|
'worklet'
|
|
446
|
-
sharedValue.value =
|
|
447
|
-
|
|
448
|
-
? withSpring(next, config as WithSpringConfig, cb)
|
|
449
|
-
: withTiming(next, config as WithTimingConfig, cb)
|
|
450
|
-
})()
|
|
844
|
+
sharedValue.value = applyAnimation(targetValue, animationConfig, cb)
|
|
845
|
+
})(next, animationConfig)
|
|
451
846
|
}
|
|
452
847
|
}
|
|
453
848
|
},
|
|
@@ -491,27 +886,25 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
491
886
|
}, [instance, getStyle])
|
|
492
887
|
}
|
|
493
888
|
|
|
494
|
-
// native: use useDerivedValue to compute the style on the UI thread.
|
|
495
|
-
// this avoids reading instance.value during render (which triggers
|
|
496
|
-
// reanimated's "Reading from `value` during component render" warning)
|
|
497
|
-
// while keeping the indirection that gives useAnimatedStyle a pure
|
|
498
|
-
// SharedValue closure — no non-SV items to confuse startMapper's
|
|
499
|
-
// dependency tracking.
|
|
500
889
|
const styleVal = useDerivedValue(() => {
|
|
501
890
|
'worklet'
|
|
502
891
|
return getStyle(instance.value)
|
|
503
892
|
})
|
|
504
893
|
|
|
505
|
-
|
|
894
|
+
const animatedStyle = useAnimatedStyle(() => {
|
|
506
895
|
'worklet'
|
|
507
896
|
return styleVal.value
|
|
508
897
|
})
|
|
898
|
+
|
|
899
|
+
silenceAnimatedComponentDevCheck(animatedStyle)
|
|
900
|
+
|
|
901
|
+
return animatedStyle
|
|
509
902
|
},
|
|
510
903
|
|
|
511
904
|
useAnimatedNumbersStyle(vals, getStyle) {
|
|
512
905
|
const instances = vals.map((v) => v.getInstance())
|
|
513
906
|
|
|
514
|
-
|
|
907
|
+
const animatedStyle = useAnimatedStyle(
|
|
515
908
|
() => {
|
|
516
909
|
'worklet'
|
|
517
910
|
const currentValues = instances.map((inst) => inst.value)
|
|
@@ -519,6 +912,10 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
519
912
|
},
|
|
520
913
|
isWeb ? [getStyle, ...instances] : undefined
|
|
521
914
|
)
|
|
915
|
+
|
|
916
|
+
silenceAnimatedComponentDevCheck(animatedStyle)
|
|
917
|
+
|
|
918
|
+
return animatedStyle
|
|
522
919
|
},
|
|
523
920
|
|
|
524
921
|
// =========================================================================
|
|
@@ -534,6 +931,7 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
534
931
|
themeName,
|
|
535
932
|
stateRef,
|
|
536
933
|
styleState,
|
|
934
|
+
onDidAnimate,
|
|
537
935
|
} = animationProps
|
|
538
936
|
|
|
539
937
|
// State flags
|
|
@@ -569,6 +967,28 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
569
967
|
|
|
570
968
|
// Get sendExitComplete callback from presence
|
|
571
969
|
const sendExitComplete = presence?.[1]
|
|
970
|
+
const onDidAnimateRef = useRef(onDidAnimate)
|
|
971
|
+
useIsomorphicLayoutEffect(() => {
|
|
972
|
+
onDidAnimateRef.current = onDidAnimate
|
|
973
|
+
}, [onDidAnimate])
|
|
974
|
+
|
|
975
|
+
const didAnimateCycleIdRef = useRef(0)
|
|
976
|
+
const pendingDidAnimateKeysRef = useRef<Set<string>>(new Set())
|
|
977
|
+
const didAnimateCompletedRef = useRef(false)
|
|
978
|
+
const isCompletingAnimationRef = useSharedValue(false)
|
|
979
|
+
const didAnimateCycleIdShared = useSharedValue(0)
|
|
980
|
+
const markDidAnimateKeyDone = useEvent(
|
|
981
|
+
(key: string, cycleId: number, finished: boolean) => {
|
|
982
|
+
if (!finished || cycleId !== didAnimateCycleIdRef.current) return
|
|
983
|
+
if (didAnimateCompletedRef.current) return
|
|
984
|
+
pendingDidAnimateKeysRef.current.delete(key)
|
|
985
|
+
if (pendingDidAnimateKeysRef.current.size === 0) {
|
|
986
|
+
didAnimateCompletedRef.current = true
|
|
987
|
+
isCompletingAnimationRef.value = false
|
|
988
|
+
onDidAnimateRef.current?.()
|
|
989
|
+
}
|
|
990
|
+
}
|
|
991
|
+
)
|
|
572
992
|
|
|
573
993
|
// =========================================================================
|
|
574
994
|
// Exit cycle state for deterministic per-property completion tracking
|
|
@@ -611,6 +1031,9 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
611
1031
|
exitCycleIdRef.current++
|
|
612
1032
|
exitCompletedRef.current = false
|
|
613
1033
|
pendingExitKeysRef.current.clear()
|
|
1034
|
+
didAnimateCycleIdRef.current++
|
|
1035
|
+
pendingDidAnimateKeysRef.current.clear()
|
|
1036
|
+
didAnimateCompletedRef.current = true
|
|
614
1037
|
}
|
|
615
1038
|
// invalidate pending callbacks when exit is canceled/interrupted
|
|
616
1039
|
if (justStoppedExiting) {
|
|
@@ -622,7 +1045,11 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
622
1045
|
useIsomorphicLayoutEffect(() => {
|
|
623
1046
|
isExitingRef.value = isExiting
|
|
624
1047
|
exitCycleIdShared.value = exitCycleIdRef.current
|
|
625
|
-
|
|
1048
|
+
if (justStartedExiting) {
|
|
1049
|
+
isCompletingAnimationRef.value = false
|
|
1050
|
+
didAnimateCycleIdShared.value = didAnimateCycleIdRef.current
|
|
1051
|
+
}
|
|
1052
|
+
}, [isExiting, exitCycleIdRef.current, justStartedExiting])
|
|
626
1053
|
|
|
627
1054
|
// track previous exiting state
|
|
628
1055
|
React.useEffect(() => {
|
|
@@ -632,45 +1059,146 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
632
1059
|
// =========================================================================
|
|
633
1060
|
// avoidRerenders: SharedValues for style updates without re-renders
|
|
634
1061
|
// =========================================================================
|
|
635
|
-
const
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
)
|
|
1062
|
+
const committedRenderKeysRef = useRef(new Set<string>())
|
|
1063
|
+
// full painted style of the last committed source (render or emitter):
|
|
1064
|
+
// statics + animated targets, transform sub-keys flattened as `transform:key`.
|
|
1065
|
+
// seeds read from it.
|
|
1066
|
+
const lastPaintedRef = useRef<Record<string, unknown>>({})
|
|
1067
|
+
// per-key first-animated values React keeps painting under the mapper —
|
|
1068
|
+
// see the styles memo below
|
|
1069
|
+
const carryRef = useRef<Record<string, unknown>>({})
|
|
640
1070
|
|
|
641
1071
|
// Separate styles into animated and static
|
|
642
|
-
const { animatedStyles, staticStyles } = useMemo(() => {
|
|
643
|
-
const animated: Record<string, unknown> = {}
|
|
644
|
-
const staticStyles: Record<string, unknown> = {}
|
|
1072
|
+
const { animatedStyles, staticStyles, nextCarry } = useMemo(() => {
|
|
645
1073
|
const animateOnly = props.animateOnly as string[] | undefined
|
|
1074
|
+
const { animated, statics } = splitAnimationStyles(
|
|
1075
|
+
style as Record<string, unknown>,
|
|
1076
|
+
isDark,
|
|
1077
|
+
disableAnimation,
|
|
1078
|
+
animateOnly
|
|
1079
|
+
)
|
|
646
1080
|
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
1081
|
+
// every animated key keeps its FIRST animated value in React's style for
|
|
1082
|
+
// as long as it stays animated. the value never changes across renders,
|
|
1083
|
+
// so React's per-key style diff never touches the key again and the
|
|
1084
|
+
// mapper's inline writes survive every commit — reanimated's Fabric
|
|
1085
|
+
// commit hook provides this guarantee on native, web has no equivalent.
|
|
1086
|
+
// during enter the carry is the enterStyle (the mapper animates over it);
|
|
1087
|
+
// a key appearing post-mount (a just-measured height) paints its value in
|
|
1088
|
+
// the same commit the mapper first sees it, a frame before the mapper's
|
|
1089
|
+
// first write.
|
|
1090
|
+
// derive from committed state. mutating carryRef during render leaks
|
|
1091
|
+
// abandoned concurrent renders into later commits.
|
|
1092
|
+
const nextCarry = cloneStyleRecord(carryRef.current)
|
|
1093
|
+
for (const key in nextCarry) {
|
|
1094
|
+
if (!(key in animated)) delete nextCarry[key]
|
|
1095
|
+
}
|
|
1096
|
+
for (const key in animated) {
|
|
1097
|
+
if (!(key in nextCarry)) {
|
|
1098
|
+
nextCarry[key] = cloneAnimationValue(animated[key])
|
|
662
1099
|
}
|
|
1100
|
+
statics[key] = nextCarry[key]
|
|
663
1101
|
}
|
|
664
1102
|
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
1103
|
+
return { animatedStyles: animated, staticStyles: statics, nextCarry }
|
|
1104
|
+
}, [disableAnimation, style, isDark, props.animateOnly])
|
|
1105
|
+
|
|
1106
|
+
const renderSnapshot = useMemo(
|
|
1107
|
+
() =>
|
|
1108
|
+
buildSnapshot(
|
|
1109
|
+
animatedStyles,
|
|
1110
|
+
staticStyles,
|
|
1111
|
+
getAnimatedTransforms(animatedStyles.transform),
|
|
1112
|
+
committedRenderKeysRef.current,
|
|
1113
|
+
lastPaintedRef.current
|
|
1114
|
+
),
|
|
1115
|
+
[animatedStyles, staticStyles]
|
|
1116
|
+
)
|
|
1117
|
+
const renderSnapshotRef = useSharedValue<AnimationSnapshot>({
|
|
1118
|
+
animated: {},
|
|
1119
|
+
statics: {},
|
|
1120
|
+
transforms: [],
|
|
1121
|
+
gatedKeys: {},
|
|
1122
|
+
seeds: {},
|
|
1123
|
+
removedKeys: {},
|
|
1124
|
+
removeTransform: false,
|
|
1125
|
+
clearValue: isWeb ? '' : null,
|
|
1126
|
+
})
|
|
1127
|
+
const emitterSnapshotRef = useSharedValue<AnimationSnapshot | null>(null)
|
|
1128
|
+
const emitterKeysRef = useRef<Set<string> | null>(null)
|
|
1129
|
+
|
|
1130
|
+
useIsomorphicLayoutEffect(() => {
|
|
1131
|
+
carryRef.current = nextCarry
|
|
1132
|
+
committedRenderKeysRef.current = renderSnapshot.keys
|
|
1133
|
+
lastPaintedRef.current = renderSnapshot.painted
|
|
1134
|
+
}, [nextCarry, renderSnapshot])
|
|
1135
|
+
|
|
1136
|
+
// reconcile the emitter latch with real re-renders.
|
|
1137
|
+
//
|
|
1138
|
+
// the avoidReRenders fast path lets a pure pseudo change (hover/press/focus) push
|
|
1139
|
+
// styles straight to the worklet via useStyleEmitter with no React commit — it sets
|
|
1140
|
+
// emitterSnapshotRef. the worklet then treats `emitterSnapshotRef !== null` as a
|
|
1141
|
+
// permanent latch: once the emitter has fired even once, it reads its last-emitted
|
|
1142
|
+
// snapshot and IGNORES `animatedStyles` from every subsequent re-render. so a base
|
|
1143
|
+
// style that changes through React (e.g. a row's `backgroundColor` flipping with an
|
|
1144
|
+
// external selection store, not a hover) never reaches the screen — it stays stuck on
|
|
1145
|
+
// the stale emitted value until the next hover re-fires the emitter. (the "active row
|
|
1146
|
+
// highlight gets stuck until you hover it again" report.)
|
|
1147
|
+
//
|
|
1148
|
+
// a real re-render is the freshest source of truth for the base style, so on render we
|
|
1149
|
+
// drop the emitter latch and let the worklet fall back to `animatedStyles`. the one
|
|
1150
|
+
// exception: while a self pseudo (hover/press/focus) is active the emitted style is a
|
|
1151
|
+
// transient override that isn't in this render, so keep it latched — otherwise an
|
|
1152
|
+
// unrelated re-render while hovering would snap back to the base. `pseudoActiveRef`
|
|
1153
|
+
// tracks that flag straight off the emitter (which fires on every pseudo transition), so
|
|
1154
|
+
// the un-hover/un-press emission flips it false and the next render reclaims the style.
|
|
1155
|
+
// no value comparison needed; runtime media stays correct because a render re-reads live
|
|
1156
|
+
// media into `animatedStyles`.
|
|
1157
|
+
// exit overrides the pseudo exception: the exit render's style IS the
|
|
1158
|
+
// authoritative target, and an element that exits while hovered/focused (every
|
|
1159
|
+
// dialog closed by clicking a button inside it) would otherwise keep the stale
|
|
1160
|
+
// emitted base latched — the worklet then "animates" to values already on
|
|
1161
|
+
// screen, every exit-key callback completes in a frame, and AnimatePresence
|
|
1162
|
+
// unmounts immediately (the dialog-exit-snap). mirrored into a plain ref so the
|
|
1163
|
+
// emitter callback (which runs outside render) can read it.
|
|
1164
|
+
const pseudoActiveRef = useRef(false)
|
|
1165
|
+
const isExitingJSRef = useRef(false)
|
|
1166
|
+
isExitingJSRef.current = isExiting
|
|
1167
|
+
// the committed render is the worklet's source of truth whenever the emitter is not
|
|
1168
|
+
// latched, so every render must publish its snapshot — not just the renders that drop
|
|
1169
|
+
// a latch. animated keys live only in this snapshot after mount (staticStyles carries
|
|
1170
|
+
// them during mount only), so a render that never publishes leaves the worklet reading
|
|
1171
|
+
// an empty snapshot and the animated properties never reach the screen at all.
|
|
1172
|
+
const publishedSnapshotRef = useRef<object | null>(null)
|
|
1173
|
+
useIsomorphicLayoutEffect(() => {
|
|
1174
|
+
const droppingLatch =
|
|
1175
|
+
(isExiting || !pseudoActiveRef.current) && emitterSnapshotRef.value !== null
|
|
1176
|
+
// when the latch drops, keys the emitter owned that this render no longer has must
|
|
1177
|
+
// be cleared too, otherwise reanimated keeps painting the stale emitted value
|
|
1178
|
+
const emitterKeys = droppingLatch ? emitterKeysRef.current : null
|
|
1179
|
+
|
|
1180
|
+
if (droppingLatch || publishedSnapshotRef.current !== renderSnapshot.value) {
|
|
1181
|
+
const removedKeys = emitterKeys
|
|
1182
|
+
? {
|
|
1183
|
+
...renderSnapshot.value.removedKeys,
|
|
1184
|
+
...getRemovedAnimatedKeys(renderSnapshot.keys, emitterKeys),
|
|
1185
|
+
}
|
|
1186
|
+
: renderSnapshot.value.removedKeys
|
|
1187
|
+
renderSnapshotRef.value = {
|
|
1188
|
+
...renderSnapshot.value,
|
|
1189
|
+
removedKeys,
|
|
1190
|
+
removeTransform: Object.keys(removedKeys).some((key) =>
|
|
1191
|
+
key.startsWith('transform:')
|
|
1192
|
+
),
|
|
669
1193
|
}
|
|
1194
|
+
publishedSnapshotRef.current = renderSnapshot.value
|
|
670
1195
|
}
|
|
671
1196
|
|
|
672
|
-
|
|
673
|
-
|
|
1197
|
+
if (droppingLatch) {
|
|
1198
|
+
emitterSnapshotRef.value = null
|
|
1199
|
+
emitterKeysRef.current = null
|
|
1200
|
+
}
|
|
1201
|
+
})
|
|
674
1202
|
|
|
675
1203
|
// Build animation config with per-property overrides using normalized transition
|
|
676
1204
|
const { baseConfig, propertyConfigs } = useMemo(() => {
|
|
@@ -681,13 +1209,18 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
681
1209
|
}
|
|
682
1210
|
}
|
|
683
1211
|
|
|
1212
|
+
// use effectiveTransition (createComponent's single source of truth) so a
|
|
1213
|
+
// platform-pseudo component with no declared transition resolves its BASE
|
|
1214
|
+
// config to instant ('0ms') too — not just its pseudo flips. otherwise base
|
|
1215
|
+
// style changes on the driver path animate with the default spring and a
|
|
1216
|
+
// hover-out / base color change visibly lingers (looks "stuck").
|
|
684
1217
|
return buildTransitionConfig(
|
|
685
|
-
|
|
1218
|
+
effectiveTransition,
|
|
686
1219
|
animations,
|
|
687
1220
|
animationState,
|
|
688
1221
|
getStyleKeys(animatedStyles)
|
|
689
1222
|
)
|
|
690
|
-
}, [isHydrating,
|
|
1223
|
+
}, [isHydrating, effectiveTransition, animatedStyles, animationState])
|
|
691
1224
|
|
|
692
1225
|
// Store config in SharedValue for worklet access (concurrent-safe)
|
|
693
1226
|
// Using useEffect to avoid writing to shared value during render
|
|
@@ -699,122 +1232,132 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
699
1232
|
})
|
|
700
1233
|
|
|
701
1234
|
useIsomorphicLayoutEffect(() => {
|
|
702
|
-
configRef.
|
|
1235
|
+
configRef.value = {
|
|
1236
|
+
baseConfig,
|
|
1237
|
+
propertyConfigs,
|
|
1238
|
+
disableAnimation,
|
|
1239
|
+
isHydrating,
|
|
1240
|
+
}
|
|
703
1241
|
}, [baseConfig, propertyConfigs, disableAnimation, isHydrating])
|
|
704
1242
|
|
|
705
1243
|
// =========================================================================
|
|
706
|
-
// avoidRerenders:
|
|
707
|
-
//
|
|
1244
|
+
// avoidRerenders: register style emitter callback
|
|
1245
|
+
// when hover/press/etc state changes, this is called instead of re-rendering
|
|
708
1246
|
// =========================================================================
|
|
709
|
-
useStyleEmitter?.(
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
if (value == undefined) continue
|
|
739
|
-
|
|
740
|
-
if (configRef.get().disableAnimation) {
|
|
741
|
-
statics[key] = value
|
|
742
|
-
continue
|
|
743
|
-
}
|
|
744
|
-
|
|
745
|
-
if (key === 'transform' && Array.isArray(value)) {
|
|
746
|
-
for (const t of value as Record<string, unknown>[]) {
|
|
747
|
-
if (t && typeof t === 'object') {
|
|
748
|
-
const tKey = Object.keys(t)[0]
|
|
749
|
-
const tVal = t[tKey]
|
|
750
|
-
if (typeof tVal === 'number' || typeof tVal === 'string') {
|
|
751
|
-
transforms.push(t)
|
|
752
|
-
}
|
|
753
|
-
}
|
|
754
|
-
}
|
|
755
|
-
continue
|
|
1247
|
+
useStyleEmitter?.(
|
|
1248
|
+
(nextStyle: Record<string, unknown>, effectiveTransition, pseudoActive) => {
|
|
1249
|
+
// while exiting, the exit render owns the style — a pseudo flip mid-exit
|
|
1250
|
+
// (hover-out as the element fades under the cursor) must not re-latch the
|
|
1251
|
+
// base style over the in-flight exit targets.
|
|
1252
|
+
if (isExitingJSRef.current) return
|
|
1253
|
+
// track whether a self pseudo is active so the render-time layout effect knows whether
|
|
1254
|
+
// this emitter snapshot is a transient override to keep latched or a base it can drop.
|
|
1255
|
+
pseudoActiveRef.current = pseudoActive === true
|
|
1256
|
+
const animateOnly = props.animateOnly as string[] | undefined
|
|
1257
|
+
|
|
1258
|
+
// effectiveTransition is computed in createComponent based on entering/exiting pseudo states
|
|
1259
|
+
// rebuild config whenever transition changes (entering OR exiting pseudo states)
|
|
1260
|
+
const transitionToUse = effectiveTransition ?? props.transition
|
|
1261
|
+
const { baseConfig: newBase, propertyConfigs: newPropertyConfigs } =
|
|
1262
|
+
buildTransitionConfig(
|
|
1263
|
+
transitionToUse,
|
|
1264
|
+
animations,
|
|
1265
|
+
animationState,
|
|
1266
|
+
getStyleKeys(nextStyle)
|
|
1267
|
+
)
|
|
1268
|
+
|
|
1269
|
+
// update configRef with the new config
|
|
1270
|
+
configRef.value = {
|
|
1271
|
+
baseConfig: newBase,
|
|
1272
|
+
propertyConfigs: newPropertyConfigs,
|
|
1273
|
+
disableAnimation: configRef.value.disableAnimation,
|
|
1274
|
+
isHydrating: configRef.value.isHydrating,
|
|
756
1275
|
}
|
|
757
1276
|
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
animatedTargetsRef.set(animated)
|
|
767
|
-
staticTargetsRef.set(statics)
|
|
768
|
-
transformTargetsRef.set(transforms)
|
|
769
|
-
|
|
770
|
-
if (
|
|
771
|
-
process.env.NODE_ENV === 'development' &&
|
|
772
|
-
props.debug &&
|
|
773
|
-
props.debug !== 'profile'
|
|
774
|
-
) {
|
|
775
|
-
console.info('[animations-reanimated] useStyleEmitter update', {
|
|
1277
|
+
const previousKeys = emitterKeysRef.current ?? committedRenderKeysRef.current
|
|
1278
|
+
const { animated, statics } = splitAnimationStyles(
|
|
1279
|
+
nextStyle,
|
|
1280
|
+
isDark,
|
|
1281
|
+
configRef.value.disableAnimation,
|
|
1282
|
+
animateOnly
|
|
1283
|
+
)
|
|
1284
|
+
const snapshot = buildSnapshot(
|
|
776
1285
|
animated,
|
|
777
1286
|
statics,
|
|
778
|
-
|
|
779
|
-
|
|
1287
|
+
getAnimatedTransforms(animated.transform),
|
|
1288
|
+
previousKeys,
|
|
1289
|
+
lastPaintedRef.current
|
|
1290
|
+
)
|
|
1291
|
+
emitterSnapshotRef.value = snapshot.value
|
|
1292
|
+
emitterKeysRef.current = snapshot.keys
|
|
1293
|
+
lastPaintedRef.current = snapshot.painted
|
|
1294
|
+
|
|
1295
|
+
if (
|
|
1296
|
+
process.env.NODE_ENV === 'development' &&
|
|
1297
|
+
props.debug &&
|
|
1298
|
+
props.debug !== 'profile'
|
|
1299
|
+
) {
|
|
1300
|
+
console.info('[animations-reanimated] useStyleEmitter update', {
|
|
1301
|
+
animated,
|
|
1302
|
+
statics,
|
|
1303
|
+
transforms: snapshot.value.transforms,
|
|
1304
|
+
})
|
|
1305
|
+
}
|
|
780
1306
|
}
|
|
781
|
-
|
|
1307
|
+
)
|
|
782
1308
|
|
|
783
1309
|
// Compute and register exit keys synchronously during render to avoid race conditions
|
|
784
1310
|
// This must happen BEFORE useAnimatedStyle runs so callbacks have a populated set
|
|
785
1311
|
const exitKeysRegistered = useRef(false)
|
|
786
1312
|
if (justStartedExiting && sendExitComplete) {
|
|
787
|
-
const exitKeys
|
|
788
|
-
|
|
1313
|
+
const exitKeys = getGatedKeys(renderSnapshot.value)
|
|
1314
|
+
pendingExitKeysRef.current = new Set(exitKeys)
|
|
1315
|
+
exitKeysRegistered.current = exitKeys.length > 0
|
|
1316
|
+
}
|
|
789
1317
|
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
1318
|
+
// onDidAnimate is an internal completion hook used by components that
|
|
1319
|
+
// need to retain content through an in-place style transition. register
|
|
1320
|
+
// the exact keys that can animate before publishing the cycle to the
|
|
1321
|
+
// worklet; shared values are only written from the layout effect, never
|
|
1322
|
+
// from the mapper that reads them.
|
|
1323
|
+
const previousDidAnimateStyleRef = useRef<string | null>(null)
|
|
1324
|
+
const didAnimateStyle = onDidAnimate ? JSON.stringify(style) : null
|
|
1325
|
+
const previousDidAnimateStyle = previousDidAnimateStyleRef.current
|
|
1326
|
+
const shouldRegisterDidAnimate =
|
|
1327
|
+
didAnimateStyle !== null &&
|
|
1328
|
+
previousDidAnimateStyle !== null &&
|
|
1329
|
+
previousDidAnimateStyle !== didAnimateStyle &&
|
|
1330
|
+
!isExiting &&
|
|
1331
|
+
!isEntering &&
|
|
1332
|
+
!justFinishedEntering
|
|
1333
|
+
const didAnimateKeys = shouldRegisterDidAnimate
|
|
1334
|
+
? getGatedKeys(renderSnapshot.value)
|
|
1335
|
+
: []
|
|
797
1336
|
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
const tKey = Object.keys(t)[0]
|
|
804
|
-
if (tKey) {
|
|
805
|
-
// check animateOnly filter for transform sub-keys
|
|
806
|
-
if (animateOnly && !animateOnly.includes(tKey)) {
|
|
807
|
-
continue
|
|
808
|
-
}
|
|
809
|
-
exitKeys.push(`transform:${tKey}`)
|
|
810
|
-
}
|
|
811
|
-
}
|
|
1337
|
+
useIsomorphicLayoutEffect(() => {
|
|
1338
|
+
if (didAnimateStyle === null) {
|
|
1339
|
+
previousDidAnimateStyleRef.current = null
|
|
1340
|
+
isCompletingAnimationRef.value = false
|
|
1341
|
+
return
|
|
812
1342
|
}
|
|
813
1343
|
|
|
814
|
-
//
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
1344
|
+
// only a committed render may advance the authoritative cycle. the
|
|
1345
|
+
// captured previous value prevents StrictMode's repeated effect from
|
|
1346
|
+
// registering the same transition twice.
|
|
1347
|
+
const previousStyleStillCurrent =
|
|
1348
|
+
previousDidAnimateStyleRef.current === previousDidAnimateStyle
|
|
1349
|
+
previousDidAnimateStyleRef.current = didAnimateStyle
|
|
1350
|
+
if (!shouldRegisterDidAnimate || !previousStyleStillCurrent) return
|
|
1351
|
+
|
|
1352
|
+
const cycleId = ++didAnimateCycleIdRef.current
|
|
1353
|
+
pendingDidAnimateKeysRef.current = new Set(didAnimateKeys)
|
|
1354
|
+
didAnimateCompletedRef.current = didAnimateKeys.length === 0
|
|
1355
|
+
isCompletingAnimationRef.value = didAnimateKeys.length > 0
|
|
1356
|
+
didAnimateCycleIdShared.value = cycleId
|
|
1357
|
+
if (didAnimateKeys.length === 0) {
|
|
1358
|
+
onDidAnimateRef.current?.()
|
|
1359
|
+
}
|
|
1360
|
+
}, [didAnimateStyle, shouldRegisterDidAnimate])
|
|
818
1361
|
|
|
819
1362
|
// handle zero-animation case in effect (after render commit)
|
|
820
1363
|
React.useEffect(() => {
|
|
@@ -829,128 +1372,159 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
829
1372
|
}
|
|
830
1373
|
}, [justStartedExiting, sendExitComplete])
|
|
831
1374
|
|
|
1375
|
+
// the worklet's own record of which animated keys it has emitted (mirrors
|
|
1376
|
+
// reanimated's per-view style history, which resets whenever a key leaves
|
|
1377
|
+
// the output). a key not in here is fresh to the mapper no matter what
|
|
1378
|
+
// React has committed — several renders can coalesce into one mapper run.
|
|
1379
|
+
// native updates use modify with the same object and no forced update, so
|
|
1380
|
+
// the mapper doesn't react to its own history write.
|
|
1381
|
+
const mapperStateRef = useSharedValue<MapperState>({
|
|
1382
|
+
emitted: {},
|
|
1383
|
+
})
|
|
832
1384
|
// Create animated style
|
|
833
1385
|
const animatedStyle = useAnimatedStyle(
|
|
834
1386
|
() => {
|
|
835
1387
|
'worklet'
|
|
836
1388
|
|
|
837
|
-
|
|
1389
|
+
const mapperState = mapperStateRef.value
|
|
1390
|
+
const config = configRef.value
|
|
1391
|
+
if (config.disableAnimation || config.isHydrating) {
|
|
1392
|
+
// the empty return wipes reanimated's per-key history, so ours
|
|
1393
|
+
// resets with it
|
|
1394
|
+
updateMapperState(mapperStateRef, {})
|
|
838
1395
|
return {}
|
|
839
1396
|
}
|
|
840
1397
|
|
|
1398
|
+
const previouslyEmitted = mapperState.emitted
|
|
1399
|
+
const emitted: Record<string, boolean> = {}
|
|
1400
|
+
|
|
841
1401
|
const result: Record<string, any> = {}
|
|
842
|
-
const config = configRef.get()
|
|
843
1402
|
|
|
844
1403
|
// Check if we have avoidRerenders updates from useStyleEmitter
|
|
845
|
-
const
|
|
846
|
-
const
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
//
|
|
851
|
-
|
|
852
|
-
|
|
1404
|
+
const emitterSnapshot = emitterSnapshotRef.value
|
|
1405
|
+
const snapshot = emitterSnapshot ?? renderSnapshotRef.value
|
|
1406
|
+
|
|
1407
|
+
// Use emitter values if available, otherwise use the committed render snapshot.
|
|
1408
|
+
// statics must fall back to the render's staticStyles (not {}): once an
|
|
1409
|
+
// emitter snapshot has applied static keys, reanimated never unsets
|
|
1410
|
+
// them, so after the latch drops a stale emitted value (e.g. a border
|
|
1411
|
+
// color that missed animateOnly) would keep painting over the fresh
|
|
1412
|
+
// style prop forever
|
|
1413
|
+
const animatedValues = snapshot.animated
|
|
1414
|
+
const staticValues = snapshot.statics
|
|
853
1415
|
|
|
854
1416
|
// read exit state from shared values
|
|
855
1417
|
const currentlyExiting = isExitingRef.value
|
|
856
1418
|
const currentCycleId = exitCycleIdShared.value
|
|
1419
|
+
const currentlyCompletingAnimation = isCompletingAnimationRef.value
|
|
1420
|
+
const currentDidAnimateCycleId = didAnimateCycleIdShared.value
|
|
857
1421
|
|
|
858
1422
|
// Include static values from emitter (for hover/press style changes)
|
|
859
1423
|
for (const key in staticValues) {
|
|
860
1424
|
result[key] = staticValues[key]
|
|
861
1425
|
}
|
|
1426
|
+
for (const key in snapshot.removedKeys) {
|
|
1427
|
+
if (!key.startsWith('transform:') && !(key in staticValues)) {
|
|
1428
|
+
result[key] = snapshot.clearValue
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
862
1431
|
|
|
863
1432
|
// Animate regular properties
|
|
864
1433
|
for (const key in animatedValues) {
|
|
865
1434
|
if (key === 'transform') continue
|
|
866
1435
|
|
|
867
1436
|
const targetValue = animatedValues[key]
|
|
868
|
-
|
|
869
|
-
|
|
870
|
-
|
|
871
|
-
|
|
872
|
-
if (currentlyExiting) {
|
|
873
|
-
const capturedKey = key
|
|
874
|
-
const capturedCycleId = currentCycleId
|
|
875
|
-
callback = (finished) => {
|
|
876
|
-
'worklet'
|
|
877
|
-
runOnJS(markExitKeyDone)(capturedKey, capturedCycleId, finished ?? false)
|
|
878
|
-
}
|
|
1437
|
+
emitted[key] = true
|
|
1438
|
+
if (typeof targetValue !== 'number' && typeof targetValue !== 'string') {
|
|
1439
|
+
result[key] = targetValue
|
|
1440
|
+
continue
|
|
879
1441
|
}
|
|
880
|
-
|
|
881
|
-
|
|
1442
|
+
result[key] = animateSnapshotValue(
|
|
1443
|
+
key,
|
|
1444
|
+
key,
|
|
1445
|
+
targetValue,
|
|
1446
|
+
config.propertyConfigs[key] ?? config.baseConfig,
|
|
1447
|
+
!!previouslyEmitted[key],
|
|
1448
|
+
snapshot.seeds[key],
|
|
1449
|
+
!!snapshot.gatedKeys[key],
|
|
1450
|
+
currentlyExiting,
|
|
1451
|
+
currentCycleId,
|
|
1452
|
+
currentlyCompletingAnimation,
|
|
1453
|
+
currentDidAnimateCycleId,
|
|
1454
|
+
markExitKeyDone,
|
|
1455
|
+
markDidAnimateKeyDone,
|
|
1456
|
+
!!COLOR_STYLE_KEYS[key]
|
|
1457
|
+
)
|
|
882
1458
|
}
|
|
883
1459
|
|
|
884
1460
|
// Handle transforms
|
|
885
|
-
const transforms =
|
|
886
|
-
? emitterTransforms
|
|
887
|
-
: animatedStyles.transform
|
|
1461
|
+
const transforms = snapshot.transforms
|
|
888
1462
|
|
|
889
1463
|
// Animate transform properties with validation
|
|
890
|
-
if (transforms
|
|
1464
|
+
if (transforms.length > 0 || snapshot.removeTransform) {
|
|
891
1465
|
const validTransforms: Record<string, unknown>[] = []
|
|
892
1466
|
|
|
893
1467
|
for (const t of transforms) {
|
|
894
1468
|
if (!t) continue
|
|
895
1469
|
const keys = Object.keys(t)
|
|
896
1470
|
if (keys.length === 0) continue
|
|
897
|
-
const
|
|
1471
|
+
const transformKey = keys[0]
|
|
1472
|
+
const value = t[transformKey]
|
|
898
1473
|
if (typeof value === 'number' || typeof value === 'string') {
|
|
899
|
-
const transformKey = Object.keys(t)[0]
|
|
900
|
-
const targetValue = t[transformKey]
|
|
901
1474
|
const propConfig =
|
|
902
1475
|
config.propertyConfigs[transformKey] ?? config.baseConfig
|
|
903
1476
|
|
|
904
|
-
|
|
905
|
-
|
|
906
|
-
if (currentlyExiting) {
|
|
907
|
-
const capturedKey = `transform:${transformKey}`
|
|
908
|
-
const capturedCycleId = currentCycleId
|
|
909
|
-
callback = (finished) => {
|
|
910
|
-
'worklet'
|
|
911
|
-
runOnJS(markExitKeyDone)(
|
|
912
|
-
capturedKey,
|
|
913
|
-
capturedCycleId,
|
|
914
|
-
finished ?? false
|
|
915
|
-
)
|
|
916
|
-
}
|
|
917
|
-
}
|
|
918
|
-
|
|
1477
|
+
const subKey = `transform:${transformKey}`
|
|
1478
|
+
emitted[subKey] = true
|
|
919
1479
|
validTransforms.push({
|
|
920
|
-
[transformKey]:
|
|
921
|
-
|
|
1480
|
+
[transformKey]: animateSnapshotValue(
|
|
1481
|
+
subKey,
|
|
1482
|
+
transformKey,
|
|
1483
|
+
value,
|
|
922
1484
|
propConfig,
|
|
923
|
-
|
|
1485
|
+
!!previouslyEmitted[subKey],
|
|
1486
|
+
snapshot.seeds[subKey],
|
|
1487
|
+
!!snapshot.gatedKeys[subKey],
|
|
1488
|
+
currentlyExiting,
|
|
1489
|
+
currentCycleId,
|
|
1490
|
+
currentlyCompletingAnimation,
|
|
1491
|
+
currentDidAnimateCycleId,
|
|
1492
|
+
markExitKeyDone,
|
|
1493
|
+
markDidAnimateKeyDone
|
|
924
1494
|
),
|
|
925
1495
|
})
|
|
1496
|
+
} else {
|
|
1497
|
+
validTransforms.push(t)
|
|
926
1498
|
}
|
|
927
1499
|
}
|
|
928
1500
|
|
|
929
|
-
if (validTransforms.length > 0) {
|
|
1501
|
+
if (validTransforms.length > 0 || !('transform' in staticValues)) {
|
|
930
1502
|
result.transform = validTransforms
|
|
931
1503
|
}
|
|
932
1504
|
}
|
|
933
1505
|
|
|
1506
|
+
updateMapperState(mapperStateRef, emitted)
|
|
1507
|
+
|
|
934
1508
|
return result
|
|
935
1509
|
},
|
|
936
1510
|
isWeb
|
|
937
1511
|
? [
|
|
938
|
-
|
|
939
|
-
|
|
940
|
-
|
|
941
|
-
|
|
942
|
-
isHydrating,
|
|
943
|
-
// pass SharedValues so the mapper watches them on web (no babel plugin)
|
|
944
|
-
animatedTargetsRef,
|
|
945
|
-
staticTargetsRef,
|
|
946
|
-
transformTargetsRef,
|
|
1512
|
+
// pass SharedValues so the stable mapper watches them on web (no babel plugin)
|
|
1513
|
+
renderSnapshotRef,
|
|
1514
|
+
emitterSnapshotRef,
|
|
1515
|
+
configRef,
|
|
947
1516
|
isExitingRef,
|
|
948
1517
|
exitCycleIdShared,
|
|
949
1518
|
markExitKeyDone,
|
|
1519
|
+
isCompletingAnimationRef,
|
|
1520
|
+
didAnimateCycleIdShared,
|
|
1521
|
+
markDidAnimateKeyDone,
|
|
950
1522
|
]
|
|
951
1523
|
: undefined
|
|
952
1524
|
)
|
|
953
1525
|
|
|
1526
|
+
silenceAnimatedComponentDevCheck(animatedStyle)
|
|
1527
|
+
|
|
954
1528
|
// Debug logging
|
|
955
1529
|
if (
|
|
956
1530
|
process.env.NODE_ENV === 'development' &&
|
|
@@ -968,6 +1542,9 @@ export function createAnimations<A extends Record<string, TransitionConfig>>(
|
|
|
968
1542
|
})
|
|
969
1543
|
}
|
|
970
1544
|
|
|
1545
|
+
// staticStyles carries animated keys only while mounting or on a key's
|
|
1546
|
+
// first post-mount appearance; after that the mapper owns them and the
|
|
1547
|
+
// commit bridge above keeps React commits from wiping its inline writes.
|
|
971
1548
|
return {
|
|
972
1549
|
style: [staticStyles, animatedStyle],
|
|
973
1550
|
}
|