@hanzogui/animations-react-native 2.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.
@@ -0,0 +1,682 @@
1
+ import { getEffectiveAnimation, normalizeTransition } from '@hanzogui/animation-helpers'
2
+ import { isWeb, useIsomorphicLayoutEffect } from '@hanzogui/constants'
3
+ import { ResetPresence, usePresence } from '@hanzogui/use-presence'
4
+ import type {
5
+ AnimatedNumberStrategy,
6
+ AnimationDriver,
7
+ TransitionProp,
8
+ UniversalAnimatedNumber,
9
+ UseAnimatedNumberReaction,
10
+ UseAnimatedNumberStyle,
11
+ } from '@hanzogui/web'
12
+ import { useEvent, useThemeWithState } from '@hanzogui/web'
13
+ import React from 'react'
14
+ import { Animated, type Text, type View } from 'react-native'
15
+
16
+ // detect Fabric (New Architecture) — Paper doesn't support native driver for all style keys
17
+ const isFabric =
18
+ !isWeb && typeof global !== 'undefined' && !!global.__nativeFabricUIManager
19
+
20
+ // Helper to resolve dynamic theme values like {dynamic: {dark: "value", light: undefined}}
21
+ const resolveDynamicValue = (value: any, isDark: boolean): any => {
22
+ if (value && typeof value === 'object' && 'dynamic' in value) {
23
+ const dynamicValue = isDark ? value.dynamic.dark : value.dynamic.light
24
+ return dynamicValue
25
+ }
26
+ return value
27
+ }
28
+
29
+ type AnimationsConfig<A extends object = any> = { [Key in keyof A]: AnimationConfig }
30
+
31
+ type SpringConfig = { type?: 'spring' } & Partial<
32
+ Pick<
33
+ Animated.SpringAnimationConfig,
34
+ | 'delay'
35
+ | 'bounciness'
36
+ | 'damping'
37
+ | 'friction'
38
+ | 'mass'
39
+ | 'overshootClamping'
40
+ | 'speed'
41
+ | 'stiffness'
42
+ | 'tension'
43
+ | 'velocity'
44
+ >
45
+ >
46
+
47
+ type TimingConfig = { type: 'timing' } & Partial<Animated.TimingAnimationConfig>
48
+
49
+ type AnimationConfig = SpringConfig | TimingConfig
50
+
51
+ const animatedStyleKey = {
52
+ transform: true,
53
+ opacity: true,
54
+ }
55
+
56
+ const colorStyleKey = {
57
+ backgroundColor: true,
58
+ color: true,
59
+ borderColor: true,
60
+ borderLeftColor: true,
61
+ borderRightColor: true,
62
+ borderTopColor: true,
63
+ borderBottomColor: true,
64
+ }
65
+
66
+ // these style keys are costly to animate and only work with native driver on Fabric
67
+ const costlyToAnimateStyleKey = {
68
+ borderRadius: true,
69
+ borderTopLeftRadius: true,
70
+ borderTopRightRadius: true,
71
+ borderBottomLeftRadius: true,
72
+ borderBottomRightRadius: true,
73
+ borderWidth: true,
74
+ borderLeftWidth: true,
75
+ borderRightWidth: true,
76
+ borderTopWidth: true,
77
+ borderBottomWidth: true,
78
+ ...colorStyleKey,
79
+ }
80
+
81
+ type CreateAnimationsOptions = {
82
+ // override native driver detection (default: auto-detect Fabric)
83
+ useNativeDriver?: boolean
84
+ }
85
+
86
+ export const AnimatedView: Animated.AnimatedComponent<typeof View> = Animated.View
87
+ export const AnimatedText: Animated.AnimatedComponent<typeof Text> = Animated.Text
88
+
89
+ export function useAnimatedNumber(
90
+ initial: number
91
+ ): UniversalAnimatedNumber<Animated.Value> {
92
+ const state = React.useRef(
93
+ null as any as {
94
+ val: Animated.Value
95
+ composite: Animated.CompositeAnimation | null
96
+ strategy: AnimatedNumberStrategy
97
+ }
98
+ )
99
+ if (!state.current) {
100
+ state.current = {
101
+ composite: null,
102
+ val: new Animated.Value(initial),
103
+ strategy: { type: 'spring' },
104
+ }
105
+ }
106
+
107
+ return {
108
+ getInstance() {
109
+ return state.current.val
110
+ },
111
+ getValue() {
112
+ return state.current.val['_value']
113
+ },
114
+ stop() {
115
+ state.current.composite?.stop()
116
+ state.current.composite = null
117
+ },
118
+ setValue(next: number, { type, ...config } = { type: 'spring' }, onFinish) {
119
+ const val = state.current.val
120
+
121
+ const handleFinish = onFinish
122
+ ? ({ finished }) => (finished ? onFinish() : null)
123
+ : undefined
124
+
125
+ if (type === 'direct') {
126
+ val.setValue(next)
127
+ } else if (type === 'spring') {
128
+ state.current.composite?.stop()
129
+ const composite = Animated.spring(val, {
130
+ ...config,
131
+ toValue: next,
132
+ useNativeDriver: isFabric,
133
+ })
134
+ composite.start(handleFinish)
135
+ state.current.composite = composite
136
+ } else {
137
+ state.current.composite?.stop()
138
+ const composite = Animated.timing(val, {
139
+ ...config,
140
+ toValue: next,
141
+ useNativeDriver: isFabric,
142
+ })
143
+ composite.start(handleFinish)
144
+ state.current.composite = composite
145
+ }
146
+ },
147
+ }
148
+ }
149
+
150
+ type RNAnimatedNum = UniversalAnimatedNumber<Animated.Value>
151
+
152
+ export const useAnimatedNumberReaction: UseAnimatedNumberReaction<RNAnimatedNum> = (
153
+ { value },
154
+ onValue
155
+ ) => {
156
+ const onChange = useEvent((current) => {
157
+ onValue(current.value)
158
+ })
159
+
160
+ React.useEffect(() => {
161
+ const id = value.getInstance().addListener(onChange)
162
+ return () => {
163
+ value.getInstance().removeListener(id)
164
+ }
165
+ }, [value, onChange])
166
+ }
167
+
168
+ export const useAnimatedNumberStyle: UseAnimatedNumberStyle<RNAnimatedNum> = (
169
+ value,
170
+ getStyle
171
+ ) => {
172
+ return getStyle(value.getInstance())
173
+ }
174
+
175
+ export function createAnimations<A extends AnimationsConfig>(
176
+ animations: A,
177
+ options?: CreateAnimationsOptions
178
+ ): AnimationDriver<A> {
179
+ const nativeDriver = options?.useNativeDriver ?? isFabric
180
+
181
+ return {
182
+ isReactNative: true,
183
+ inputStyle: 'value',
184
+ outputStyle: 'inline',
185
+ avoidReRenders: true,
186
+ animations,
187
+ needsCustomComponent: true,
188
+ View: AnimatedView,
189
+ Text: AnimatedText,
190
+ useAnimatedNumber,
191
+ useAnimatedNumberReaction,
192
+ useAnimatedNumberStyle,
193
+ usePresence,
194
+ ResetPresence,
195
+ useAnimations: ({
196
+ props,
197
+ onDidAnimate,
198
+ style,
199
+ componentState,
200
+ presence,
201
+ useStyleEmitter,
202
+ }) => {
203
+ const isDisabled = isWeb && componentState.unmounted === true
204
+ const isExiting = presence?.[0] === false
205
+ const sendExitComplete = presence?.[1]
206
+ const [, themeState] = useThemeWithState({})
207
+ // Check scheme first, then fall back to checking theme name for 'dark'
208
+ const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark')
209
+
210
+ /** store Animated value of each key e.g: color: AnimatedValue */
211
+ const animateStyles = React.useRef<Record<string, Animated.Value>>({})
212
+ const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])
213
+ const animationsState = React.useRef(
214
+ new WeakMap<
215
+ Animated.Value,
216
+ {
217
+ interpolation: Animated.AnimatedInterpolation<any>
218
+ current?: number | string | undefined
219
+ // only for colors
220
+ animateToValue?: number
221
+ }
222
+ >()
223
+ )
224
+
225
+ // exit cycle guards to prevent stale/duplicate completion
226
+ const exitCycleIdRef = React.useRef(0)
227
+ const exitCompletedRef = React.useRef(false)
228
+ const wasExitingRef = React.useRef(false)
229
+
230
+ // detect transition into/out of exiting state
231
+ const justStartedExiting = isExiting && !wasExitingRef.current
232
+ const justStoppedExiting = !isExiting && wasExitingRef.current
233
+
234
+ // start new exit cycle only on transition INTO exiting
235
+ if (justStartedExiting) {
236
+ exitCycleIdRef.current++
237
+ exitCompletedRef.current = false
238
+ }
239
+ // invalidate pending callbacks when exit is canceled/interrupted
240
+ if (justStoppedExiting) {
241
+ exitCycleIdRef.current++
242
+ }
243
+
244
+ const animateOnly = (props.animateOnly as string[]) || []
245
+ const hasTransitionOnly = !!props.animateOnly
246
+
247
+ // Track if we just finished entering (transition from entering to not entering)
248
+ // must be declared before args array that uses justFinishedEntering
249
+ const isEntering = !!componentState.unmounted
250
+ const wasEnteringRef = React.useRef(isEntering)
251
+ const justFinishedEntering = wasEnteringRef.current && !isEntering
252
+ React.useEffect(() => {
253
+ wasEnteringRef.current = isEntering
254
+ })
255
+
256
+ const args = [
257
+ JSON.stringify(style),
258
+ componentState,
259
+ isExiting,
260
+ !!onDidAnimate,
261
+ isDark,
262
+ justFinishedEntering,
263
+ hasTransitionOnly,
264
+ ]
265
+
266
+ const res = React.useMemo(() => {
267
+ const runners: Function[] = []
268
+ const completions: Promise<void>[] = []
269
+
270
+ // Determine animation state for enter/exit transitions
271
+ // Use 'enter' if we're entering OR if we just finished entering
272
+ const animationState: 'enter' | 'exit' | 'default' = isExiting
273
+ ? 'exit'
274
+ : isEntering || justFinishedEntering
275
+ ? 'enter'
276
+ : 'default'
277
+
278
+ const nonAnimatedStyle = {}
279
+
280
+ for (const key in style) {
281
+ const rawVal = style[key]
282
+ // Resolve dynamic theme values (like $theme-dark)
283
+ const val = resolveDynamicValue(rawVal, isDark)
284
+ if (val === undefined) continue
285
+
286
+ if (isDisabled) {
287
+ continue
288
+ }
289
+
290
+ if (animatedStyleKey[key] == null && !costlyToAnimateStyleKey[key]) {
291
+ nonAnimatedStyle[key] = val
292
+ continue
293
+ }
294
+
295
+ if (hasTransitionOnly && !animateOnly.includes(key)) {
296
+ nonAnimatedStyle[key] = val
297
+ continue
298
+ }
299
+
300
+ if (key !== 'transform') {
301
+ animateStyles.current[key] = update(key, animateStyles.current[key], val)
302
+ continue
303
+ }
304
+ // key: 'transform'
305
+ // for now just support one transform key
306
+ if (!val) continue
307
+ if (typeof val === 'string') {
308
+ console.warn(`Warning: Hanzo GUI can't animate string transforms yet!`)
309
+ continue
310
+ }
311
+
312
+ for (const [index, transform] of val.entries()) {
313
+ if (!transform) continue
314
+ // tkey: e.g: 'translateX'
315
+ const tkey = Object.keys(transform)[0]
316
+ const currentTransform = animatedTranforms.current[index]?.[tkey]
317
+ animatedTranforms.current[index] = {
318
+ [tkey]: update(tkey, currentTransform, transform[tkey]),
319
+ }
320
+ animatedTranforms.current = [...animatedTranforms.current]
321
+ }
322
+ }
323
+
324
+ const animatedTransformStyle =
325
+ animatedTranforms.current.length > 0
326
+ ? {
327
+ transform: animatedTranforms.current.map((r) => {
328
+ const key = Object.keys(r)[0]
329
+ const val =
330
+ animationsState.current!.get(r[key])?.interpolation || r[key]
331
+ return { [key]: val }
332
+ }),
333
+ }
334
+ : {}
335
+
336
+ const animatedStyle = {
337
+ ...Object.fromEntries(
338
+ Object.entries(animateStyles.current).map(([k, v]) => [
339
+ k,
340
+ animationsState.current!.get(v)?.interpolation || v,
341
+ ])
342
+ ),
343
+ ...animatedTransformStyle,
344
+ }
345
+
346
+ return {
347
+ runners,
348
+ completions,
349
+ style: [nonAnimatedStyle, animatedStyle],
350
+ }
351
+
352
+ function update(
353
+ key: string,
354
+ animated: Animated.Value | undefined,
355
+ valIn: string | number
356
+ ) {
357
+ const isColorStyleKey = colorStyleKey[key]
358
+ const [val, type] = isColorStyleKey ? [0, undefined] : getValue(valIn)
359
+ let animateToValue = val
360
+ const value = animated || new Animated.Value(val)
361
+ const curInterpolation = animationsState.current.get(value)
362
+
363
+ let interpolateArgs: any
364
+ if (type) {
365
+ interpolateArgs = getInterpolated(
366
+ curInterpolation?.current ?? value['_value'],
367
+ val,
368
+ type
369
+ )
370
+ animationsState.current!.set(value, {
371
+ interpolation: value.interpolate(interpolateArgs),
372
+ current: val,
373
+ })
374
+ }
375
+
376
+ if (isColorStyleKey) {
377
+ animateToValue = curInterpolation?.animateToValue ? 0 : 1
378
+ interpolateArgs = getColorInterpolated(
379
+ curInterpolation?.current as string,
380
+ // valIn is the next color
381
+ valIn as string,
382
+ animateToValue
383
+ )
384
+ animationsState.current!.set(value, {
385
+ current: valIn,
386
+ interpolation: value.interpolate(interpolateArgs),
387
+ animateToValue: curInterpolation?.animateToValue ? 0 : 1,
388
+ })
389
+ }
390
+
391
+ if (value) {
392
+ const animationConfig = getAnimationConfig(
393
+ key,
394
+ animations,
395
+ props.transition,
396
+ animationState
397
+ )
398
+
399
+ let resolve
400
+ const promise = new Promise<void>((res) => {
401
+ resolve = res
402
+ })
403
+ completions.push(promise)
404
+
405
+ runners.push(() => {
406
+ value.stopAnimation()
407
+
408
+ function getAnimation() {
409
+ return Animated[animationConfig.type || 'spring'](value, {
410
+ toValue: animateToValue,
411
+ useNativeDriver: nativeDriver,
412
+ ...animationConfig,
413
+ })
414
+ }
415
+
416
+ const animation = animationConfig.delay
417
+ ? Animated.sequence([
418
+ Animated.delay(animationConfig.delay),
419
+ getAnimation(),
420
+ ])
421
+ : getAnimation()
422
+
423
+ animation.start(({ finished }) => {
424
+ // always resolve during exit (element is leaving anyway)
425
+ // for non-exit, only resolve on successful completion
426
+ if (finished || isExiting) {
427
+ resolve()
428
+ }
429
+ })
430
+ })
431
+ }
432
+
433
+ if (process.env.NODE_ENV === 'development') {
434
+ if (props['debug'] === 'verbose') {
435
+ // prettier-ignore
436
+ console.info(
437
+ ' 💠 animate',
438
+ key,
439
+ `from (${value['_value']}) to`,
440
+ valIn,
441
+ `(${val})`,
442
+ 'type',
443
+ type,
444
+ 'interpolate',
445
+ interpolateArgs
446
+ )
447
+ }
448
+ }
449
+ return value
450
+ }
451
+ }, args)
452
+
453
+ // track previous exiting state
454
+ React.useEffect(() => {
455
+ wasExitingRef.current = isExiting
456
+ })
457
+
458
+ useIsomorphicLayoutEffect(() => {
459
+ res.runners.forEach((r) => r())
460
+
461
+ // capture current cycle id
462
+ const cycleId = exitCycleIdRef.current
463
+
464
+ // handle zero-completion case immediately
465
+ if (res.completions.length === 0) {
466
+ onDidAnimate?.()
467
+ if (isExiting && !exitCompletedRef.current) {
468
+ exitCompletedRef.current = true
469
+ sendExitComplete?.()
470
+ }
471
+ return
472
+ }
473
+
474
+ let cancel = false
475
+ Promise.all(res.completions).then(() => {
476
+ if (cancel) return
477
+ // guard against stale cycle completion
478
+ if (isExiting && cycleId !== exitCycleIdRef.current) return
479
+ if (isExiting && exitCompletedRef.current) return
480
+
481
+ onDidAnimate?.()
482
+ if (isExiting) {
483
+ exitCompletedRef.current = true
484
+ sendExitComplete?.()
485
+ }
486
+ })
487
+ return () => {
488
+ cancel = true
489
+ }
490
+ }, args)
491
+
492
+ // avoidReRenders: receive style changes imperatively from gui
493
+ // and update Animated.Values directly without React re-renders
494
+ // reuses the same update() + runner pattern as the useMemo path
495
+ useStyleEmitter?.((nextStyle) => {
496
+ for (const key in nextStyle) {
497
+ const rawVal = nextStyle[key]
498
+ const val = resolveDynamicValue(rawVal, isDark)
499
+ if (val === undefined) continue
500
+
501
+ if (key === 'transform' && Array.isArray(val)) {
502
+ for (const [index, transform] of val.entries()) {
503
+ if (!transform) continue
504
+ const tkey = Object.keys(transform)[0]
505
+ const currentTransform = animatedTranforms.current[index]?.[tkey]
506
+ animatedTranforms.current[index] = {
507
+ [tkey]: update(tkey, currentTransform, transform[tkey]),
508
+ }
509
+ }
510
+ } else if (animatedStyleKey[key] != null || costlyToAnimateStyleKey[key]) {
511
+ animateStyles.current[key] = update(key, animateStyles.current[key], val)
512
+ }
513
+ }
514
+
515
+ // run the queued animations immediately
516
+ res.runners.forEach((r) => r())
517
+
518
+ function update(
519
+ key: string,
520
+ animated: Animated.Value | undefined,
521
+ valIn: string | number
522
+ ) {
523
+ const isColor = colorStyleKey[key]
524
+ const [numVal, type] = isColor ? [0, undefined] : getValue(valIn)
525
+ let animateToValue = numVal
526
+ const value = animated || new Animated.Value(numVal)
527
+ const curInterpolation = animationsState.current.get(value)
528
+
529
+ if (type) {
530
+ animationsState.current.set(value, {
531
+ interpolation: value.interpolate(
532
+ getInterpolated(
533
+ curInterpolation?.current ?? value['_value'],
534
+ numVal,
535
+ type
536
+ )
537
+ ),
538
+ current: numVal,
539
+ })
540
+ }
541
+
542
+ if (isColor) {
543
+ animateToValue = curInterpolation?.animateToValue ? 0 : 1
544
+ animationsState.current.set(value, {
545
+ current: valIn,
546
+ interpolation: value.interpolate(
547
+ getColorInterpolated(
548
+ curInterpolation?.current as string,
549
+ valIn as string,
550
+ animateToValue
551
+ )
552
+ ),
553
+ animateToValue: curInterpolation?.animateToValue ? 0 : 1,
554
+ })
555
+ }
556
+
557
+ const animationConfig = getAnimationConfig(
558
+ key,
559
+ animations,
560
+ props.transition,
561
+ 'default'
562
+ )
563
+ res.runners.push(() => {
564
+ value.stopAnimation()
565
+ const anim = Animated[animationConfig.type || 'spring'](value, {
566
+ toValue: animateToValue,
567
+ useNativeDriver: nativeDriver,
568
+ ...animationConfig,
569
+ })
570
+ ;(animationConfig.delay
571
+ ? Animated.sequence([Animated.delay(animationConfig.delay), anim])
572
+ : anim
573
+ ).start()
574
+ })
575
+
576
+ return value
577
+ }
578
+ })
579
+
580
+ if (process.env.NODE_ENV === 'development') {
581
+ if (props['debug'] === 'verbose') {
582
+ console.info(`Animated`, { response: res, inputStyle: style, isExiting })
583
+ }
584
+ }
585
+
586
+ return res
587
+ },
588
+ }
589
+ }
590
+
591
+ function getColorInterpolated(
592
+ currentColor: string | undefined,
593
+ nextColor: string,
594
+ animateToValue: number
595
+ ) {
596
+ const inputRange = [0, 1]
597
+ const outputRange = [currentColor ? currentColor : nextColor, nextColor]
598
+ if (animateToValue === 0) {
599
+ // because we are animating from value 1 to 0, we need to put target color at the beginning
600
+ outputRange.reverse()
601
+ }
602
+ return {
603
+ inputRange,
604
+ outputRange,
605
+ }
606
+ }
607
+
608
+ function getInterpolated(current: number, next: number, postfix = 'deg') {
609
+ if (next === current) {
610
+ current = next - 0.000000001
611
+ }
612
+ const inputRange = [current, next]
613
+ const outputRange = [`${current}${postfix}`, `${next}${postfix}`]
614
+ if (next < current) {
615
+ inputRange.reverse()
616
+ outputRange.reverse()
617
+ }
618
+ return {
619
+ inputRange,
620
+ outputRange,
621
+ }
622
+ }
623
+
624
+ function getAnimationConfig(
625
+ key: string,
626
+ animations: AnimationsConfig,
627
+ transition?: TransitionProp,
628
+ animationState: 'enter' | 'exit' | 'default' = 'default'
629
+ ): AnimationConfig {
630
+ const normalized = normalizeTransition(transition)
631
+ const shortKey = transformShorthands[key]
632
+
633
+ // Check for property-specific animation
634
+ const propAnimation = normalized.properties[key] ?? normalized.properties[shortKey]
635
+
636
+ let animationType: string | null = null
637
+ let extraConf: any = {}
638
+
639
+ if (typeof propAnimation === 'string') {
640
+ // Direct animation name: { x: 'quick' }
641
+ animationType = propAnimation
642
+ } else if (propAnimation && typeof propAnimation === 'object') {
643
+ // Config object: { x: { type: 'quick', delay: 100 } }
644
+ // Use effective animation based on state if no explicit type in config
645
+ animationType =
646
+ propAnimation.type || getEffectiveAnimation(normalized, animationState)
647
+ extraConf = propAnimation
648
+ } else {
649
+ // Fall back to effective animation based on state (enter/exit/default)
650
+ animationType = getEffectiveAnimation(normalized, animationState)
651
+ }
652
+
653
+ // Apply global delay if no property-specific delay
654
+ if (normalized.delay && !extraConf.delay) {
655
+ extraConf = { ...extraConf, delay: normalized.delay }
656
+ }
657
+
658
+ const found = animationType ? animations[animationType] : {}
659
+ return {
660
+ ...found,
661
+ // Apply global spring config overrides (from transition={['bouncy', { stiffness: 1000 }]})
662
+ ...normalized.config,
663
+ // Property-specific config takes highest precedence
664
+ ...extraConf,
665
+ }
666
+ }
667
+
668
+ // try both combos
669
+ const transformShorthands = {
670
+ x: 'translateX',
671
+ y: 'translateY',
672
+ translateX: 'x',
673
+ translateY: 'y',
674
+ }
675
+
676
+ function getValue(input: number | string, isColor = false) {
677
+ if (typeof input !== 'string') {
678
+ return [input] as const
679
+ }
680
+ const [_, number, after] = input.match(/([-0-9]+)(deg|%|px)/) ?? []
681
+ return [+number, after] as const
682
+ }
package/src/index.ts ADDED
@@ -0,0 +1,3 @@
1
+ import './polyfill'
2
+
3
+ export * from './createAnimations'
@@ -0,0 +1,5 @@
1
+ // for SSR
2
+ if (typeof requestAnimationFrame === 'undefined') {
3
+ globalThis['requestAnimationFrame'] =
4
+ typeof setImmediate === 'undefined' ? setTimeout : setImmediate
5
+ }