From e5008c39db9a7b12ef191660f3853834a6808a5e Mon Sep 17 00:00:00 2001 From: Maciej Lodygowski Date: Fri, 19 Jun 2026 11:08:53 +0200 Subject: [PATCH] feat: add range slider mode --- README.md | 6 + example-web/src/Examples.tsx | 28 ++ example/src/Examples.tsx | 28 ++ package/__test__/Slider.test.tsx | 64 ++++ package/package.json | 1 + package/src/RangeSlider.tsx | 572 +++++++++++++++++++++++++++++++ package/src/Slider.tsx | 189 +++++++--- package/typings/index.d.ts | 47 +++ 8 files changed, 895 insertions(+), 40 deletions(-) create mode 100644 package/src/RangeSlider.tsx diff --git a/README.md b/README.md index 4d42aa5d..76468a40 100644 --- a/README.md +++ b/README.md @@ -83,13 +83,19 @@ To use this library you need to ensure you are using the correct version of Reac | `minimumValue` | Initial minimum value of the slider.
Default value is 0. | number | | | `lowerLimit` | Slide lower limit. The user won't be able to slide below this limit. | number | Android, iOS, Web | | `upperLimit` | Slide upper limit. The user won't be able to slide above this limit. | number | Android, iOS, Web | +| `minimumRange` | Minimum distance between lower and upper thumbs when `range` is true.
Default value is 0. | number | | | `onSlidingStart` | Callback that is called when the user picks up the slider.
The initial value is passed as an argument to the callback handler. | function | | | `onSlidingComplete` | Callback that is called when the user releases the slider, regardless if the value has changed.
The current value is passed as an argument to the callback handler. | function | | | `onValueChange` | Callback continuously called while the user is dragging the slider. | function | | +| `onRangeSlidingStart` | Callback that is called when the user touches either range thumb.
The current values and active thumb index are passed as arguments. Used when `range` is true. | function | | +| `onRangeSlidingComplete` | Callback that is called when the user releases either range thumb.
The current values and active thumb index are passed as arguments. Used when `range` is true. | function | | +| `onValuesChange` | Callback continuously called while the user is dragging either range thumb.
The current values and active thumb index are passed as arguments. Used when `range` is true. | function | | | `step` | Step value of the slider. The value should be between 0 and (maximumValue - minimumValue). Default value is 0.
On Windows OS the default value is 1% of slider's range (from `minimumValue` to `maximumValue`). | number | | | `maximumTrackTintColor` | The color used for the track to the right of the button.
Overrides the default gray gradient image on iOS. | [color](https://reactnative.dev/docs/colors) | | | `testID` | Used to locate this view in UI automation tests. | string | | | `value` | Write-only property representing the value of the slider. Can be used to programmatically control the position of the thumb. Entered once at the beginning still acts as an initial value. Changing the value programmatically does not trigger any event.
The value should be between minimumValue and maximumValue, which default to 0 and 1 respectively. Default value is 0.
_This is not a controlled component_, you don't need to update the value during dragging. | number | | +| `range` | Enables two-thumb range selection.
Default value is false. | bool | | +| `values` | Write-only property representing the lower and upper values of the range slider. Used when `range` is true. | [number, number] | | | `tapToSeek` | Permits tapping on the slider track to set the thumb position.
Defaults to false on iOS. No effect on Android or Windows. | bool | iOS | | `inverted` | Reverses the direction of the slider.
Default value is false. | bool | | | `vertical` | Changes the orientation of the slider to vertical, if set to `true`.
Default value is false. | bool | Windows | diff --git a/example-web/src/Examples.tsx b/example-web/src/Examples.tsx index b91f4307..9d904799 100644 --- a/example-web/src/Examples.tsx +++ b/example-web/src/Examples.tsx @@ -25,6 +25,28 @@ const SliderExample = (props: SliderProps) => { ); }; +const RangeSliderExample = () => { + const [values, setValues] = useState<[number, number]>([20, 80]); + + return ( + + + {values[0].toFixed(0)} - {values[1].toFixed(0)} + + setValues(nextValues)} + /> + + ); +}; + const SlidingStartExample = (props: SliderProps) => { const [slideStartingValue, setSlideStartingValue] = useState(0); const [slideStartingCount, setSlideStartingCount] = useState(0); @@ -105,6 +127,12 @@ export const examples: Props[] = [ return ; }, }, + { + title: 'Range slider', + render(): React.ReactElement { + return ; + }, + }, { title: 'step: 0.25, tap to seek on iOS', render(): React.ReactElement { diff --git a/example/src/Examples.tsx b/example/src/Examples.tsx index cb218c63..6030d9f9 100644 --- a/example/src/Examples.tsx +++ b/example/src/Examples.tsx @@ -31,6 +31,28 @@ const SliderExample = (props: SliderProps) => { ); }; +const RangeSliderExample = () => { + const [values, setValues] = useState<[number, number]>([20, 80]); + + return ( + + + {values[0].toFixed(0)} - {values[1].toFixed(0)} + + setValues(nextValues)} + /> + + ); +}; + const SlidingStartExample = (props: SliderProps) => { const [slideStartingValue, setSlideStartingValue] = useState(0); const [slideStartingCount, setSlideStartingCount] = useState(0); @@ -594,6 +616,12 @@ export const examples: Props[] = [ ); }, }, + { + title: 'Range slider', + render() { + return ; + }, + }, { title: 'onSlidingStart', render(): React.ReactElement { diff --git a/package/__test__/Slider.test.tsx b/package/__test__/Slider.test.tsx index f2073126..7e29fa4c 100644 --- a/package/__test__/Slider.test.tsx +++ b/package/__test__/Slider.test.tsx @@ -77,4 +77,68 @@ describe('Slider', () => { fireEvent(getByTestId('slider'), 'onResponderTerminationRequest'); expect(mockedRelease).not.toHaveBeenCalled(); }); + + it('Calls the given onValuesChange when a range thumb is moved', () => { + const onValuesChange = jest.fn(); + const {getByTestId} = render( + , + ); + const slider = getByTestId('slider'); + + fireEvent(slider, 'onLayout', {nativeEvent: {layout: {width: 120}}}); + fireEvent(slider, 'onResponderGrant', {nativeEvent: {locationX: 30}}); + fireEvent(slider, 'onResponderMove', {nativeEvent: {locationX: 50}}); + + expect(onValuesChange).toHaveBeenCalledWith([4, 8], 0); + }); + + it('Calls the given onRangeSlidingComplete when a range thumb is released', () => { + const onRangeSlidingComplete = jest.fn(); + const {getByTestId} = render( + , + ); + const slider = getByTestId('slider'); + + fireEvent(slider, 'onLayout', {nativeEvent: {layout: {width: 120}}}); + fireEvent(slider, 'onResponderGrant', {nativeEvent: {locationX: 90}}); + fireEvent(slider, 'onResponderRelease', {nativeEvent: {locationX: 80}}); + + expect(onRangeSlidingComplete).toHaveBeenCalledWith([2, 7], 1); + }); + + it('Keeps range thumbs from crossing each other', () => { + const onValuesChange = jest.fn(); + const {getByTestId} = render( + , + ); + const slider = getByTestId('slider'); + + fireEvent(slider, 'onLayout', {nativeEvent: {layout: {width: 120}}}); + fireEvent(slider, 'onResponderGrant', {nativeEvent: {locationX: 30}}); + fireEvent(slider, 'onResponderMove', {nativeEvent: {locationX: 100}}); + + expect(onValuesChange).toHaveBeenCalledWith([4, 5], 0); + }); }); diff --git a/package/package.json b/package/package.json index 63179687..ab9b6204 100644 --- a/package/package.json +++ b/package/package.json @@ -61,6 +61,7 @@ "preset": "react-native", "verbose": true, "modulePathIgnorePatterns": [ + "/dist/", "/e2e/" ] }, diff --git a/package/src/RangeSlider.tsx b/package/src/RangeSlider.tsx new file mode 100644 index 00000000..2f316c8b --- /dev/null +++ b/package/src/RangeSlider.tsx @@ -0,0 +1,572 @@ +import React, {Ref, useCallback, useEffect, useRef, useState} from 'react'; +import { + AccessibilityActionEvent, + ColorValue, + GestureResponderEvent, + Image, + ImageStyle, + ImageSource, + ImageSourcePropType, + LayoutChangeEvent, + StyleProp, + View, + ViewProps, + ViewStyle, +} from 'react-native'; +import type {FC} from 'react'; +import {constants} from './utils/constants'; +import type {MarkerProps} from './components/TrackMark'; + +export type RangeValue = [number, number]; +export type RangeThumbIndex = 0 | 1; + +type RangeSliderProps = ViewProps & + Readonly<{ + value?: number; + values?: RangeValue; + minimumValue: number; + maximumValue: number; + lowerLimit: number; + upperLimit: number; + minimumRange?: number; + step: number; + inverted: boolean; + disabled?: boolean; + style?: StyleProp; + minimumTrackTintColor?: ColorValue; + maximumTrackTintColor?: ColorValue; + thumbTintColor?: ColorValue; + thumbImage?: ImageSource; + thumbSize?: number; + StepMarker?: FC; + renderStepNumber?: boolean; + accessibilityUnits?: string; + accessibilityIncrements?: Array; + maximumTrackImage?: ImageSource; + minimumTrackImage?: ImageSource; + tapToSeek?: boolean; + trackImage?: ImageSource; + vertical?: boolean; + onValuesChange?: (values: RangeValue, thumbIndex: RangeThumbIndex) => void; + onRangeSlidingStart?: ( + values: RangeValue, + thumbIndex: RangeThumbIndex, + ) => void; + onRangeSlidingComplete?: ( + values: RangeValue, + thumbIndex: RangeThumbIndex, + ) => void; + }>; + +const DEFAULT_MINIMUM_TRACK_TINT_COLOR = '#007aff'; +const DEFAULT_MAXIMUM_TRACK_TINT_COLOR = '#b3b3b3'; +const DEFAULT_THUMB_TINT_COLOR = '#ffffff'; +const TRACK_HEIGHT = 4; +const THUMB_IMAGE_STYLE: ImageStyle = {width: '100%', height: '100%'}; + +const getPrecision = ( + minimumValue: number, + maximumValue: number, + step: number, +) => { + if (!step) { + return Infinity; + } + + const decimals = [minimumValue, maximumValue, step].map( + (value) => ((value + '').split('.').pop() || '').length, + ); + return Math.max(...decimals); +}; + +const roundToStep = ( + value: number, + minimumValue: number, + maximumValue: number, + step: number, +) => { + if (!step) { + return value; + } + + const precision = getPrecision(minimumValue, maximumValue, step); + const stepped = + minimumValue + Math.round((value - minimumValue) / step) * step; + + return precision < 20 + ? Number.parseFloat(stepped.toFixed(precision)) + : stepped; +}; + +const clamp = (value: number, minimumValue: number, maximumValue: number) => { + return Math.max(minimumValue, Math.min(value, maximumValue)); +}; + +const getBounds = ({ + minimumValue, + maximumValue, + lowerLimit, + upperLimit, +}: Pick< + RangeSliderProps, + 'minimumValue' | 'maximumValue' | 'lowerLimit' | 'upperLimit' +>) => { + return { + minimum: Math.max(minimumValue, lowerLimit), + maximum: Math.min(maximumValue, upperLimit), + }; +}; + +const normalizeSingleValue = ( + value: number, + props: Pick< + RangeSliderProps, + 'minimumValue' | 'maximumValue' | 'lowerLimit' | 'upperLimit' | 'step' + >, +) => { + const bounds = getBounds(props); + const stepped = roundToStep( + value, + props.minimumValue, + props.maximumValue, + props.step, + ); + + return clamp(stepped, bounds.minimum, bounds.maximum); +}; + +const normalizeRange = ( + values: RangeValue, + props: Pick< + RangeSliderProps, + | 'minimumValue' + | 'maximumValue' + | 'lowerLimit' + | 'upperLimit' + | 'minimumRange' + | 'step' + >, + activeThumb?: RangeThumbIndex, +): RangeValue => { + const bounds = getBounds(props); + const minimumRange = Math.max(0, props.minimumRange ?? 0); + let lower = normalizeSingleValue(values[0], props); + let upper = normalizeSingleValue(values[1], props); + + if (lower > upper && activeThumb === 0) { + lower = upper; + } else if (lower > upper && activeThumb === 1) { + upper = lower; + } else if (lower > upper) { + [lower, upper] = [upper, lower]; + } + + if (upper - lower < minimumRange) { + if (activeThumb === 0) { + lower = upper - minimumRange; + } else { + upper = lower + minimumRange; + } + } + + if (lower < bounds.minimum) { + lower = bounds.minimum; + upper = lower + minimumRange; + } + + if (upper > bounds.maximum) { + upper = bounds.maximum; + lower = upper - minimumRange; + } + + return [ + clamp(lower, bounds.minimum, bounds.maximum), + clamp(upper, bounds.minimum, bounds.maximum), + ]; +}; + +const getInitialRange = ({ + values, + value, + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, +}: Pick< + RangeSliderProps, + | 'values' + | 'value' + | 'minimumValue' + | 'maximumValue' + | 'lowerLimit' + | 'upperLimit' + | 'minimumRange' + | 'step' +>): RangeValue => { + const initialValues: RangeValue = values ?? [ + value ?? minimumValue, + maximumValue, + ]; + + return normalizeRange(initialValues, { + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + }); +}; + +const RangeSliderComponent = ( + { + value, + values, + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange = 0, + step, + inverted, + disabled, + style, + minimumTrackTintColor = DEFAULT_MINIMUM_TRACK_TINT_COLOR, + maximumTrackTintColor = DEFAULT_MAXIMUM_TRACK_TINT_COLOR, + thumbTintColor = DEFAULT_THUMB_TINT_COLOR, + thumbImage, + thumbSize = constants.THUMB_SIZE, + onValuesChange, + onRangeSlidingStart, + onRangeSlidingComplete, + onAccessibilityAction, + testID, + StepMarker: _StepMarker, + renderStepNumber: _renderStepNumber, + accessibilityUnits: _accessibilityUnits, + accessibilityIncrements: _accessibilityIncrements, + maximumTrackImage: _maximumTrackImage, + minimumTrackImage: _minimumTrackImage, + tapToSeek: _tapToSeek, + trackImage: _trackImage, + vertical: _vertical, + ...props + }: RangeSliderProps, + forwardedRef?: Ref, +) => { + const [range, setRange] = useState(() => + getInitialRange({ + values, + value, + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + }), + ); + const [width, setWidth] = useState(0); + const activeThumbRef = useRef(0); + const isSlidingRef = useRef(false); + const rangeRef = useRef(range); + const viewRef = useRef(null); + + useEffect(() => { + rangeRef.current = range; + }, [range]); + + useEffect(() => { + if (isSlidingRef.current) { + return; + } + + setRange( + getInitialRange({ + values, + value, + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + }), + ); + }, [ + value, + values, + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + ]); + + React.useImperativeHandle( + forwardedRef, + () => ({ + updateValue: (nextValue: number) => { + setRange((currentRange) => + normalizeRange([nextValue, currentRange[1]], { + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + }), + ); + }, + updateValues: (nextValues: RangeValue) => { + setRange( + normalizeRange(nextValues, { + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + }), + ); + }, + }), + [minimumValue, maximumValue, lowerLimit, upperLimit, minimumRange, step], + ); + + const usableWidth = Math.max(1, width - thumbSize); + const valueRange = maximumValue - minimumValue || 1; + const lowerRatio = inverted + ? (maximumValue - range[0]) / valueRange + : (range[0] - minimumValue) / valueRange; + const upperRatio = inverted + ? (maximumValue - range[1]) / valueRange + : (range[1] - minimumValue) / valueRange; + const lowerLeft = clamp(lowerRatio, 0, 1) * usableWidth; + const upperLeft = clamp(upperRatio, 0, 1) * usableWidth; + const lowerCenter = lowerLeft + thumbSize / 2; + const upperCenter = upperLeft + thumbSize / 2; + const selectedTrackLeft = Math.min(lowerCenter, upperCenter); + const selectedTrackWidth = Math.abs(upperCenter - lowerCenter); + + const getValueFromLocation = useCallback( + (locationX: number) => { + const percent = clamp((locationX - thumbSize / 2) / usableWidth, 0, 1); + const adjustedPercent = inverted ? 1 - percent : percent; + + return normalizeSingleValue(minimumValue + adjustedPercent * valueRange, { + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + step, + }); + }, + [ + thumbSize, + usableWidth, + inverted, + minimumValue, + maximumValue, + valueRange, + lowerLimit, + upperLimit, + step, + ], + ); + + const getEventLocation = (event: GestureResponderEvent) => { + const {locationX, pageX} = event.nativeEvent; + return typeof locationX === 'number' ? locationX : pageX; + }; + + const getClosestThumb = useCallback((nextValue: number): RangeThumbIndex => { + const currentRange = rangeRef.current; + const lowerDistance = Math.abs(nextValue - currentRange[0]); + const upperDistance = Math.abs(nextValue - currentRange[1]); + + if (lowerDistance === upperDistance) { + return nextValue > (currentRange[0] + currentRange[1]) / 2 ? 1 : 0; + } + + return lowerDistance < upperDistance ? 0 : 1; + }, []); + + const updateThumbValue = useCallback( + (thumbIndex: RangeThumbIndex, nextValue: number, emitChange: boolean) => { + const nextRange = normalizeRange( + thumbIndex === 0 + ? [nextValue, rangeRef.current[1]] + : [rangeRef.current[0], nextValue], + { + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + }, + thumbIndex, + ); + + rangeRef.current = nextRange; + setRange(nextRange); + + if (emitChange) { + onValuesChange?.(nextRange, thumbIndex); + } + + return nextRange; + }, + [ + minimumValue, + maximumValue, + lowerLimit, + upperLimit, + minimumRange, + step, + onValuesChange, + ], + ); + + const onLayout = (event: LayoutChangeEvent) => { + setWidth(event.nativeEvent.layout.width); + props.onLayout?.(event); + }; + + const onResponderGrant = (event: GestureResponderEvent) => { + const nextValue = getValueFromLocation(getEventLocation(event)); + const thumbIndex = getClosestThumb(nextValue); + + activeThumbRef.current = thumbIndex; + isSlidingRef.current = true; + onRangeSlidingStart?.(rangeRef.current, thumbIndex); + }; + + const onResponderMove = (event: GestureResponderEvent) => { + updateThumbValue( + activeThumbRef.current, + getValueFromLocation(getEventLocation(event)), + true, + ); + }; + + const onResponderRelease = (event: GestureResponderEvent) => { + const thumbIndex = activeThumbRef.current; + const nextRange = updateThumbValue( + thumbIndex, + getValueFromLocation(getEventLocation(event)), + false, + ); + + isSlidingRef.current = false; + onRangeSlidingComplete?.(nextRange, thumbIndex); + }; + + const onAccessibilityActionEvent = (event: AccessibilityActionEvent) => { + const tenth = (maximumValue - minimumValue) / 10; + const thumbIndex = activeThumbRef.current; + const delta = step || tenth; + + if (event.nativeEvent.actionName === 'increment') { + updateThumbValue(thumbIndex, rangeRef.current[thumbIndex] + delta, true); + } + + if (event.nativeEvent.actionName === 'decrement') { + updateThumbValue(thumbIndex, rangeRef.current[thumbIndex] - delta, true); + } + + onAccessibilityAction?.(event); + }; + + const thumbBaseStyle: ViewStyle = { + position: 'absolute', + width: thumbSize, + height: thumbSize, + borderRadius: thumbSize / 2, + backgroundColor: thumbTintColor, + borderColor: 'rgba(0, 0, 0, 0.18)', + borderWidth: thumbImage ? 0 : 1, + overflow: 'hidden', + zIndex: 2, + }; + const rootStyle = [ + {minHeight: Math.max(40, thumbSize), justifyContent: 'center' as const}, + style, + ]; + const maximumTrackStyle: ViewStyle = { + position: 'absolute', + left: thumbSize / 2, + right: thumbSize / 2, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + backgroundColor: maximumTrackTintColor, + }; + const selectedTrackStyle: ViewStyle = { + position: 'absolute', + left: selectedTrackLeft, + width: selectedTrackWidth, + height: TRACK_HEIGHT, + borderRadius: TRACK_HEIGHT / 2, + backgroundColor: minimumTrackTintColor, + }; + const lowerThumbStyle = [thumbBaseStyle, {left: lowerLeft}]; + const upperThumbStyle = [thumbBaseStyle, {left: upperLeft}]; + + return ( + !disabled} + onMoveShouldSetResponder={() => !disabled} + onResponderGrant={onResponderGrant} + onResponderMove={onResponderMove} + onResponderRelease={onResponderRelease} + onResponderTerminate={onResponderRelease} + pointerEvents={disabled ? 'none' : 'auto'}> + + + + {thumbImage ? ( + + ) : null} + + + {thumbImage ? ( + + ) : null} + + + ); +}; + +export const RangeSlider = React.forwardRef(RangeSliderComponent); diff --git a/package/src/Slider.tsx b/package/src/Slider.tsx index c452ef9c..7c6b9da3 100644 --- a/package/src/Slider.tsx +++ b/package/src/Slider.tsx @@ -19,6 +19,8 @@ import {MarkerProps} from './components/TrackMark'; import {StepsIndicator} from './components/StepsIndicator'; import {styles} from './utils/styles'; import {constants} from './utils/constants'; +import {RangeSlider} from './RangeSlider'; +import type {RangeThumbIndex, RangeValue} from './RangeSlider'; type Event = NativeSyntheticEvent< Readonly<{ @@ -87,6 +89,18 @@ type Props = ViewProps & */ value?: number; + /** + * Enables two-thumb range selection. + * Default value is false. + */ + range?: boolean; + + /** + * Write-only property representing the lower and upper values of the + * range slider. Used when `range` is true. + */ + values?: RangeValue; + /** * Step value of the slider. The value should be * between 0 and (maximumValue - minimumValue). @@ -114,6 +128,13 @@ type Props = ViewProps & */ upperLimit?: number; + /** + * Minimum distance between the lower and upper range thumbs. + * Used when `range` is true. + * Default value is 0. + */ + minimumRange?: number; + /** * The color used for the track to the left of the button. * Overrides the default blue gradient image on iOS. @@ -142,6 +163,15 @@ type Props = ViewProps & */ onValueChange?: (_value: number) => void; + /** + * Callback continuously called while the user is dragging either range + * thumb. Used when `range` is true. + */ + onValuesChange?: ( + _values: RangeValue, + _thumbIndex: RangeThumbIndex, + ) => void; + /** * Callback that is called when the user touches the slider, * regardless if the value has changed. The current value is passed @@ -149,6 +179,15 @@ type Props = ViewProps & */ onSlidingStart?: (_value: number) => void; + /** + * Callback that is called when the user touches either range thumb. + * Used when `range` is true. + */ + onRangeSlidingStart?: ( + _values: RangeValue, + _thumbIndex: RangeThumbIndex, + ) => void; + /** * Callback that is called when the user releases the slider, * regardless if the value has changed. The current value is passed @@ -156,6 +195,15 @@ type Props = ViewProps & */ onSlidingComplete?: (_value: number) => void; + /** + * Callback that is called when the user releases either range thumb. + * Used when `range` is true. + */ + onRangeSlidingComplete?: ( + _values: RangeValue, + _thumbIndex: RangeThumbIndex, + ) => void; + /** * Used to locate this view in UI automation tests. */ @@ -210,12 +258,18 @@ const SliderComponent = ( onValueChange, onSlidingStart, onSlidingComplete, + onValuesChange, + onRangeSlidingStart, + onRangeSlidingComplete, onAccessibilityAction, value = constants.SLIDER_DEFAULT_INITIAL_VALUE, + values, minimumValue = 0, maximumValue = 1, step = 0, inverted = false, + range = false, + minimumRange = 0, tapToSeek = false, lowerLimit = Platform.select({ web: minimumValue, @@ -227,10 +281,12 @@ const SliderComponent = ( }), ...props }: Props, - forwardedRef?: Ref, + forwardedRef?: Ref, ) => { const [currentValue, setCurrentValue] = useState( - value ?? minimumValue ?? constants.SLIDER_DEFAULT_INITIAL_VALUE, + range && values + ? values[0] + : value ?? minimumValue ?? constants.SLIDER_DEFAULT_INITIAL_VALUE, ); const [width, setWidth] = useState(0); @@ -256,6 +312,30 @@ const SliderComponent = ( setCurrentValue(event.nativeEvent.value); }; + const onValuesChangeEvent = ( + nextValues: RangeValue, + thumbIndex: RangeThumbIndex, + ) => { + setCurrentValue(nextValues[thumbIndex]); + onValuesChange && onValuesChange(nextValues, thumbIndex); + }; + + const onRangeSlidingStartEvent = ( + nextValues: RangeValue, + thumbIndex: RangeThumbIndex, + ) => { + setCurrentValue(nextValues[thumbIndex]); + onRangeSlidingStart && onRangeSlidingStart(nextValues, thumbIndex); + }; + + const onRangeSlidingCompleteEvent = ( + nextValues: RangeValue, + thumbIndex: RangeThumbIndex, + ) => { + setCurrentValue(nextValues[thumbIndex]); + onRangeSlidingComplete && onRangeSlidingComplete(nextValues, thumbIndex); + }; + const _disabled = typeof props.disabled === 'boolean' ? props.disabled @@ -309,44 +389,73 @@ const SliderComponent = ( isLTR={inverted} /> ) : null} - true} - onResponderTerminationRequest={() => false} - onRNCSliderAccessibilityAction={onAccessibilityActionEvent} - thumbTintColor={ - props.thumbImage && !!props.StepMarker - ? 'transparent' - : props.thumbTintColor - } - /> + {range ? ( + + ) : ( + true} + onResponderTerminationRequest={() => false} + onRNCSliderAccessibilityAction={onAccessibilityActionEvent} + thumbTintColor={ + props.thumbImage && !!props.StepMarker + ? 'transparent' + : props.thumbTintColor + } + /> + )} ); }; diff --git a/package/typings/index.d.ts b/package/typings/index.d.ts index 6903ca7a..1fab64bf 100644 --- a/package/typings/index.d.ts +++ b/package/typings/index.d.ts @@ -18,8 +18,12 @@ export interface SliderPropsAndroid extends ReactNative.ViewProps { export interface SliderRef { updateValue(value: number): void; + updateValues(values: RangeValue): void; } +export type RangeValue = [number, number]; +export type RangeThumbIndex = 0 | 1; + export type TrackMarksProps = { isTrue: boolean; index: number; @@ -106,6 +110,13 @@ export interface SliderProps */ upperLimit?: number; + /** + * Minimum distance between the lower and upper range thumbs. + * Used when `range` is true. + * Default value is 0. + */ + minimumRange?: number; + /** * The color used for the track to the left of the button. * Overrides the default blue gradient image. @@ -123,16 +134,40 @@ export interface SliderProps */ onSlidingStart?: (value: number) => void; + /** + * Callback that is called when the user touches either range thumb. + * Used when `range` is true. + */ + onRangeSlidingStart?: ( + values: RangeValue, + thumbIndex: RangeThumbIndex, + ) => void; + /** * Callback called when the user finishes changing the value (e.g. when the slider is released). */ onSlidingComplete?: (value: number) => void; + /** + * Callback that is called when the user releases either range thumb. + * Used when `range` is true. + */ + onRangeSlidingComplete?: ( + values: RangeValue, + thumbIndex: RangeThumbIndex, + ) => void; + /** * Callback continuously called while the user is dragging the slider. */ onValueChange?: (value: number) => void; + /** + * Callback continuously called while the user is dragging either range thumb. + * Used when `range` is true. + */ + onValuesChange?: (values: RangeValue, thumbIndex: RangeThumbIndex) => void; + /** * Step value of the slider. The value should be between 0 and (maximumValue - minimumValue). Default value is 0. */ @@ -161,6 +196,18 @@ export interface SliderProps */ value?: number; + /** + * Enables two-thumb range selection. + * Default value is false. + */ + range?: boolean; + + /** + * Write-only property representing the lower and upper values of the range slider. + * Used when `range` is true. + */ + values?: RangeValue; + /** * Reverses the direction of the slider. */