diff --git a/package.json b/package.json index 2734e91..20adc83 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "react-native-animated-stopwatch", "version": "0.1.0", - "description": "test", + "description": "React Native Stopwatch component that smoothly animates the digits change", "main": "lib/commonjs/index", "module": "lib/module/index", "types": "lib/typescript/index.d.ts", diff --git a/src/AnimatedStopwatch.tsx b/src/AnimatedStopwatch.tsx new file mode 100644 index 0000000..7701aae --- /dev/null +++ b/src/AnimatedStopwatch.tsx @@ -0,0 +1,223 @@ +import React, { + ForwardedRef, + forwardRef, + useEffect, + useImperativeHandle, +} from 'react'; +import Animated, { + EntryAnimationsValues, + ExitAnimationsValues, + SharedValue, + useSharedValue, + withDelay, + withTiming, +} from 'react-native-reanimated'; +import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; +import { StyleSheet, Text, View } from 'react-native'; +import useStopwatch from './useStopwatch'; + +const DEFAULT_ANIMATION_DELAY = 0; +const DEFAULT_ANIMATION_DISTANCE = 80; +const DEFAULT_ANIMATION_DURATION = 200; + +export interface StopwatchProps { + /** + * The enter/exit animation duration in milliseconds of a stopwatch digit. + */ + animationDuration?: number; + /** + * The enter/exit animation delay in milliseconds of a stopwatch digit. + */ + animationDelay?: number; + /** + * The enter/exit animation distance in dp of a stopwatch digit. + */ + animationDistance?: number; + /** + * The style of the stopwatch View container. + */ + containerStyle?: StyleProp; + /** + * The number of zeros for the minutes. + */ + leadingZeros?: 1 | 2; + /** + * Whether the new digit should enter from the top or the bottom. + */ + enterAnimationType?: 'slide-in-up' | 'slide-in-down'; + /** + * A snapshot of the stopwatch digits and the current ms Elapsed + */ + onPaused?: (elapsedInMs: number) => void; + /** + * The style of the stopwatch Text. + */ + textStyle?: StyleProp; + /** + * If 0, the stopwatch will only display seconds and minutes. + * If 1, the stopwatch will display seconds, minutes and hundredth of ms. + */ + trailingZeros?: 0 | 1 | 2; +} + +export interface StopWatchMethods { + /** + * Pauses the stopwatch. + */ + pause: () => void; + /** + * Starts the stopwatch. Has no effect if the stopwatch is already running. + */ + start: () => void; + /** + * Resets the stopwatch. + */ + reset: () => void; +} + +function Stopwatch( + { + animationDelay = DEFAULT_ANIMATION_DELAY, + animationDistance = DEFAULT_ANIMATION_DISTANCE, + animationDuration = DEFAULT_ANIMATION_DURATION, + containerStyle, + enterAnimationType = 'slide-in-up', + leadingZeros = 1, + onPaused, + textStyle, + trailingZeros = 1, + }: StopwatchProps, + ref: ForwardedRef +) { + const { tensOfMs, lastDigit, tens, minutes, start, reset, pause } = + useStopwatch(onPaused); + + useImperativeHandle(ref, () => ({ + pause, + start, + reset, + })); + + const oldLastDigit = useSharedValue(-1); + const oldTens = useSharedValue(-1); + const oldMinutes = useSharedValue(-1); + + const newLastDigit = useSharedValue(lastDigit); + const newTens = useSharedValue(tens); + const newMinutes = useSharedValue(minutes); + + useEffect(() => { + newLastDigit.value = lastDigit; + newTens.value = tens; + newMinutes.value = minutes; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [lastDigit, tens, minutes]); + + const createEntering = + (oldValue: SharedValue, numericValue: number) => + (values: EntryAnimationsValues) => { + 'worklet'; + if (oldValue.value === -1) { + // skip entering animation on first render + oldValue.value = numericValue; + return { initialValues: {}, animations: {} }; + } + oldValue.value = numericValue; + const animations = { + originY: withDelay( + animationDelay, + withTiming(values.targetOriginY, { + duration: animationDuration, + }) + ), + }; + const enterDirection = enterAnimationType === 'slide-in-up' ? -1 : 1; + const initialValues = { + originY: values.targetOriginY + animationDistance * enterDirection, + }; + return { + initialValues, + animations, + }; + }; + + const exiting = (values: ExitAnimationsValues) => { + 'worklet'; + const exitDirection = enterAnimationType === 'slide-in-up' ? 1 : -1; + const animations = { + originY: withDelay( + animationDelay, + withTiming(values.currentOriginY + animationDistance * exitDirection, { + duration: animationDuration, + }) + ), + }; + const initialValues = { + originY: values.currentOriginY, + }; + return { + initialValues, + animations, + }; + }; + + return ( + + {leadingZeros === 2 && 0} + + {minutes} + + : + + {tens} + + + {lastDigit} + + {trailingZeros > 0 && ( + <> + , + + {tensOfMs >= 10 ? String(tensOfMs).charAt(0) : 0} + + {trailingZeros === 2 && ( + + {tensOfMs >= 10 + ? String(tensOfMs).charAt(1) + : String(tensOfMs).charAt(0)} + + )} + + )} + + ); +} + +const styles = StyleSheet.create({ + container: { + flexDirection: 'row', + alignItems: 'center', + overflow: 'hidden', + }, +}); + +const AnimatedStopwatch = forwardRef( + Stopwatch +); + +export default AnimatedStopwatch; diff --git a/src/index.tsx b/src/index.tsx index 7701aae..ef21d01 100644 --- a/src/index.tsx +++ b/src/index.tsx @@ -1,223 +1,8 @@ -import React, { - ForwardedRef, - forwardRef, - useEffect, - useImperativeHandle, -} from 'react'; -import Animated, { - EntryAnimationsValues, - ExitAnimationsValues, - SharedValue, - useSharedValue, - withDelay, - withTiming, -} from 'react-native-reanimated'; -import type { StyleProp, TextStyle, ViewStyle } from 'react-native'; -import { StyleSheet, Text, View } from 'react-native'; -import useStopwatch from './useStopwatch'; - -const DEFAULT_ANIMATION_DELAY = 0; -const DEFAULT_ANIMATION_DISTANCE = 80; -const DEFAULT_ANIMATION_DURATION = 200; - -export interface StopwatchProps { - /** - * The enter/exit animation duration in milliseconds of a stopwatch digit. - */ - animationDuration?: number; - /** - * The enter/exit animation delay in milliseconds of a stopwatch digit. - */ - animationDelay?: number; - /** - * The enter/exit animation distance in dp of a stopwatch digit. - */ - animationDistance?: number; - /** - * The style of the stopwatch View container. - */ - containerStyle?: StyleProp; - /** - * The number of zeros for the minutes. - */ - leadingZeros?: 1 | 2; - /** - * Whether the new digit should enter from the top or the bottom. - */ - enterAnimationType?: 'slide-in-up' | 'slide-in-down'; - /** - * A snapshot of the stopwatch digits and the current ms Elapsed - */ - onPaused?: (elapsedInMs: number) => void; - /** - * The style of the stopwatch Text. - */ - textStyle?: StyleProp; - /** - * If 0, the stopwatch will only display seconds and minutes. - * If 1, the stopwatch will display seconds, minutes and hundredth of ms. - */ - trailingZeros?: 0 | 1 | 2; -} - -export interface StopWatchMethods { - /** - * Pauses the stopwatch. - */ - pause: () => void; - /** - * Starts the stopwatch. Has no effect if the stopwatch is already running. - */ - start: () => void; - /** - * Resets the stopwatch. - */ - reset: () => void; -} - -function Stopwatch( - { - animationDelay = DEFAULT_ANIMATION_DELAY, - animationDistance = DEFAULT_ANIMATION_DISTANCE, - animationDuration = DEFAULT_ANIMATION_DURATION, - containerStyle, - enterAnimationType = 'slide-in-up', - leadingZeros = 1, - onPaused, - textStyle, - trailingZeros = 1, - }: StopwatchProps, - ref: ForwardedRef -) { - const { tensOfMs, lastDigit, tens, minutes, start, reset, pause } = - useStopwatch(onPaused); - - useImperativeHandle(ref, () => ({ - pause, - start, - reset, - })); - - const oldLastDigit = useSharedValue(-1); - const oldTens = useSharedValue(-1); - const oldMinutes = useSharedValue(-1); - - const newLastDigit = useSharedValue(lastDigit); - const newTens = useSharedValue(tens); - const newMinutes = useSharedValue(minutes); - - useEffect(() => { - newLastDigit.value = lastDigit; - newTens.value = tens; - newMinutes.value = minutes; - // eslint-disable-next-line react-hooks/exhaustive-deps - }, [lastDigit, tens, minutes]); - - const createEntering = - (oldValue: SharedValue, numericValue: number) => - (values: EntryAnimationsValues) => { - 'worklet'; - if (oldValue.value === -1) { - // skip entering animation on first render - oldValue.value = numericValue; - return { initialValues: {}, animations: {} }; - } - oldValue.value = numericValue; - const animations = { - originY: withDelay( - animationDelay, - withTiming(values.targetOriginY, { - duration: animationDuration, - }) - ), - }; - const enterDirection = enterAnimationType === 'slide-in-up' ? -1 : 1; - const initialValues = { - originY: values.targetOriginY + animationDistance * enterDirection, - }; - return { - initialValues, - animations, - }; - }; - - const exiting = (values: ExitAnimationsValues) => { - 'worklet'; - const exitDirection = enterAnimationType === 'slide-in-up' ? 1 : -1; - const animations = { - originY: withDelay( - animationDelay, - withTiming(values.currentOriginY + animationDistance * exitDirection, { - duration: animationDuration, - }) - ), - }; - const initialValues = { - originY: values.currentOriginY, - }; - return { - initialValues, - animations, - }; - }; - - return ( - - {leadingZeros === 2 && 0} - - {minutes} - - : - - {tens} - - - {lastDigit} - - {trailingZeros > 0 && ( - <> - , - - {tensOfMs >= 10 ? String(tensOfMs).charAt(0) : 0} - - {trailingZeros === 2 && ( - - {tensOfMs >= 10 - ? String(tensOfMs).charAt(1) - : String(tensOfMs).charAt(0)} - - )} - - )} - - ); -} - -const styles = StyleSheet.create({ - container: { - flexDirection: 'row', - alignItems: 'center', - overflow: 'hidden', - }, -}); - -const AnimatedStopwatch = forwardRef( - Stopwatch -); +import AnimatedStopwatch, { + StopwatchProps, + StopWatchMethods, +} from './AnimatedStopwatch'; export default AnimatedStopwatch; + +export type { StopwatchProps, StopWatchMethods }; diff --git a/src/useStopwatch.ts b/src/useStopwatch.ts index 0eb696c..77522e5 100644 --- a/src/useStopwatch.ts +++ b/src/useStopwatch.ts @@ -1,5 +1,5 @@ import { useRef, useState } from 'react'; -import type { StopwatchProps } from './index'; +import type { StopwatchProps } from './AnimatedStopwatch'; /** * A custom hooks that handles the state for the stopwatch