Skip to content

Commit 87bad13

Browse files
feat: implemented range slider
1 parent 22757e6 commit 87bad13

11 files changed

+438
-17
lines changed
Lines changed: 138 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,138 @@
1+
import React from 'react';
2+
import {View} from 'react-native';
3+
import {GestureDetector} from 'react-native-gesture-handler';
4+
import {type LayoutChangeEvent, type ViewProps, type StyleProp, type ViewStyle, type TextStyle} from 'react-native';
5+
6+
import {styles} from './range-slider.styles';
7+
import {useTheme} from '../../theme/useTheme.hook';
8+
import {useRangeSlider} from './useRangeSlider.hook';
9+
import {SliderTrack} from '../ui/slider-track/SliderTrack.component';
10+
import {useRangeSliderTrackPoints} from './useRangeSliderTrackPoints.hook';
11+
import {SliderIndicator} from '../ui/slider-indicator/SliderIndicator.component';
12+
import {SliderTrackPoint} from '../ui/slider-track-point/SliderTrackPoint.component';
13+
14+
export interface RangeSliderProps extends ViewProps {
15+
max: number;
16+
min: number;
17+
18+
range?: number[];
19+
step?: number;
20+
damping?: number;
21+
centered?: boolean;
22+
23+
valueHeight?: number;
24+
thumbWidthActive?: number;
25+
thumbWidthInactive?: number;
26+
27+
thumbStyle?: StyleProp<ViewStyle>;
28+
valueStyle?: StyleProp<TextStyle>;
29+
indicatorStyle?: StyleProp<ViewStyle>;
30+
trackPointStyle?: StyleProp<ViewStyle>;
31+
trackPointsStyle?: StyleProp<ViewStyle>;
32+
filledTrackStyle?: StyleProp<ViewStyle>;
33+
remainingTrackStyle?: StyleProp<ViewStyle>;
34+
35+
onChangeRange?: (range: number[]) => void;
36+
}
37+
38+
export const RangeSlider: React.FC<RangeSliderProps> = ({
39+
max,
40+
min,
41+
42+
step,
43+
range = [0, 0],
44+
damping = 20,
45+
centered = false,
46+
47+
thumbStyle,
48+
valueStyle,
49+
indicatorStyle,
50+
trackPointStyle,
51+
trackPointsStyle,
52+
filledTrackStyle,
53+
remainingTrackStyle,
54+
55+
valueHeight,
56+
thumbWidthActive,
57+
thumbWidthInactive,
58+
59+
onChangeRange,
60+
onLayout,
61+
style,
62+
...props
63+
}) => {
64+
const {primary, secondaryContainer} = useTheme();
65+
66+
const trackPoints = useRangeSliderTrackPoints({max, min, step, centered});
67+
const {
68+
gesture,
69+
selectedRange,
70+
thumbMinSliding,
71+
thumbMaxSliding,
72+
animMinValueProps,
73+
animMaxValueProps,
74+
filledTrackAnimatedStyle,
75+
remainingTrackAfterAnimatedStyle,
76+
remainingTrackBeforeAnimatedStyle,
77+
slideToTrackPoint,
78+
setUpSliderLayout,
79+
} = useRangeSlider({max, min, centered, step, damping}, range, onChangeRange);
80+
81+
const handleLayoutChange = (e: LayoutChangeEvent) => {
82+
setUpSliderLayout(e);
83+
84+
if (onLayout) {
85+
onLayout(e);
86+
}
87+
};
88+
89+
const renderTrackPoint = (pointValue: number) => (
90+
<SliderTrackPoint
91+
key={pointValue}
92+
value={pointValue}
93+
selectedValue={selectedRange}
94+
onPress={slideToTrackPoint}
95+
style={[{backgroundColor: secondaryContainer.text}, trackPointStyle]}
96+
/>
97+
);
98+
99+
return (
100+
<GestureDetector gesture={gesture}>
101+
<View style={[styles.container, style]} onLayout={handleLayoutChange} {...props}>
102+
<SliderTrack
103+
style={[{backgroundColor: secondaryContainer.background}, styles.remainingBeforeTrack, remainingTrackBeforeAnimatedStyle, filledTrackStyle]}
104+
/>
105+
<SliderIndicator
106+
animValueProps={animMinValueProps}
107+
sliding={thumbMinSliding}
108+
style={[styles.thumb, indicatorStyle]}
109+
thumbStyle={thumbStyle}
110+
valueStyle={valueStyle}
111+
valueHeight={valueHeight}
112+
thumbWidthActive={thumbWidthActive}
113+
thumbWidthInactive={thumbWidthInactive}
114+
/>
115+
<SliderTrack style={[{backgroundColor: primary.background}, styles.filledTrack, filledTrackAnimatedStyle, filledTrackStyle]} />
116+
<SliderIndicator
117+
animValueProps={animMaxValueProps}
118+
sliding={thumbMaxSliding}
119+
style={[styles.thumb, indicatorStyle]}
120+
thumbStyle={thumbStyle}
121+
valueStyle={valueStyle}
122+
valueHeight={valueHeight}
123+
thumbWidthActive={thumbWidthActive}
124+
thumbWidthInactive={thumbWidthInactive}
125+
/>
126+
<SliderTrack
127+
style={[
128+
{backgroundColor: secondaryContainer.background},
129+
styles.remainingAfterTrack,
130+
remainingTrackAfterAnimatedStyle,
131+
remainingTrackStyle,
132+
]}
133+
/>
134+
<View style={[styles.trackPoints, trackPointsStyle]}>{trackPoints.map(renderTrackPoint)}</View>
135+
</View>
136+
</GestureDetector>
137+
);
138+
};
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import {StyleSheet} from 'react-native';
2+
3+
export const styles = StyleSheet.create({
4+
container: {
5+
flexDirection: 'row',
6+
alignItems: 'center',
7+
},
8+
thumb: {
9+
zIndex: 1,
10+
},
11+
trackPoints: {
12+
position: 'absolute',
13+
14+
flexDirection: 'row',
15+
justifyContent: 'space-between',
16+
17+
width: '100%',
18+
paddingHorizontal: 4,
19+
},
20+
filledTrack: {
21+
borderRadius: 2,
22+
},
23+
remainingBeforeTrack: {
24+
borderTopEndRadius: 2,
25+
borderBottomEndRadius: 2,
26+
borderTopStartRadius: 16,
27+
borderBottomStartRadius: 16,
28+
},
29+
remainingAfterTrack: {
30+
justifyContent: 'center',
31+
32+
borderTopEndRadius: 16,
33+
borderBottomEndRadius: 16,
34+
borderTopStartRadius: 2,
35+
borderBottomStartRadius: 2,
36+
},
37+
});
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
import {useEffect, useState} from 'react';
2+
import {type LayoutChangeEvent} from 'react-native';
3+
import {Gesture} from 'react-native-gesture-handler';
4+
import {
5+
interpolate,
6+
runOnJS,
7+
useAnimatedProps,
8+
useAnimatedStyle,
9+
useSharedValue,
10+
withSpring,
11+
withTiming,
12+
type SharedValue,
13+
} from 'react-native-reanimated';
14+
15+
import {type SliderConfig} from '../slider-config.type';
16+
import {normalize} from '../worklets/normalize.worklet';
17+
import {validateSliderRange} from './validate-slider-range';
18+
import {calcTranslationXBasedOnValue} from '../worklets/calc-translationX-based-on-value.worklet';
19+
20+
enum ThumbType {
21+
MIN = 'MIN',
22+
MAX = 'MAX',
23+
}
24+
25+
export const useRangeSlider = ({max, min, step, damping}: SliderConfig, range: number[], onChangeRange?: (range: number[]) => void) => {
26+
const [sliderLayout, setSliderLayout] = useState({width: 0, height: 0, x: 0, y: 0});
27+
28+
const [minValue, maxValue] = [range[0] ?? 0, range[1] ?? 0];
29+
30+
const thumbMinSliding = useSharedValue(0);
31+
const thumbMaxSliding = useSharedValue(0);
32+
const thumbMinTranslationX = useSharedValue(0);
33+
const thumbMaxTranslationX = useSharedValue(0);
34+
const thumbsTranslationXContext = useSharedValue({min: 0, max: 0});
35+
const selectedRange = useSharedValue([minValue, maxValue]);
36+
37+
useEffect(() => {
38+
validateSliderRange(range);
39+
adjustThumbsPosition(sliderLayout.width, true);
40+
}, [range, sliderLayout]);
41+
42+
const filledTrackAnimatedStyle = useAnimatedStyle(() => {
43+
const remainingTrackFlex = interpolate(thumbMinTranslationX.value, [0, sliderLayout.width], [0, 1]);
44+
const remainingTrackAfterFlex = interpolate(thumbMaxTranslationX.value, [0, sliderLayout.width], [0, 1]);
45+
46+
return {flex: Math.abs(remainingTrackFlex - remainingTrackAfterFlex)};
47+
}, [sliderLayout]);
48+
49+
const remainingTrackBeforeAnimatedStyle = useAnimatedStyle(
50+
() => ({flex: interpolate(thumbMinTranslationX.value, [0, sliderLayout.width], [0, 1])}),
51+
[sliderLayout]
52+
);
53+
54+
const remainingTrackAfterAnimatedStyle = useAnimatedStyle(
55+
() => ({flex: interpolate(thumbMaxTranslationX.value, [0, sliderLayout.width], [1, 0])}),
56+
[sliderLayout]
57+
);
58+
59+
const calcValueBasedOnThumbTranslationX = (translationX: number, thumbType: ThumbType) => {
60+
'worklet';
61+
62+
const value = Math.round(interpolate(translationX, [0, sliderLayout.width], [min, max]));
63+
const normalizedValue = normalize(value, step);
64+
65+
selectedRange.value =
66+
thumbType === ThumbType.MIN ? [normalizedValue, selectedRange.value[1] ?? 0] : [selectedRange.value[0] ?? 0, normalizedValue];
67+
68+
return normalizedValue;
69+
};
70+
71+
const updateTranslationX = (thumbTranslationX: SharedValue<number>, translationX: number) => {
72+
'worklet';
73+
74+
if (step) {
75+
const stepSize = (sliderLayout.width / (max - min)) * step;
76+
const stepSnap = normalize(translationX, stepSize);
77+
78+
thumbTranslationX.value = stepSnap;
79+
} else {
80+
thumbTranslationX.value = translationX;
81+
}
82+
};
83+
84+
const gesture = Gesture.Pan()
85+
.onStart((e) => {
86+
const touchX = e.x - sliderLayout.x;
87+
88+
const distanceToMinThumb = Math.abs(touchX - thumbMinTranslationX.value);
89+
const distanceToMaxThumb = Math.abs(touchX - thumbMaxTranslationX.value);
90+
const activeThumb = touchX < thumbMaxTranslationX.value && distanceToMinThumb <= distanceToMaxThumb ? thumbMinSliding : thumbMaxSliding;
91+
92+
activeThumb.value = withTiming(1);
93+
})
94+
.onUpdate((e) => {
95+
if (thumbMinSliding.value) {
96+
const currentMinTranslationX = Math.max(0, Math.min(thumbMaxTranslationX.value, e.translationX + thumbsTranslationXContext.value.min));
97+
98+
updateTranslationX(thumbMinTranslationX, currentMinTranslationX);
99+
} else if (thumbMaxSliding.value) {
100+
const currentMaxTranslationX = Math.min(
101+
sliderLayout.width,
102+
Math.max(thumbMinTranslationX.value, e.translationX + thumbsTranslationXContext.value.max)
103+
);
104+
105+
updateTranslationX(thumbMaxTranslationX, currentMaxTranslationX);
106+
}
107+
})
108+
.onEnd(() => {
109+
thumbMinSliding.value = withTiming(0);
110+
thumbMaxSliding.value = withTiming(0);
111+
thumbsTranslationXContext.value = {min: thumbMinTranslationX.value, max: thumbMaxTranslationX.value};
112+
113+
if (onChangeRange) {
114+
runOnJS(onChangeRange)(selectedRange.value);
115+
}
116+
});
117+
118+
const setUpSliderLayout = (e: LayoutChangeEvent) => {
119+
const layout = e.nativeEvent.layout;
120+
121+
adjustThumbsPosition(layout.width);
122+
setSliderLayout(layout);
123+
};
124+
125+
const adjustThumbsPosition = (width: number, isUpdating: boolean = false) => {
126+
const initialStartTthumbTranslationX = calcTranslationXBasedOnValue({min, max}, normalize(minValue, step), width);
127+
const initiaEndTthumbTranslationX = calcTranslationXBasedOnValue({min, max}, normalize(maxValue, step), width);
128+
129+
thumbMinTranslationX.value = isUpdating ? withSpring(initialStartTthumbTranslationX, {damping}) : initialStartTthumbTranslationX;
130+
thumbMaxTranslationX.value = isUpdating ? withSpring(initiaEndTthumbTranslationX, {damping}) : initiaEndTthumbTranslationX;
131+
thumbsTranslationXContext.value = {min: initialStartTthumbTranslationX, max: initiaEndTthumbTranslationX};
132+
};
133+
134+
const slideToTrackPoint = (pointValue: number) => {
135+
if (typeof selectedRange.value[0] !== 'number' || typeof selectedRange.value[1] !== 'number') {
136+
return;
137+
}
138+
139+
const diffFromMinThumb = Math.abs(pointValue - selectedRange.value[0]);
140+
const diffFromMaxThumb = Math.abs(pointValue - selectedRange.value[1]);
141+
const [activeThumbSliding, activeThumbTranslationX] =
142+
diffFromMinThumb < diffFromMaxThumb ? [thumbMinSliding, thumbMinTranslationX] : [thumbMaxSliding, thumbMaxTranslationX];
143+
const thumbPointValueTranslationX = calcTranslationXBasedOnValue({min, max}, pointValue, sliderLayout.width);
144+
145+
activeThumbSliding.value = withTiming(1);
146+
activeThumbTranslationX.value = withSpring(thumbPointValueTranslationX, {damping}, () => {
147+
activeThumbSliding.value = withTiming(0);
148+
thumbsTranslationXContext.value = {min: thumbMinTranslationX.value, max: thumbMaxTranslationX.value};
149+
150+
if (onChangeRange) {
151+
runOnJS(onChangeRange)(selectedRange.value);
152+
}
153+
});
154+
};
155+
156+
const animMinValueProps = useAnimatedProps(() => ({
157+
text: `${calcValueBasedOnThumbTranslationX(thumbMinTranslationX.value, ThumbType.MIN)}`,
158+
}));
159+
160+
const animMaxValueProps = useAnimatedProps(() => ({
161+
text: `${calcValueBasedOnThumbTranslationX(thumbMaxTranslationX.value, ThumbType.MAX)}`,
162+
}));
163+
164+
return {
165+
gesture,
166+
selectedRange,
167+
thumbMinSliding,
168+
thumbMaxSliding,
169+
animMinValueProps,
170+
animMaxValueProps,
171+
filledTrackAnimatedStyle,
172+
remainingTrackAfterAnimatedStyle,
173+
remainingTrackBeforeAnimatedStyle,
174+
175+
setUpSliderLayout,
176+
slideToTrackPoint,
177+
};
178+
};
Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
import {type SliderConfig} from '../slider-config.type';
2+
3+
export const useRangeSliderTrackPoints = ({max, min, step, centered}: SliderConfig) => {
4+
const getDiscreteRangeSliderTrackPoints = (discreteStep: number) => {
5+
const totalPoints = Math.ceil((max - min) / discreteStep) + 1;
6+
7+
return Array.from({length: totalPoints}, (_, index) => Math.min(min + index * discreteStep, max));
8+
};
9+
10+
const getContinuousRangeSliderTrackPoints = () => (centered ? [min, (min + max) / 2, max] : [min, max]);
11+
12+
const trackPoints = step ? getDiscreteRangeSliderTrackPoints(step) : getContinuousRangeSliderTrackPoints();
13+
14+
return trackPoints;
15+
};
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
export const validateSliderRange = (range: number[]) => {
2+
if (range.length !== 2) {
3+
throw new Error('Range length must be equal 2');
4+
}
5+
6+
if (range[0]! > range[1]!) {
7+
throw new Error('Min value must be less or equal to max');
8+
}
9+
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,5 +3,6 @@ export interface SliderConfig {
33
min: number;
44

55
step?: number;
6+
damping?: number;
67
centered?: boolean;
78
}

0 commit comments

Comments
 (0)