import { forwardRef, useImperativeHandle, useMemo, useRef } from 'react'; import { Easing, type SharedValue, useAnimatedProps, useSharedValue, withDelay, withTiming, } from 'react-native-reanimated'; import AnimatedPath from '../components/AnimatedPath'; import { extendStart, getPathString } from '../geometry'; import type { Stroke } from '../hanzi-writer'; import { scheduleOnRN } from 'react-native-worklets'; interface StrokeAnimatorProps { stroke: Stroke; strokeColor: string; strokeWidth: number; } type AnimateParams = { duration: number; delay?: number; onComplete?: () => void; }; export type StrokeAnimatorRef = { animate(params: AnimateParams): void; reset(): void; }; export const StrokeAnimator = forwardRef< StrokeAnimatorRef, StrokeAnimatorProps >(function StrokeAnimator({ stroke, strokeColor, strokeWidth }, ref) { const progress = useSharedValue(0); useImperativeHandle(ref, () => ({ reset: () => { progress.value = withDelay( 150, withTiming(0, { duration: 0, easing: Easing.ease, }) ); }, animate: (params: { duration: number; delay: number; onComplete?: () => void; }) => { const { duration, delay, onComplete } = params; const onCompleteCallback = () => onComplete?.(); const strokeIn = withTiming( 1, { duration, easing: Easing.linear }, (finished) => { // Reanimated also fires this with `finished: false` when the // animation is cancelled (e.g. the stroke is re-animated or reset), // which would otherwise report a completion that never happened. if (finished) { scheduleOnRN(onCompleteCallback); } } ); progress.value = delay ? withDelay(delay, strokeIn) : strokeIn; }, })); const path = useMemo(() => { const extendedMaskPoints = extendStart(stroke.points, strokeWidth / 2); return getPathString(extendedMaskPoints); }, [stroke.points, strokeWidth]); const pathLength = stroke.getLength() + strokeWidth / 2; return ( ); }); interface AnimatedStrokeProps { d: string; progress: SharedValue; clipPath: string; pathLength: number; color: string; } function AnimatedStroke({ d, progress, clipPath, color, pathLength, }: AnimatedStrokeProps) { const ref = useRef(null); const animatedProps = useAnimatedProps(() => { // Sometimes the progress value can be slightly out of bounds due to floating point errors const clampedProgress = Math.min(Math.max(progress.value, 0), 1); return { strokeDashoffset: pathLength * (1 - clampedProgress), // Ease out the stroke opacity strokeOpacity: clampedProgress < 0.01 ? clampedProgress * 2 : 1, }; }); return ( ); }