I built a Slider component (Single and Range) that mimics the iOS native Slider. I'm new to using animations and PanResponder, so not entirely sure how that works + Using it with useCallback and useMemo.
Below is the code for the component, but I've also linked the GitHub Repo with the component imported for testing. Can someone please review my code/give me tips for enhancing the Slider component?
GitHub Repo: https://github.com/jefelewis/react-native-slider
Slider.tsx
// Imports: Dependenciesimport React, { useState, useEffect, useCallback, useMemo, useRef } from 'react';import { Animated, Dimensions, PanResponder, PanResponderGestureState, StyleSheet, View } from 'react-native';// Imports: React Hooksimport { useLowHigh, useWidthLayout, useSelectedRail } from './hooks/hooks';// Imports: Helper Functionsimport { clamp, getValueForPosition, isLowCloser } from './helpers/helpers';// Imports: Stylesimport { defaultStyles } from '../../styles/styles';// TypeScript Type: Propsinterface IProps { type: 'Range' | 'Single'; min: number; max: number; step: number; onChange: (low: number, high: number) => void; minRange?: number; disabled?: boolean; darkMode?: boolean;}// TypeScript Type: Gesture State Refinterface IGestureStateRef { isLow: boolean; lastValue: number; lastPosition: number;}// React Native: Screen Dimensionsconst { width } = Dimensions.get('window');// Component: Sliderexport const Slider = ({ type, min, max, step, onChange, minRange = 0, disabled = false, darkMode = false }: IProps): JSX.Element => { // React Hooks: State const [thumbWidth, setThumbWidth] = useState<number>(0); // TODO TODO TODO (SET STATE + FIX OVERLAP WITH NAMES) const [lowProp, setLowProp] = useState<number>(min); const [highProp, setHighProp] = useState<number>(max); // React Hooks: Refs const lowThumbXRef = useRef<Animated.Value>(new Animated.Value(0)); const highThumbXRef = useRef<Animated.Value>(new Animated.Value(0)); const pointerX = useRef<Animated.Value>(new Animated.Value(0)).current; const gestureStateRef = useRef<IGestureStateRef>({ isLow: true, lastValue: 0, lastPosition: 0 }); const containerWidthRef = useRef<number>(0); // React Hooks: Use Low High const { inPropsRef, inPropsRefPrev, setLow, setHigh } = useLowHigh(lowProp, type === 'Single' ? max : highProp, min, max, step); // React Hooks: Use Selected Rail const [selectedRailStyle, updateSelectedRail] = useSelectedRail(inPropsRef, containerWidthRef, thumbWidth, type); // Update Thumbs const updateThumbs = useCallback(() => { // Ref: Container With const { current: containerWidth } = containerWidthRef; if (!thumbWidth || !containerWidth) { return; } // Ref: Props const { low, high } = inPropsRef.current; // Slider Type: Range if (type === 'Range') { // High Position const highPosition: number = ((high - min) / (max - min)) * (containerWidth - thumbWidth); // Ref: Set Value (High Thumb X) highThumbXRef.current.setValue(highPosition); } // Low Position const lowPosition: number = ((low - min) / (max - min)) * (containerWidth - thumbWidth); // Ref: Set Value (Low Thumb X) lowThumbXRef.current.setValue(lowPosition); // Update Selected Rail updateSelectedRail(); // Props: On Change onChange?.(low, high); }, [type, inPropsRef, max, min, onChange, thumbWidth, updateSelectedRail]); // React Hooks: Lifecycle Methods useEffect(() => { // TODO (WHAT?) if ((lowProp !== undefined && lowProp !== inPropsRefPrev.lowPrev) || (highProp !== undefined && highProp !== inPropsRefPrev.highPrev)) { // Update Thumbs updateThumbs(); } }, [highProp, inPropsRefPrev.lowPrev, inPropsRefPrev.highPrev, lowProp, inPropsRefPrev, updateThumbs]); useEffect(() => { // Update Thumbs updateThumbs(); }, [updateThumbs]); // Handle Container Layout const handleContainerLayout = useWidthLayout(containerWidthRef, updateThumbs); // Handle Thumb Layout const handleThumbLayout = useCallback( ({ nativeEvent }) => { const { layout: { width: newWidth }, } = nativeEvent; if (thumbWidth !== newWidth) { setThumbWidth(newWidth); } }, [thumbWidth], ); // React Native: Pan Handlers const { panHandlers } = useMemo( () => // React Native: Pan Responder PanResponder.create({ // On Start Should Set Pan Responder onStartShouldSetPanResponder: (): boolean => true, // On Start Should Set Pan Responder Capture onStartShouldSetPanResponderCapture: (): boolean => true, // On Move Should Set Pan Responder onMoveShouldSetPanResponder: (): boolean => true, // On Move Should Set Pan Responder Capture onMoveShouldSetPanResponderCapture: (): boolean => true, // On Pan Responder Termination Request onPanResponderTerminationRequest: (): boolean => true, // On Pan Responder Terminate onPanResponderTerminate: (): boolean => true, // On Should Block Native Responder onShouldBlockNativeResponder: (): boolean => true, // On Pan Responder Grant onPanResponderGrant: ({ nativeEvent }, gestureState: PanResponderGestureState): void => { // TODO (WHAT?) (REFACTOR WITH !disabled) if (disabled) { return; } // Gesture State: Number Of Active Touches (Currently On Screen) const { numberActiveTouches } = gestureState; if (numberActiveTouches > 1) { return; } // TODO (WHAT?) const { locationX: downX, pageX } = nativeEvent; // Container X const containerX: number = pageX - downX; // TODO (WHAT?) const { low, high, min, max } = inPropsRef.current; // Container Width const containerWidth: number = containerWidthRef.current; // Low Position const lowPosition: number = thumbWidth / 2 + ((low - min) / (max - min)) * (containerWidth - thumbWidth); // High Position const highPosition: number = thumbWidth / 2 + ((high - min) / (max - min)) * (containerWidth - thumbWidth); // Is Low const isLow: boolean = type === 'Single' || isLowCloser(downX, lowPosition, highPosition); // TODO (WHAT?) gestureStateRef.current.isLow = isLow; // Handle Position Change const handlePositionChange = (positionInView: number): void => { // TODO (WHAT?) const { low, high, min, max, step } = inPropsRef.current; // Min Value const minValue: number = isLow ? min : low + minRange; // Max Value const maxValue: number = isLow ? high - minRange : max; // Value const value: number = clamp(getValueForPosition(positionInView, containerWidth, thumbWidth, min, max, step), minValue, maxValue); // TODO (WHAT?) if (gestureStateRef.current.lastValue === value) { return; } // Available Space const availableSpace: number = containerWidth - thumbWidth; // Absolute Position const absolutePosition: number = ((value - min) / (max - min)) * availableSpace; // Ref: Last Value (Gesture State) gestureStateRef.current.lastValue = value; // Ref: Last Position (Gesture State) gestureStateRef.current.lastPosition = absolutePosition + thumbWidth / 2; // Ref: Set Value (Absolute Position) (isLow ? lowThumbXRef.current : highThumbXRef.current).setValue(absolutePosition); // Props: On Change onChange?.(isLow ? value : low, isLow ? high : value); // TODO (WHAT?) (isLow ? setLow : setHigh)(value); // Update Selected Rail updateSelectedRail(); }; // Handle Position Change handlePositionChange(downX); // Ref: Remove All Listeners (Pointer X) pointerX.removeAllListeners(); // Ref: Add Listener (Pointer X) pointerX.addListener(({ value: pointerPosition }): void => { // Position In View const positionInView: number = pointerPosition - containerX; // Handle Position Change handlePositionChange(positionInView); }); }, // On Pan Responder Move onPanResponderMove: disabled ? undefined : Animated.event([null, { moveX: pointerX }], { useNativeDriver: false }), // On Pan Responder Release onPanResponderRelease: (): void => { return; }, }), [disabled, pointerX, inPropsRef, thumbWidth, type, minRange, onChange, setLow, setHigh, updateSelectedRail], ); return (<View style={{ width: width - 32 }}><View onLayout={handleContainerLayout} style={styles.controlsContainer}><View style={[styles.railsContainer, { marginHorizontal: thumbWidth / 2 }]}><View style={[styles.railContainer, darkMode ? styles.railContainerDark : styles.railContainerLight]} /><Animated.View style={selectedRailStyle}><View style={[styles.railSelectedContainer, darkMode ? styles.railSelectedContainerDark : styles.railSelectedContainerLight]} /></Animated.View></View><Animated.View style={{ transform: [{ translateX: lowThumbXRef.current }] }} onLayout={handleThumbLayout}><View style={[styles.thumbContainer, darkMode ? styles.thumbContainerDark : styles.thumbContainerLight]} /></Animated.View> {type === 'Range'&& (<Animated.View style={[styles.highThumbContainer, { transform: [{ translateX: highThumbXRef.current }] }]}><View style={[styles.thumbContainer, darkMode ? styles.thumbContainerDark : styles.thumbContainerLight]} /></Animated.View> )}<View {...panHandlers} style={styles.touchableArea} collapsible={false} /></View></View> );};// Stylesconst styles = StyleSheet.create({ controlsContainer: { flexDirection: 'row', justifyContent: 'flex-start', alignItems: 'center', }, highThumbContainer: { position: 'absolute', }, railsContainer: { ...StyleSheet.absoluteFillObject, flexDirection: 'row', alignItems: 'center', }, labelFixedContainer: { alignItems: 'flex-start', }, labelFloatingContainer: { position: 'absolute', alignItems: 'flex-start', left: 0, right: 0, }, touchableArea: { ...StyleSheet.absoluteFillObject, }, railContainer: { flex: 1, height: 2, borderRadius: 2, }, railContainerLight: { backgroundColor: defaultStyles.colorLightBorder, }, railContainerDark: { backgroundColor: defaultStyles.colorDarkBorder, }, railSelectedContainer: { height: 2, borderRadius: 2, }, railSelectedContainerLight: { backgroundColor: defaultStyles.colorLightBlue, }, railSelectedContainerDark: { backgroundColor: defaultStyles.colorDarkBlue, }, thumbContainer: { height: 30, width: 30, backgroundColor: '#FFFFFF', borderRadius: 30, borderWidth: 0.5, shadowOffset: { width: 0, height: 3, }, shadowRadius: 1, shadowOpacity: 0.1, }, thumbContainerLight: { borderColor: defaultStyles.colorLightBorder, }, thumbContainerDark: { borderColor: defaultStyles.colorDarkBorder, },});