@hanzogui/animations-react-native 2.0.0-rc.41-hanzoai.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.
@@ -0,0 +1,690 @@
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 const useAnimatedNumbersStyle = (
176
+ vals: RNAnimatedNum[],
177
+ getStyle: (...currentValues: any[]) => any
178
+ ): any => {
179
+ return getStyle(...vals.map((v) => v.getInstance()))
180
+ }
181
+
182
+ export function createAnimations<A extends AnimationsConfig>(
183
+ animations: A,
184
+ options?: CreateAnimationsOptions
185
+ ): AnimationDriver<A> {
186
+ const nativeDriver = options?.useNativeDriver ?? isFabric
187
+
188
+ return {
189
+ isReactNative: true,
190
+ inputStyle: 'value',
191
+ outputStyle: 'inline',
192
+ avoidReRenders: true,
193
+ animations,
194
+ needsCustomComponent: true,
195
+ View: AnimatedView,
196
+ Text: AnimatedText,
197
+ useAnimatedNumber,
198
+ useAnimatedNumberReaction,
199
+ useAnimatedNumberStyle,
200
+ useAnimatedNumbersStyle,
201
+ usePresence,
202
+ ResetPresence,
203
+ useAnimations: ({
204
+ props,
205
+ onDidAnimate,
206
+ style,
207
+ componentState,
208
+ presence,
209
+ useStyleEmitter,
210
+ }) => {
211
+ const isDisabled = isWeb && componentState.unmounted === true
212
+ const isExiting = presence?.[0] === false
213
+ const sendExitComplete = presence?.[1]
214
+ const [, themeState] = useThemeWithState({})
215
+ // Check scheme first, then fall back to checking theme name for 'dark'
216
+ const isDark = themeState?.scheme === 'dark' || themeState?.name?.startsWith('dark')
217
+
218
+ /** store Animated value of each key e.g: color: AnimatedValue */
219
+ const animateStyles = React.useRef<Record<string, Animated.Value>>({})
220
+ const animatedTranforms = React.useRef<{ [key: string]: Animated.Value }[]>([])
221
+ const animationsState = React.useRef(
222
+ new WeakMap<
223
+ Animated.Value,
224
+ {
225
+ interpolation: Animated.AnimatedInterpolation<any>
226
+ current?: number | string | undefined
227
+ // only for colors
228
+ animateToValue?: number
229
+ }
230
+ >()
231
+ )
232
+
233
+ // exit cycle guards to prevent stale/duplicate completion
234
+ const exitCycleIdRef = React.useRef(0)
235
+ const exitCompletedRef = React.useRef(false)
236
+ const wasExitingRef = React.useRef(false)
237
+
238
+ // detect transition into/out of exiting state
239
+ const justStartedExiting = isExiting && !wasExitingRef.current
240
+ const justStoppedExiting = !isExiting && wasExitingRef.current
241
+
242
+ // start new exit cycle only on transition INTO exiting
243
+ if (justStartedExiting) {
244
+ exitCycleIdRef.current++
245
+ exitCompletedRef.current = false
246
+ }
247
+ // invalidate pending callbacks when exit is canceled/interrupted
248
+ if (justStoppedExiting) {
249
+ exitCycleIdRef.current++
250
+ }
251
+
252
+ const animateOnly = (props.animateOnly as string[]) || []
253
+ const hasTransitionOnly = !!props.animateOnly
254
+
255
+ // Track if we just finished entering (transition from entering to not entering)
256
+ // must be declared before args array that uses justFinishedEntering
257
+ const isEntering = !!componentState.unmounted
258
+ const wasEnteringRef = React.useRef(isEntering)
259
+ const justFinishedEntering = wasEnteringRef.current && !isEntering
260
+ React.useEffect(() => {
261
+ wasEnteringRef.current = isEntering
262
+ })
263
+
264
+ const args = [
265
+ JSON.stringify(style),
266
+ componentState,
267
+ isExiting,
268
+ !!onDidAnimate,
269
+ isDark,
270
+ justFinishedEntering,
271
+ hasTransitionOnly,
272
+ ]
273
+
274
+ const res = React.useMemo(() => {
275
+ const runners: Function[] = []
276
+ const completions: Promise<void>[] = []
277
+
278
+ // Determine animation state for enter/exit transitions
279
+ // Use 'enter' if we're entering OR if we just finished entering
280
+ const animationState: 'enter' | 'exit' | 'default' = isExiting
281
+ ? 'exit'
282
+ : isEntering || justFinishedEntering
283
+ ? 'enter'
284
+ : 'default'
285
+
286
+ const nonAnimatedStyle = {}
287
+
288
+ for (const key in style) {
289
+ const rawVal = style[key]
290
+ // Resolve dynamic theme values (like $theme-dark)
291
+ const val = resolveDynamicValue(rawVal, isDark)
292
+ if (val === undefined) continue
293
+
294
+ if (isDisabled) {
295
+ continue
296
+ }
297
+
298
+ if (animatedStyleKey[key] == null && !costlyToAnimateStyleKey[key]) {
299
+ nonAnimatedStyle[key] = val
300
+ continue
301
+ }
302
+
303
+ if (hasTransitionOnly && !animateOnly.includes(key)) {
304
+ nonAnimatedStyle[key] = val
305
+ continue
306
+ }
307
+
308
+ if (key !== 'transform') {
309
+ animateStyles.current[key] = update(key, animateStyles.current[key], val)
310
+ continue
311
+ }
312
+ // key: 'transform'
313
+ // for now just support one transform key
314
+ if (!val) continue
315
+ if (typeof val === 'string') {
316
+ console.warn(`Warning: Hanzogui can't animate string transforms yet!`)
317
+ continue
318
+ }
319
+
320
+ for (const [index, transform] of val.entries()) {
321
+ if (!transform) continue
322
+ // tkey: e.g: 'translateX'
323
+ const tkey = Object.keys(transform)[0]
324
+ const currentTransform = animatedTranforms.current[index]?.[tkey]
325
+ animatedTranforms.current[index] = {
326
+ [tkey]: update(tkey, currentTransform, transform[tkey]),
327
+ }
328
+ animatedTranforms.current = [...animatedTranforms.current]
329
+ }
330
+ }
331
+
332
+ const animatedTransformStyle =
333
+ animatedTranforms.current.length > 0
334
+ ? {
335
+ transform: animatedTranforms.current.map((r) => {
336
+ const key = Object.keys(r)[0]
337
+ const val =
338
+ animationsState.current!.get(r[key])?.interpolation || r[key]
339
+ return { [key]: val }
340
+ }),
341
+ }
342
+ : {}
343
+
344
+ const animatedStyle = {
345
+ ...Object.fromEntries(
346
+ Object.entries(animateStyles.current).map(([k, v]) => [
347
+ k,
348
+ animationsState.current!.get(v)?.interpolation || v,
349
+ ])
350
+ ),
351
+ ...animatedTransformStyle,
352
+ }
353
+
354
+ return {
355
+ runners,
356
+ completions,
357
+ style: [nonAnimatedStyle, animatedStyle],
358
+ }
359
+
360
+ function update(
361
+ key: string,
362
+ animated: Animated.Value | undefined,
363
+ valIn: string | number
364
+ ) {
365
+ const isColorStyleKey = colorStyleKey[key]
366
+ const [val, type] = isColorStyleKey ? [0, undefined] : getValue(valIn)
367
+ let animateToValue = val
368
+ const value = animated || new Animated.Value(val)
369
+ const curInterpolation = animationsState.current.get(value)
370
+
371
+ let interpolateArgs: any
372
+ if (type) {
373
+ interpolateArgs = getInterpolated(
374
+ curInterpolation?.current ?? value['_value'],
375
+ val,
376
+ type
377
+ )
378
+ animationsState.current!.set(value, {
379
+ interpolation: value.interpolate(interpolateArgs),
380
+ current: val,
381
+ })
382
+ }
383
+
384
+ if (isColorStyleKey) {
385
+ animateToValue = curInterpolation?.animateToValue ? 0 : 1
386
+ interpolateArgs = getColorInterpolated(
387
+ curInterpolation?.current as string,
388
+ // valIn is the next color
389
+ valIn as string,
390
+ animateToValue
391
+ )
392
+ animationsState.current!.set(value, {
393
+ current: valIn,
394
+ interpolation: value.interpolate(interpolateArgs),
395
+ animateToValue: curInterpolation?.animateToValue ? 0 : 1,
396
+ })
397
+ }
398
+
399
+ if (value) {
400
+ const animationConfig = getAnimationConfig(
401
+ key,
402
+ animations,
403
+ props.transition,
404
+ animationState
405
+ )
406
+
407
+ let resolve
408
+ const promise = new Promise<void>((res) => {
409
+ resolve = res
410
+ })
411
+ completions.push(promise)
412
+
413
+ runners.push(() => {
414
+ value.stopAnimation()
415
+
416
+ function getAnimation() {
417
+ return Animated[animationConfig.type || 'spring'](value, {
418
+ toValue: animateToValue,
419
+ useNativeDriver: nativeDriver,
420
+ ...animationConfig,
421
+ })
422
+ }
423
+
424
+ const animation = animationConfig.delay
425
+ ? Animated.sequence([
426
+ Animated.delay(animationConfig.delay),
427
+ getAnimation(),
428
+ ])
429
+ : getAnimation()
430
+
431
+ animation.start(({ finished }) => {
432
+ // always resolve during exit (element is leaving anyway)
433
+ // for non-exit, only resolve on successful completion
434
+ if (finished || isExiting) {
435
+ resolve()
436
+ }
437
+ })
438
+ })
439
+ }
440
+
441
+ if (process.env.NODE_ENV === 'development') {
442
+ if (props['debug'] === 'verbose') {
443
+ // prettier-ignore
444
+ console.info(
445
+ ' 💠 animate',
446
+ key,
447
+ `from (${value['_value']}) to`,
448
+ valIn,
449
+ `(${val})`,
450
+ 'type',
451
+ type,
452
+ 'interpolate',
453
+ interpolateArgs
454
+ )
455
+ }
456
+ }
457
+ return value
458
+ }
459
+ }, args)
460
+
461
+ // track previous exiting state
462
+ React.useEffect(() => {
463
+ wasExitingRef.current = isExiting
464
+ })
465
+
466
+ useIsomorphicLayoutEffect(() => {
467
+ res.runners.forEach((r) => r())
468
+
469
+ // capture current cycle id
470
+ const cycleId = exitCycleIdRef.current
471
+
472
+ // handle zero-completion case immediately
473
+ if (res.completions.length === 0) {
474
+ onDidAnimate?.()
475
+ if (isExiting && !exitCompletedRef.current) {
476
+ exitCompletedRef.current = true
477
+ sendExitComplete?.()
478
+ }
479
+ return
480
+ }
481
+
482
+ let cancel = false
483
+ Promise.all(res.completions).then(() => {
484
+ if (cancel) return
485
+ // guard against stale cycle completion
486
+ if (isExiting && cycleId !== exitCycleIdRef.current) return
487
+ if (isExiting && exitCompletedRef.current) return
488
+
489
+ onDidAnimate?.()
490
+ if (isExiting) {
491
+ exitCompletedRef.current = true
492
+ sendExitComplete?.()
493
+ }
494
+ })
495
+ return () => {
496
+ cancel = true
497
+ }
498
+ }, args)
499
+
500
+ // avoidReRenders: receive style changes imperatively from hanzogui
501
+ // and update Animated.Values directly without React re-renders
502
+ // reuses the same update() + runner pattern as the useMemo path
503
+ useStyleEmitter?.((nextStyle) => {
504
+ for (const key in nextStyle) {
505
+ const rawVal = nextStyle[key]
506
+ const val = resolveDynamicValue(rawVal, isDark)
507
+ if (val === undefined) continue
508
+
509
+ if (key === 'transform' && Array.isArray(val)) {
510
+ for (const [index, transform] of val.entries()) {
511
+ if (!transform) continue
512
+ const tkey = Object.keys(transform)[0]
513
+ const currentTransform = animatedTranforms.current[index]?.[tkey]
514
+ animatedTranforms.current[index] = {
515
+ [tkey]: update(tkey, currentTransform, transform[tkey]),
516
+ }
517
+ }
518
+ } else if (animatedStyleKey[key] != null || costlyToAnimateStyleKey[key]) {
519
+ animateStyles.current[key] = update(key, animateStyles.current[key], val)
520
+ }
521
+ }
522
+
523
+ // run the queued animations immediately
524
+ res.runners.forEach((r) => r())
525
+
526
+ function update(
527
+ key: string,
528
+ animated: Animated.Value | undefined,
529
+ valIn: string | number
530
+ ) {
531
+ const isColor = colorStyleKey[key]
532
+ const [numVal, type] = isColor ? [0, undefined] : getValue(valIn)
533
+ let animateToValue = numVal
534
+ const value = animated || new Animated.Value(numVal)
535
+ const curInterpolation = animationsState.current.get(value)
536
+
537
+ if (type) {
538
+ animationsState.current.set(value, {
539
+ interpolation: value.interpolate(
540
+ getInterpolated(
541
+ curInterpolation?.current ?? value['_value'],
542
+ numVal,
543
+ type
544
+ )
545
+ ),
546
+ current: numVal,
547
+ })
548
+ }
549
+
550
+ if (isColor) {
551
+ animateToValue = curInterpolation?.animateToValue ? 0 : 1
552
+ animationsState.current.set(value, {
553
+ current: valIn,
554
+ interpolation: value.interpolate(
555
+ getColorInterpolated(
556
+ curInterpolation?.current as string,
557
+ valIn as string,
558
+ animateToValue
559
+ )
560
+ ),
561
+ animateToValue: curInterpolation?.animateToValue ? 0 : 1,
562
+ })
563
+ }
564
+
565
+ const animationConfig = getAnimationConfig(
566
+ key,
567
+ animations,
568
+ props.transition,
569
+ 'default'
570
+ )
571
+ res.runners.push(() => {
572
+ value.stopAnimation()
573
+ const anim = Animated[animationConfig.type || 'spring'](value, {
574
+ toValue: animateToValue,
575
+ useNativeDriver: nativeDriver,
576
+ ...animationConfig,
577
+ })
578
+ ;(animationConfig.delay
579
+ ? Animated.sequence([Animated.delay(animationConfig.delay), anim])
580
+ : anim
581
+ ).start()
582
+ })
583
+
584
+ return value
585
+ }
586
+ })
587
+
588
+ if (process.env.NODE_ENV === 'development') {
589
+ if (props['debug'] === 'verbose') {
590
+ console.info(`Animated`, { response: res, inputStyle: style, isExiting })
591
+ }
592
+ }
593
+
594
+ return res
595
+ },
596
+ }
597
+ }
598
+
599
+ function getColorInterpolated(
600
+ currentColor: string | undefined,
601
+ nextColor: string,
602
+ animateToValue: number
603
+ ) {
604
+ const inputRange = [0, 1]
605
+ const outputRange = [currentColor ? currentColor : nextColor, nextColor]
606
+ if (animateToValue === 0) {
607
+ // because we are animating from value 1 to 0, we need to put target color at the beginning
608
+ outputRange.reverse()
609
+ }
610
+ return {
611
+ inputRange,
612
+ outputRange,
613
+ }
614
+ }
615
+
616
+ function getInterpolated(current: number, next: number, postfix = 'deg') {
617
+ if (next === current) {
618
+ current = next - 0.000000001
619
+ }
620
+ const inputRange = [current, next]
621
+ const outputRange = [`${current}${postfix}`, `${next}${postfix}`]
622
+ if (next < current) {
623
+ inputRange.reverse()
624
+ outputRange.reverse()
625
+ }
626
+ return {
627
+ inputRange,
628
+ outputRange,
629
+ }
630
+ }
631
+
632
+ function getAnimationConfig(
633
+ key: string,
634
+ animations: AnimationsConfig,
635
+ transition?: TransitionProp,
636
+ animationState: 'enter' | 'exit' | 'default' = 'default'
637
+ ): AnimationConfig {
638
+ const normalized = normalizeTransition(transition)
639
+ const shortKey = transformShorthands[key]
640
+
641
+ // Check for property-specific animation
642
+ const propAnimation = normalized.properties[key] ?? normalized.properties[shortKey]
643
+
644
+ let animationType: string | null = null
645
+ let extraConf: any = {}
646
+
647
+ if (typeof propAnimation === 'string') {
648
+ // Direct animation name: { x: 'quick' }
649
+ animationType = propAnimation
650
+ } else if (propAnimation && typeof propAnimation === 'object') {
651
+ // Config object: { x: { type: 'quick', delay: 100 } }
652
+ // Use effective animation based on state if no explicit type in config
653
+ animationType =
654
+ propAnimation.type || getEffectiveAnimation(normalized, animationState)
655
+ extraConf = propAnimation
656
+ } else {
657
+ // Fall back to effective animation based on state (enter/exit/default)
658
+ animationType = getEffectiveAnimation(normalized, animationState)
659
+ }
660
+
661
+ // Apply global delay if no property-specific delay
662
+ if (normalized.delay && !extraConf.delay) {
663
+ extraConf = { ...extraConf, delay: normalized.delay }
664
+ }
665
+
666
+ const found = animationType ? animations[animationType] : {}
667
+ return {
668
+ ...found,
669
+ // Apply global spring config overrides (from transition={['bouncy', { stiffness: 1000 }]})
670
+ ...normalized.config,
671
+ // Property-specific config takes highest precedence
672
+ ...extraConf,
673
+ }
674
+ }
675
+
676
+ // try both combos
677
+ const transformShorthands = {
678
+ x: 'translateX',
679
+ y: 'translateY',
680
+ translateX: 'x',
681
+ translateY: 'y',
682
+ }
683
+
684
+ function getValue(input: number | string, isColor = false) {
685
+ if (typeof input !== 'string') {
686
+ return [input] as const
687
+ }
688
+ const [_, number, after] = input.match(/([-0-9]+)(deg|%|px)/) ?? []
689
+ return [+number, after] as const
690
+ }
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
+ }