import React, { useCallback, useMemo, useState } from "react"; import { I18nManager, StyleSheet, View } from "react-native"; import { Gesture, GestureDetector } from "react-native-gesture-handler"; import Animated, { runOnJS, useAnimatedStyle, useSharedValue, } from "react-native-reanimated"; import { ThemeType, useTheme } from "../../theme"; import { SliderLabels } from "./SliderLabel"; import { Input } from "../Input"; const THUMB_SIZE = 28; const THUMB_RADIUS = THUMB_SIZE / 2; const RAIL_HEIGHT = 6; const LABEL_HEIGHT = 26; type ScaleType = "linear" | "logarithmic" | "percentile"; interface RangeSliderProps { min: number; max: number; step?: number; sliderWidth: number; initialMinValue: number; initialMaxValue: number; onValueChange: (values: { min: number; max: number }) => void; theme?: ThemeType; scale?: ScaleType; dataPoints?: number[]; showCustomInputs?: boolean; inputPlaceholders?: { min?: string; max?: string; }; inputLabels?: { min?: string; max?: string; }; isBottomSheet?: boolean; } /** * Creates percentile breakpoints from data * Returns sorted unique values that represent the distribution */ function createPercentileBreakpoints(data: number[]): number[] { const sorted = [...new Set(data)].sort((a, b) => a - b); if (sorted.length <= 100) { return sorted; } const breakpoints: number[] = []; const numBreakpoints = 100; for (let i = 0; i <= numBreakpoints; i++) { const index = Math.floor((i / numBreakpoints) * (sorted.length - 1)); const value = sorted[index]; if (breakpoints[breakpoints.length - 1] !== value) { breakpoints.push(value); } } return breakpoints; } /** * Binary search to find position in breakpoints */ function findBreakpointIndex(breakpoints: number[], value: number): number { "worklet"; let left = 0; let right = breakpoints.length - 1; while (left < right) { const mid = Math.floor((left + right) / 2); if (breakpoints[mid] < value) { left = mid + 1; } else { right = mid; } } return left; } const RangeSlider: React.FC = ({ min, max, step = 1, sliderWidth, initialMinValue, initialMaxValue, onValueChange, theme, scale = "linear", dataPoints, showCustomInputs = false, inputPlaceholders, inputLabels, isBottomSheet = false, }) => { const { theme: defaultTheme } = useTheme(); const currTheme = theme || defaultTheme; const styles = getStyles(currTheme); const isRTL = I18nManager.isRTL; const [leftLabel, setLeftLabel] = useState(initialMinValue.toLocaleString()); const [rightLabel, setRightLabel] = useState( initialMaxValue.toLocaleString(), ); // State for custom input fields const [minInputValue, setMinInputValue] = useState( initialMinValue.toString(), ); const [maxInputValue, setMaxInputValue] = useState( initialMaxValue.toString(), ); // The effective track width (where thumb CENTER can travel) const effectiveWidth = sliderWidth - THUMB_SIZE; // Pre-compute breakpoints for percentile scale const breakpoints = useMemo(() => { if (scale === "percentile" && dataPoints?.length) { return createPercentileBreakpoints(dataPoints); } return []; }, [scale, dataPoints]); const safeMin = scale === "logarithmic" ? Math.max(min, 0.001) : min; const valueToPosition = useCallback( (value: number) => { "worklet"; let percentage: number; if (scale === "percentile" && breakpoints.length > 1) { const index = findBreakpointIndex(breakpoints, value); if (index === 0) { percentage = 0; } else if (index >= breakpoints.length - 1) { percentage = 1; } else { const lowerVal = breakpoints[index - 1]; const upperVal = breakpoints[index]; const lowerPct = (index - 1) / (breakpoints.length - 1); const upperPct = index / (breakpoints.length - 1); if (upperVal === lowerVal) { percentage = lowerPct; } else { const ratio = (value - lowerVal) / (upperVal - lowerVal); percentage = lowerPct + ratio * (upperPct - lowerPct); } } } else if (scale === "logarithmic") { const logMin = Math.log(safeMin); const logMax = Math.log(max); const logValue = Math.log(Math.max(value, safeMin)); percentage = (logValue - logMin) / (logMax - logMin); } else { percentage = (value - min) / (max - min); } // Map to effective width, offset by thumb radius const position = percentage * effectiveWidth + THUMB_RADIUS; return isRTL ? sliderWidth - position : position; }, [min, max, safeMin, sliderWidth, effectiveWidth, isRTL, scale, breakpoints], ); const positionToValue = useCallback( (position: number) => { "worklet"; // Convert position to percentage using effective width const adjustedPosition = isRTL ? sliderWidth - position : position; const percentage = (adjustedPosition - THUMB_RADIUS) / effectiveWidth; const clampedPercentage = Math.max(0, Math.min(1, percentage)); let rawValue: number; if (scale === "percentile" && breakpoints.length > 1) { const exactIndex = clampedPercentage * (breakpoints.length - 1); const lowerIndex = Math.floor(exactIndex); const upperIndex = Math.min(lowerIndex + 1, breakpoints.length - 1); const ratio = exactIndex - lowerIndex; rawValue = breakpoints[lowerIndex] + ratio * (breakpoints[upperIndex] - breakpoints[lowerIndex]); } else if (scale === "logarithmic") { const logMin = Math.log(safeMin); const logMax = Math.log(max); rawValue = Math.exp(logMin + clampedPercentage * (logMax - logMin)); } else { rawValue = clampedPercentage * (max - min) + min; } return Math.round(rawValue / step) * step; }, [ min, max, safeMin, step, sliderWidth, effectiveWidth, isRTL, scale, breakpoints, ], ); const leftPosition = useSharedValue(valueToPosition(initialMinValue)); const rightPosition = useSharedValue(valueToPosition(initialMaxValue)); const context = useSharedValue({ x: 0 }); const activeThumb = useSharedValue<"left" | "right" | null>(null); const panGesture = Gesture.Pan() .onBegin((e) => { const distToLeft = Math.abs(e.x - leftPosition.value); const distToRight = Math.abs(e.x - rightPosition.value); if (distToLeft <= distToRight) { activeThumb.value = "left"; context.value = { x: leftPosition.value }; } else { activeThumb.value = "right"; context.value = { x: rightPosition.value }; } }) .onUpdate((e) => { if (activeThumb.value === null) return; const newPos = context.value.x + e.translationX; if (activeThumb.value === "left") { const lowerBound = isRTL ? rightPosition.value + THUMB_SIZE : THUMB_RADIUS; const upperBound = isRTL ? sliderWidth - THUMB_RADIUS : rightPosition.value - THUMB_SIZE; const clampedPos = Math.max(Math.min(newPos, upperBound), lowerBound); leftPosition.value = clampedPos; const newValue = positionToValue(clampedPos); runOnJS(setLeftLabel)(newValue.toLocaleString()); runOnJS(setMinInputValue)(newValue.toString()); } else { const lowerBound = isRTL ? THUMB_RADIUS : leftPosition.value + THUMB_SIZE; const upperBound = isRTL ? leftPosition.value - THUMB_SIZE : sliderWidth - THUMB_RADIUS; const clampedPos = Math.max(Math.min(newPos, upperBound), lowerBound); rightPosition.value = clampedPos; const newValue = positionToValue(clampedPos); runOnJS(setRightLabel)(newValue.toLocaleString()); runOnJS(setMaxInputValue)(newValue.toString()); } }) .onEnd(() => { if (activeThumb.value === null) return; const finalLeftValue = positionToValue(leftPosition.value); const finalRightValue = positionToValue(rightPosition.value); runOnJS(onValueChange)({ min: finalLeftValue, max: finalRightValue }); activeThumb.value = null; }); // Handlers for custom input fields const handleMinInputChange = useCallback((text: string) => { // Allow only numeric input (with optional decimal) const sanitized = text.replace(/[^0-9.]/g, ""); setMinInputValue(sanitized); }, []); const handleMaxInputChange = useCallback((text: string) => { const sanitized = text.replace(/[^0-9.]/g, ""); setMaxInputValue(sanitized); }, []); const handleMinInputSubmit = useCallback(() => { const parsed = parseFloat(minInputValue); if (isNaN(parsed)) { // Reset to current slider value const currentValue = positionToValue(leftPosition.value); setMinInputValue(currentValue.toString()); return; } // Clamp value: must be >= min and <= current max value const currentMaxValue = positionToValue(rightPosition.value); const clampedValue = Math.max( min, Math.min(parsed, currentMaxValue - step), ); const steppedValue = Math.round(clampedValue / step) * step; // Update input display setMinInputValue(steppedValue.toString()); setLeftLabel(steppedValue.toLocaleString()); // Update slider position leftPosition.value = valueToPosition(steppedValue); // Trigger callback onValueChange({ min: steppedValue, max: currentMaxValue }); }, [ minInputValue, min, step, leftPosition, rightPosition, positionToValue, valueToPosition, onValueChange, ]); const handleMaxInputSubmit = useCallback(() => { const parsed = parseFloat(maxInputValue); if (isNaN(parsed)) { // Reset to current slider value const currentValue = positionToValue(rightPosition.value); setMaxInputValue(currentValue.toString()); return; } // Clamp value: must be <= max and >= current min value const currentMinValue = positionToValue(leftPosition.value); const clampedValue = Math.min( max, Math.max(parsed, currentMinValue + step), ); const steppedValue = Math.round(clampedValue / step) * step; // Update input display setMaxInputValue(steppedValue.toString()); setRightLabel(steppedValue.toLocaleString()); // Update slider position rightPosition.value = valueToPosition(steppedValue); // Trigger callback onValueChange({ min: currentMinValue, max: steppedValue }); }, [ maxInputValue, max, step, leftPosition, rightPosition, positionToValue, valueToPosition, onValueChange, ]); // Thumb position = center of thumb, so offset by radius to get left edge const animatedLeftThumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: leftPosition.value - THUMB_RADIUS }], })); const animatedRightThumbStyle = useAnimatedStyle(() => ({ transform: [{ translateX: rightPosition.value - THUMB_RADIUS }], })); const animatedActiveRailStyle = useAnimatedStyle(() => { const start = Math.min(leftPosition.value, rightPosition.value); const end = Math.max(leftPosition.value, rightPosition.value); return { left: start, right: sliderWidth - end, }; }); return ( {showCustomInputs ? ( ) : ( )} ); }; const getStyles = (theme: ThemeType) => StyleSheet.create({ container: { height: LABEL_HEIGHT + THUMB_SIZE, justifyContent: "center", marginTop: LABEL_HEIGHT, direction: "ltr", }, containerWithInputs: { direction: "ltr", }, railContainer: { justifyContent: "center", height: THUMB_SIZE, }, rail: { height: RAIL_HEIGHT, borderRadius: RAIL_HEIGHT / 2, backgroundColor: theme.surface, }, activeRail: { height: RAIL_HEIGHT, backgroundColor: theme.primary, position: "absolute", }, thumbContainer: { position: "absolute", width: THUMB_SIZE, height: THUMB_SIZE, justifyContent: "center", alignItems: "center", }, thumb: { width: THUMB_SIZE, height: THUMB_SIZE, borderRadius: THUMB_SIZE / 2, backgroundColor: theme.background, borderColor: theme.primary, borderWidth: 5, }, inputsContainer: { flexDirection: "row", justifyContent: "space-between", gap: 16, marginBottom: 12, direction: I18nManager.isRTL ? "rtl" : "ltr", }, inputWrapper: { flex: 1, }, inputLabel: { color: theme.onSurface, fontSize: 13, fontFamily: "DinMedium", marginBottom: 6, }, input: { backgroundColor: theme.background, paddingHorizontal: 16, paddingVertical: 10, borderRadius: 8, borderWidth: 1, borderColor: theme.surfaceVariant, color: theme.onSurface, fontSize: 15, fontFamily: "DinBold", textAlign: "center", }, }); export default RangeSlider;