From e8f828d90ad28f4e12832c3589d171b0f6164fff Mon Sep 17 00:00:00 2001 From: fightrate-bot Date: Tue, 16 Jun 2026 01:46:23 -0300 Subject: [PATCH] feat(hooks): usePulseAnimation for live indicators --- src/__tests__/usePulseAnimation.test.tsx | 49 ++++++++++++++++++++++++ src/hooks/usePulseAnimation.ts | 44 +++++++++++++++++++++ 2 files changed, 93 insertions(+) create mode 100644 src/__tests__/usePulseAnimation.test.tsx create mode 100644 src/hooks/usePulseAnimation.ts diff --git a/src/__tests__/usePulseAnimation.test.tsx b/src/__tests__/usePulseAnimation.test.tsx new file mode 100644 index 0000000..abd2e47 --- /dev/null +++ b/src/__tests__/usePulseAnimation.test.tsx @@ -0,0 +1,49 @@ +import React from 'react'; +import TestRenderer from 'react-test-renderer'; + +jest.mock('react-native-reanimated', () => { + return { + Easing: { out: () => (() => 0), inOut: () => (() => 0), quad: () => 0 }, + useSharedValue: (initial: number) => ({ value: initial }), + useAnimatedStyle: (fn: () => unknown) => fn(), + withTiming: (to: number) => to, + withRepeat: (to: number) => to, + cancelAnimation: () => undefined, + }; +}); + +import { usePulseAnimation } from '../hooks/usePulseAnimation'; + +function Probe() { + const style = usePulseAnimation(false) as any; + return null; +} + +describe('usePulseAnimation', () => { + it('returns initial opacity 0.5 and scale 1 when inactive', () => { + let captured: any = null; + function Capture() { + const style = usePulseAnimation(false) as any; + captured = style; + return null; + } + TestRenderer.act(() => { + TestRenderer.create(React.createElement(Capture)); + }); + expect(captured.transform[0].scale).toBe(1); + expect(captured.opacity).toBe(0.5); + }); + + it('returns opacity 1 when active', () => { + let captured: any = null; + function Capture() { + const style = usePulseAnimation(true) as any; + captured = style; + return null; + } + TestRenderer.act(() => { + TestRenderer.create(React.createElement(Capture)); + }); + expect(captured.opacity).toBe(1); + }); +}); diff --git a/src/hooks/usePulseAnimation.ts b/src/hooks/usePulseAnimation.ts new file mode 100644 index 0000000..23faf78 --- /dev/null +++ b/src/hooks/usePulseAnimation.ts @@ -0,0 +1,44 @@ +import { useEffect } from 'react'; +import { + Easing, + cancelAnimation, + useAnimatedStyle, + useSharedValue, + withRepeat, + withTiming, +} from 'react-native-reanimated'; + +type AnimatedStyle = { + transform: Array<{ scale: number }>; + opacity: number; +}; + +export function usePulseAnimation(active: boolean, durationMs = 1600): AnimatedStyle { + const scale = useSharedValue(1); + const opacity = useSharedValue(active ? 1 : 0.5); + + useEffect(() => { + if (active) { + scale.value = withRepeat( + withTiming(1.4, { duration: durationMs / 2, easing: Easing.out(Easing.quad) }), + -1, + true, + ); + opacity.value = withRepeat( + withTiming(0.55, { duration: durationMs / 2, easing: Easing.inOut(Easing.quad) }), + -1, + true, + ); + } else { + cancelAnimation(scale); + cancelAnimation(opacity); + scale.value = withTiming(1, { duration: 160 }); + opacity.value = withTiming(0.5, { duration: 160 }); + } + }, [active, durationMs, scale, opacity]); + + return useAnimatedStyle(() => ({ + transform: [{ scale: scale.value }], + opacity: opacity.value, + })); +}