@ozziedave/react-native-multi-slider
Version:
Android and iOS supported pure JS slider component with multiple markers for React Native
694 lines (638 loc) • 19.7 kB
JavaScript
import React from 'react';
import {
StyleSheet,
PanResponder,
View,
Platform,
Dimensions,
I18nManager,
ImageBackground,
Pressable,
} from 'react-native';
import DefaultMarker from './DefaultMarker';
import DefaultLabel from './DefaultLabel';
import { createArray, valueToPosition, positionToValue } from './converters';
import { number } from 'yup';
export default class MultiSlider extends React.Component {
static defaultProps = {
values: [0],
onValuesChangeStart: () => {},
onValuesChange: values => {},
onValuesChangeFinish: values => {},
onMarkersPosition: values => {},
step: 1,
min: 0,
max: 10,
stepHitSlop: 15,
touchDimensions: {
height: 50,
width: 50,
borderRadius: 15,
slipDisplacement: 200,
},
customMarker: DefaultMarker,
customMarkerLeft: DefaultMarker,
customMarkerRight: DefaultMarker,
customLabel: DefaultLabel,
markerOffsetX: 0,
markerOffsetY: 0,
sliderLength: 280,
onToggleOne: undefined,
onToggleTwo: undefined,
enabledOne: true,
enabledTwo: true,
allowOverlap: false,
snapped: false,
vertical: false,
minMarkerOverlapDistance: 0,
enableTapSteps: false,
tappableStepStyle: undefined,
tappableContainerStepStyle: undefined,
activeTappableStepStyle: undefined,
};
constructor(props) {
super(props);
this.optionsArray =
this.props.optionsArray ||
createArray(this.props.min, this.props.max, this.props.step);
this.stepLength = this.props.sliderLength / this.optionsArray.length;
var initialValues = this.props.values.map(value =>
valueToPosition(value, this.optionsArray, this.props.sliderLength),
);
this.state = {
pressedOne: true,
valueOne: this.props.values[0],
valueTwo: this.props.values[1],
pastOne: initialValues[0],
pastTwo: initialValues[1],
positionOne: initialValues[0],
positionTwo: initialValues[1],
};
this.subscribePanResponder();
}
subscribePanResponder = () => {
var customPanResponder = (start, move, end) => {
return PanResponder.create({
onStartShouldSetPanResponder: (evt, gestureState) => true,
onStartShouldSetPanResponderCapture: (evt, gestureState) => true,
onMoveShouldSetPanResponder: (evt, gestureState) => true,
onMoveShouldSetPanResponderCapture: (evt, gestureState) => true,
onPanResponderGrant: (evt, gestureState) => start(),
onPanResponderMove: (evt, gestureState) => move(gestureState),
onPanResponderTerminationRequest: (evt, gestureState) => false,
onPanResponderRelease: (evt, gestureState) => end(gestureState),
onPanResponderTerminate: (evt, gestureState) => end(gestureState),
onShouldBlockNativeResponder: (evt, gestureState) => true,
});
};
this._panResponderBetween = customPanResponder(
gestureState => {
this.startOne(gestureState);
this.startTwo(gestureState);
},
gestureState => {
this.moveOne(gestureState);
this.moveTwo(gestureState);
},
gestureState => {
this.endOne(gestureState);
this.endTwo(gestureState);
},
);
this._panResponderOne = customPanResponder(
this.startOne,
this.moveOne,
this.endOne,
);
this._panResponderTwo = customPanResponder(
this.startTwo,
this.moveTwo,
this.endTwo,
);
};
startOne = () => {
if (this.props.enabledOne) {
this.props.onValuesChangeStart();
this.setState({
onePressed: !this.state.onePressed,
});
}
};
startTwo = () => {
if (this.props.enabledTwo) {
this.props.onValuesChangeStart();
this.setState({
twoPressed: !this.state.twoPressed,
});
}
};
moveOne = gestureState => {
if (!this.props.enabledOne) {
return;
}
const accumDistance = this.props.vertical
? -gestureState.dy
: gestureState.dx;
const accumDistanceDisplacement = this.props.vertical
? gestureState.dx
: gestureState.dy;
const unconfined = I18nManager.isRTL
? this.state.pastOne - accumDistance
: accumDistance + this.state.pastOne;
var bottom = 0;
var trueTop =
this.state.positionTwo -
(this.props.allowOverlap
? 0
: this.props.minMarkerOverlapDistance > 0
? this.props.minMarkerOverlapDistance
: this.stepLength);
var top = trueTop === 0 ? 0 : trueTop || this.props.sliderLength;
var confined =
unconfined < bottom ? bottom : unconfined > top ? top : unconfined;
var slipDisplacement = this.props.touchDimensions.slipDisplacement;
if (
Math.abs(accumDistanceDisplacement) < slipDisplacement ||
!slipDisplacement
) {
var value = positionToValue(
confined,
this.optionsArray,
this.props.sliderLength,
);
var snapped = valueToPosition(
value,
this.optionsArray,
this.props.sliderLength,
);
this.setState({
positionOne: this.props.snapped ? snapped : confined,
});
if (value !== this.state.valueOne) {
this.setState(
{
valueOne: value,
},
() => {
var change = [this.state.valueOne];
if (this.state.valueTwo) {
change.push(this.state.valueTwo);
}
this.props.onValuesChange(change);
this.props.onMarkersPosition([
this.state.positionOne,
this.state.positionTwo,
]);
},
);
}
}
};
moveTwo = gestureState => {
if (!this.props.enabledTwo) {
return;
}
const accumDistance = this.props.vertical
? -gestureState.dy
: gestureState.dx;
const accumDistanceDisplacement = this.props.vertical
? gestureState.dx
: gestureState.dy;
const unconfined = I18nManager.isRTL
? this.state.pastTwo - accumDistance
: accumDistance + this.state.pastTwo;
var bottom =
this.state.positionOne +
(this.props.allowOverlap
? 0
: this.props.minMarkerOverlapDistance > 0
? this.props.minMarkerOverlapDistance
: this.stepLength);
var top = this.props.sliderLength;
var confined =
unconfined < bottom ? bottom : unconfined > top ? top : unconfined;
var slipDisplacement = this.props.touchDimensions.slipDisplacement;
if (
Math.abs(accumDistanceDisplacement) < slipDisplacement ||
!slipDisplacement
) {
var value = positionToValue(
confined,
this.optionsArray,
this.props.sliderLength,
);
var snapped = valueToPosition(
value,
this.optionsArray,
this.props.sliderLength,
);
this.setState({
positionTwo: this.props.snapped ? snapped : confined,
});
if (value !== this.state.valueTwo) {
this.setState(
{
valueTwo: value,
},
() => {
this.props.onValuesChange([
this.state.valueOne,
this.state.valueTwo,
]);
this.props.onMarkersPosition([
this.state.positionOne,
this.state.positionTwo,
]);
},
);
}
}
};
endOne = gestureState => {
if (gestureState.moveX === 0 && this.props.onToggleOne) {
this.props.onToggleOne();
return;
}
this.setState(
{
pastOne: this.state.positionOne,
onePressed: !this.state.onePressed,
},
() => {
var change = [this.state.valueOne];
if (this.state.valueTwo) {
change.push(this.state.valueTwo);
}
this.props.onValuesChangeFinish(change);
},
);
};
endTwo = gestureState => {
if (gestureState.moveX === 0 && this.props.onToggleTwo) {
this.props.onToggleTwo();
return;
}
this.setState(
{
twoPressed: !this.state.twoPressed,
pastTwo: this.state.positionTwo,
},
() => {
this.props.onValuesChangeFinish([
this.state.valueOne,
this.state.valueTwo,
]);
},
);
};
componentDidUpdate(prevProps, prevState) {
const {
positionOne: prevPositionOne,
positionTwo: prevPositionTwo,
} = prevState;
const { positionOne, positionTwo } = this.state;
if (
typeof positionOne === 'undefined' &&
typeof positionTwo !== 'undefined'
) {
return;
}
if (positionOne !== prevPositionOne || positionTwo !== prevPositionTwo) {
this.props.onMarkersPosition([positionOne, positionTwo]);
}
if (this.state.onePressed || this.state.twoPressed) {
return;
}
let nextState = {};
if (
prevProps.min !== this.props.min ||
prevProps.max !== this.props.max ||
prevProps.step !== this.props.step ||
prevProps.values[0] !== this.props.values[0] ||
prevProps.sliderLength !== this.props.sliderLength ||
prevProps.values[1] !== this.props.values[1] ||
(prevProps.sliderLength !== this.props.sliderLength &&
prevProps.values[1])
) {
this.optionsArray =
this.props.optionsArray ||
createArray(this.props.min, this.props.max, this.props.step);
this.stepLength = this.props.sliderLength / this.optionsArray.length;
const positionOne = valueToPosition(
this.props.values[0],
this.optionsArray,
this.props.sliderLength,
);
nextState.valueOne = this.props.values[0];
nextState.pastOne = positionOne;
nextState.positionOne = positionOne;
const positionTwo = valueToPosition(
this.props.values[1],
this.optionsArray,
this.props.sliderLength,
);
nextState.valueTwo = this.props.values[1];
nextState.pastTwo = positionTwo;
nextState.positionTwo = positionTwo;
this.setState(nextState);
}
}
onStepPressed(value) {
if (value === this.state.valueOne) {
return;
}
const snapped = valueToPosition(
value,
this.optionsArray,
this.props.sliderLength,
);
this.setState({
positionOne: this.props.snapped ? snapped : confined,
});
this.setState(
{
valueOne: value,
},
() => {
var change = [this.state.valueOne];
if (this.state.valueTwo) {
change.push(this.state.valueTwo);
}
this.props.onValuesChange(change);
this.props.onMarkersPosition([
this.state.positionOne,
this.state.positionTwo,
]);
},
);
}
render() {
const { positionOne, positionTwo, valueOne } = this.state;
const {
selectedStyle,
unselectedStyle,
sliderLength,
markerOffsetX,
markerOffsetY,
} = this.props;
const twoMarkers = this.props.values.length == 2; // when allowOverlap, positionTwo could be 0, identified as string '0' and throwing 'RawText 0 needs to be wrapped in <Text>' error
const trackOneLength = positionOne;
const trackOneStyle = twoMarkers
? unselectedStyle
: selectedStyle || styles.selectedTrack;
const trackThreeLength = twoMarkers ? sliderLength - positionTwo : 0;
const trackThreeStyle = unselectedStyle;
const trackTwoLength = sliderLength - trackOneLength - trackThreeLength;
const trackTwoStyle = twoMarkers
? selectedStyle || styles.selectedTrack
: unselectedStyle;
const Marker = this.props.customMarker;
const MarkerLeft = this.props.customMarkerLeft;
const MarkerRight = this.props.customMarkerRight;
const isMarkersSeparated = this.props.isMarkersSeparated || false;
const Label = this.props.customLabel;
const {
slipDisplacement,
height,
width,
borderRadius,
} = this.props.touchDimensions;
const touchStyle = {
borderRadius: borderRadius || 0,
};
const markerContainerOne = {
top: markerOffsetY - 24,
left: trackOneLength + markerOffsetX - 24,
};
const markerContainerTwo = {
top: markerOffsetY - 24,
right: trackThreeLength - markerOffsetX - 24,
};
const containerStyle = [styles.container, this.props.containerStyle];
if (this.props.vertical) {
containerStyle.push({
transform: [{ rotate: '-90deg' }],
});
}
const tappableStepStyle = this.props.tappableStepStyle || styles.tappableStepStyle;
const tappableContainerStepStyle = this.props.tappableContainerStepStyle;
const activeTappableStepStyle = this.props.activeTappableStepStyle || styles.activeTappableStepStyle;
const totalSteps = new Array(this.props.max + 1).fill({});
const body = (
<React.Fragment>
{this.props.enableTapSteps ? (
<View style={[styles.tappableStepContainer, { width: sliderLength }]}>
{totalSteps.map((_, idx) => (
<Pressable key={`step_${idx}`} style={tappableContainerStepStyle} hitSlop={this.props.stepHitSlop} onPress={() => this.onStepPressed(idx)}>
<View style={[tappableStepStyle, valueOne > idx ? activeTappableStepStyle : {}]} />
</Pressable>
))}
</View>
) : null}
<View style={[styles.fullTrack, { width: sliderLength }]}>
<View
style={[
styles.track,
this.props.trackStyle,
trackOneStyle,
{ width: trackOneLength },
]}
/>
<View
style={[
styles.track,
this.props.trackStyle,
trackTwoStyle,
{ width: trackTwoLength },
]}
{...(twoMarkers ? this._panResponderBetween.panHandlers : {})}
/>
{twoMarkers && (
<View
style={[
styles.track,
this.props.trackStyle,
trackThreeStyle,
{ width: trackThreeLength },
]}
/>
)}
<View
style={[
styles.markerContainer,
markerContainerOne,
this.props.markerContainerStyle,
positionOne > sliderLength / 2 && styles.topMarkerContainer,
]}
>
<View
style={[styles.touch, touchStyle]}
ref={component => (this._markerOne = component)}
{...this._panResponderOne.panHandlers}
>
{isMarkersSeparated === false ? (
<Marker
enabled={this.props.enabledOne}
pressed={this.state.onePressed}
markerStyle={this.props.markerStyle}
pressedMarkerStyle={this.props.pressedMarkerStyle}
disabledMarkerStyle={this.props.disabledMarkerStyle}
currentValue={this.state.valueOne}
valuePrefix={this.props.valuePrefix}
valueSuffix={this.props.valueSuffix}
/>
) : (
<MarkerLeft
enabled={this.props.enabledOne}
pressed={this.state.onePressed}
markerStyle={this.props.markerStyle}
pressedMarkerStyle={this.props.pressedMarkerStyle}
disabledMarkerStyle={this.props.disabledMarkerStyle}
currentValue={this.state.valueOne}
valuePrefix={this.props.valuePrefix}
valueSuffix={this.props.valueSuffix}
/>
)}
</View>
</View>
{twoMarkers && positionOne !== this.props.sliderLength && (
<View
style={[
styles.markerContainer,
markerContainerTwo,
this.props.markerContainerStyle,
]}
>
<View
style={[styles.touch, touchStyle]}
ref={component => (this._markerTwo = component)}
{...this._panResponderTwo.panHandlers}
>
{isMarkersSeparated === false ? (
<Marker
pressed={this.state.twoPressed}
markerStyle={this.props.markerStyle}
pressedMarkerStyle={this.props.pressedMarkerStyle}
disabledMarkerStyle={this.props.disabledMarkerStyle}
currentValue={this.state.valueTwo}
enabled={this.props.enabledTwo}
valuePrefix={this.props.valuePrefix}
valueSuffix={this.props.valueSuffix}
/>
) : (
<MarkerRight
pressed={this.state.twoPressed}
markerStyle={this.props.markerStyle}
pressedMarkerStyle={this.props.pressedMarkerStyle}
disabledMarkerStyle={this.props.disabledMarkerStyle}
currentValue={this.state.valueTwo}
enabled={this.props.enabledTwo}
valuePrefix={this.props.valuePrefix}
valueSuffix={this.props.valueSuffix}
/>
)}
</View>
</View>
)}
</View>
</React.Fragment>
);
return (
<View>
{this.props.enableLabel && (
<Label
oneMarkerValue={this.state.valueOne}
twoMarkerValue={this.state.valueTwo}
oneMarkerLeftPosition={positionOne}
twoMarkerLeftPosition={positionTwo}
oneMarkerPressed={this.state.onePressed}
twoMarkerPressed={this.state.twoPressed}
/>
)}
{this.props.imageBackgroundSource && (
<ImageBackground
source={this.props.imageBackgroundSource}
style={[{ width: '100%', height: '100%' }, containerStyle]}
>
{body}
</ImageBackground>
)}
{!this.props.imageBackgroundSource && (
<View style={containerStyle}>{body}</View>
)}
</View>
);
}
}
const styles = StyleSheet.create({
container: {
position: 'relative',
height: 50,
justifyContent: 'center',
},
fullTrack: {
flexDirection: 'row',
},
tappableStepContainer: {
flexDirection: 'row',
justifyContent: 'space-between',
position: 'relative',
top: 4,
},
tappableStepStyle: {
width: 2,
height: 6,
backgroundColor: '#A7A7A7',
},
activeTappableStepStyle: {
backgroundColor: '#095FFF',
},
track: {
...Platform.select({
ios: {
height: 2,
borderRadius: 2,
backgroundColor: '#A7A7A7',
},
android: {
height: 2,
backgroundColor: '#CECECE',
},
web: {
height: 2,
borderRadius: 2,
backgroundColor: '#A7A7A7',
},
}),
},
selectedTrack: {
...Platform.select({
ios: {
backgroundColor: '#095FFF',
},
android: {
backgroundColor: '#0D8675',
},
web: {
backgroundColor: '#095FFF',
},
}),
},
markerContainer: {
position: 'absolute',
width: 48,
height: 48,
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
},
topmarkerContainer: {
zIndex: 1,
},
touch: {
backgroundColor: 'transparent',
justifyContent: 'center',
alignItems: 'center',
alignSelf: 'stretch',
},
});