UNPKG

react-native-fast-range-slider

Version:

A high-performance React Native range slider with smooth animations and precise touch controls using react-native-reanimated

380 lines (358 loc) 12.1 kB
"use strict"; import { useState, useCallback, forwardRef, useEffect, useMemo } from 'react'; import { View, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { useAnimatedStyle, useSharedValue, useDerivedValue, runOnJS } from 'react-native-reanimated'; import { Gesture, GestureDetector } from 'react-native-gesture-handler'; import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime"; // ================ Constants ================ const HORIZONTAL_PADDING = 15; const TOUCH_HITSLOP = { top: 20, bottom: 20, left: 20, right: 20 }; const MIN_THUMB_SPACING = 16; const DEFAULT_VALUES = { WIDTH: 270, THUMB_SIZE: 32, TRACK_HEIGHT: 2.5, STEP: 1, LEFT_THUMB_LABEL: 'Left handle', RIGHT_THUMB_LABEL: 'Right handle', MINIMUM_DISTANCE: MIN_THUMB_SPACING, SHOW_THUMB_LINES: true }; // ================ Types ================ // ================ Styles ================ const styles = StyleSheet.create({ markerContainer: { overflow: 'visible' } }); const staticStyles = StyleSheet.create({ container: { height: 50, justifyContent: 'center', paddingHorizontal: HORIZONTAL_PADDING }, markerLine: { width: 1, height: 12, backgroundColor: '#8E8E8E', marginHorizontal: 2 }, thumbInner: { width: '100%', height: '100%', borderRadius: 999, backgroundColor: 'white', justifyContent: 'center', alignItems: 'center', flexDirection: 'row' } }); const createDynamicStyles = props => ({ root: { width: props.width + HORIZONTAL_PADDING * 2, alignSelf: 'center' }, track: { position: 'absolute', height: props.trackHeight, width: props.width, left: HORIZONTAL_PADDING }, selectedTrack: { position: 'absolute', height: props.trackHeight }, thumb: { height: props.thumbSize, width: props.thumbSize, borderRadius: props.thumbSize / 2, backgroundColor: 'white', position: 'absolute', left: 0, top: '50%', marginTop: -(props.thumbSize / 2), justifyContent: 'center', alignItems: 'center', flexDirection: 'row', opacity: props.enabled ? 1 : 0.5, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.2, shadowRadius: 4, elevation: 3 } }); // ================ Component ================ const RangeSlider = ({ // Core props initialMinValue, initialMaxValue, min, max, step = DEFAULT_VALUES.STEP, // Style props selectedTrackStyle, unselectedTrackStyle, thumbStyle, pressedThumbStyle, containerStyle, selectedTrackColor, // Customization props width = DEFAULT_VALUES.WIDTH, thumbSize = DEFAULT_VALUES.THUMB_SIZE, trackHeight = DEFAULT_VALUES.TRACK_HEIGHT, minimumDistance = DEFAULT_VALUES.MINIMUM_DISTANCE, // Behavior props enabled = true, allowOverlap = false, // Callback props onValuesChange = () => {}, onValuesChangeFinish = () => {}, onValuesChangeStart = () => {}, // Accessibility props leftThumbAccessibilityLabel = DEFAULT_VALUES.LEFT_THUMB_LABEL, rightThumbAccessibilityLabel = DEFAULT_VALUES.RIGHT_THUMB_LABEL, testID, // Visual props showThumbLines = DEFAULT_VALUES.SHOW_THUMB_LINES }, _ref) => { // Track pressed state const [pressed, setPressed] = useState({ left: false, right: false }); // Memoize effective width calculation const effectiveSliderWidth = useMemo(() => width || DEFAULT_VALUES.WIDTH, [width]); // Memoize initial values and positions const initialPositions = useMemo(() => { const leftValue = Math.max(min, Math.min(max, initialMinValue ?? min)); const rightValue = Math.max(min, Math.min(max, initialMaxValue ?? max)); const normalizedLeft = (leftValue - min) / (max - min); const normalizedRight = (rightValue - min) / (max - min); return { left: HORIZONTAL_PADDING + normalizedLeft * effectiveSliderWidth, right: HORIZONTAL_PADDING + normalizedRight * effectiveSliderWidth }; }, [min, max, initialMinValue, initialMaxValue, effectiveSliderWidth]); // Initialize shared values with correct positions immediately const leftPos = useSharedValue(initialPositions.left); const rightPos = useSharedValue(initialPositions.right); const leftOffset = useSharedValue(initialPositions.left); const rightOffset = useSharedValue(initialPositions.right); // For position calculations const calculatePosition = useCallback(value => { 'worklet'; const normalizedValue = (value - min) / (max - min); const position = HORIZONTAL_PADDING + normalizedValue * effectiveSliderWidth; return position; }, [min, max, effectiveSliderWidth]); const leftTransform = useDerivedValue(() => { 'worklet'; return [{ translateX: leftPos.value - HORIZONTAL_PADDING }]; }); const rightTransform = useDerivedValue(() => { 'worklet'; return [{ translateX: rightPos.value - HORIZONTAL_PADDING }]; }); const trackStyle = useDerivedValue(() => { 'worklet'; return { left: leftPos.value, width: rightPos.value - leftPos.value }; }); const leftThumbStyle = useAnimatedStyle(() => ({ transform: leftTransform.value })); const rightThumbStyle = useAnimatedStyle(() => ({ transform: rightTransform.value })); const animatedTrackStyle = useAnimatedStyle(() => ({ left: trackStyle.value.left, width: trackStyle.value.width })); const convertPositionToValue = useCallback(position => { 'worklet'; const value = min + (position - HORIZONTAL_PADDING) / effectiveSliderWidth * (max - min); return value; }, [min, max, effectiveSliderWidth]); const updateValues = useCallback((leftPosition, rightPosition) => { 'worklet'; const leftValue = convertPositionToValue(leftPosition); const rightValue = convertPositionToValue(rightPosition); const rounded = [Math.round(leftValue / step) * step, Math.round(rightValue / step) * step]; return rounded; }, [convertPositionToValue, step]); // Handle value changes const updateOutputValues = useCallback(values => { onValuesChange(values); }, [onValuesChange]); const leftGesture = Gesture.Pan().hitSlop(TOUCH_HITSLOP).onStart(() => { 'worklet'; if (!enabled) return; runOnJS(setPressed)({ left: true, right: false }); runOnJS(onValuesChangeStart)(updateValues(leftPos.value, rightPos.value)); }).onUpdate(event => { 'worklet'; if (!enabled) return; const position = leftOffset.value + event.translationX; const minPosition = HORIZONTAL_PADDING; const maxPosition = allowOverlap ? rightPos.value : rightPos.value - minimumDistance; const clampedPosition = Math.max(minPosition, Math.min(maxPosition, position)); leftPos.value = clampedPosition; const newValues = updateValues(clampedPosition, rightPos.value); runOnJS(updateOutputValues)(newValues); }).onEnd(() => { 'worklet'; if (!enabled) return; const values = updateValues(leftPos.value, rightPos.value); const leftValue = values[0]; const newLeftPos = calculatePosition(leftValue); leftPos.value = newLeftPos; leftOffset.value = newLeftPos; runOnJS(onValuesChangeFinish)(values); runOnJS(setPressed)({ left: false, right: false }); }); const rightGesture = Gesture.Pan().hitSlop(TOUCH_HITSLOP).onStart(() => { 'worklet'; if (!enabled) return; runOnJS(setPressed)({ left: false, right: true }); runOnJS(onValuesChangeStart)(updateValues(leftPos.value, rightPos.value)); }).onUpdate(event => { 'worklet'; if (!enabled) return; const position = rightOffset.value + event.translationX; const minPosition = allowOverlap ? leftPos.value : leftPos.value + minimumDistance; const maxPosition = effectiveSliderWidth + HORIZONTAL_PADDING; const clampedPosition = Math.max(minPosition, Math.min(maxPosition, position)); rightPos.value = clampedPosition; const newValues = updateValues(leftPos.value, clampedPosition); runOnJS(updateOutputValues)(newValues); }).onEnd(() => { 'worklet'; if (!enabled) return; const values = updateValues(leftPos.value, rightPos.value); const rightValue = values[1]; const newRightPos = calculatePosition(rightValue); rightPos.value = newRightPos; rightOffset.value = newRightPos; runOnJS(onValuesChangeFinish)(values); runOnJS(setPressed)({ left: false, right: false }); }); // Update positions only when initial values change externally useEffect(() => { // Only update if not currently being dragged if (!pressed.left && !pressed.right) { leftPos.value = initialPositions.left; rightPos.value = initialPositions.right; leftOffset.value = initialPositions.left; rightOffset.value = initialPositions.right; } // eslint-disable-next-line react-hooks/exhaustive-deps }, [initialMinValue, initialMaxValue]); const dynamicStyles = createDynamicStyles({ width: effectiveSliderWidth, thumbSize, trackHeight, enabled }); return /*#__PURE__*/_jsx(GestureHandlerRootView, { style: [dynamicStyles.root, { direction: 'ltr' }], testID: testID, children: /*#__PURE__*/_jsxs(View, { style: [staticStyles.container, containerStyle, { direction: 'ltr' }], children: [/*#__PURE__*/_jsx(View, { style: [dynamicStyles.track, { backgroundColor: '#CECECE' }, unselectedTrackStyle] }), /*#__PURE__*/_jsx(Animated.View, { style: [dynamicStyles.selectedTrack, { backgroundColor: selectedTrackColor || '#2196F3' }, selectedTrackStyle, animatedTrackStyle] }), /*#__PURE__*/_jsx(Animated.View, { style: [dynamicStyles.thumb, styles.markerContainer, thumbStyle, pressed.left && pressedThumbStyle, leftThumbStyle], children: /*#__PURE__*/_jsx(GestureDetector, { gesture: leftGesture, children: /*#__PURE__*/_jsx(Animated.View, { accessible: true, accessibilityLabel: leftThumbAccessibilityLabel, accessibilityRole: "adjustable", style: [{ width: '100%', height: '100%' }], children: /*#__PURE__*/_jsx(View, { style: staticStyles.thumbInner, children: showThumbLines && /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(View, { style: staticStyles.markerLine }), /*#__PURE__*/_jsx(View, { style: staticStyles.markerLine }), /*#__PURE__*/_jsx(View, { style: staticStyles.markerLine })] }) }) }) }) }), /*#__PURE__*/_jsx(Animated.View, { style: [dynamicStyles.thumb, styles.markerContainer, thumbStyle, pressed.right && pressedThumbStyle, rightThumbStyle], children: /*#__PURE__*/_jsx(GestureDetector, { gesture: rightGesture, children: /*#__PURE__*/_jsx(Animated.View, { accessible: true, accessibilityLabel: rightThumbAccessibilityLabel, accessibilityRole: "adjustable", style: [{ width: '100%', height: '100%' }], children: /*#__PURE__*/_jsx(View, { style: staticStyles.thumbInner, children: showThumbLines && /*#__PURE__*/_jsxs(_Fragment, { children: [/*#__PURE__*/_jsx(View, { style: staticStyles.markerLine }), /*#__PURE__*/_jsx(View, { style: staticStyles.markerLine }), /*#__PURE__*/_jsx(View, { style: staticStyles.markerLine })] }) }) }) }) })] }) }); }; export default /*#__PURE__*/forwardRef(RangeSlider); //# sourceMappingURL=RangeSlider.js.map