react-native-awesome-slider
Version:
A versatile, responsive <Slider /> component for React Native and Web.
625 lines (613 loc) • 21.2 kB
JavaScript
import React, { memo, useCallback, useMemo, useRef, useState } from 'react';
import { StyleSheet, View, I18nManager } from 'react-native';
import { Gesture, GestureDetector } from 'react-native-gesture-handler';
import Animated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withSpring, withTiming, withRepeat, withSequence } from 'react-native-reanimated';
import { Bubble } from './ballon';
import { palette } from './theme/palette';
import { clamp } from './utils';
import { HitSlop } from './hit-slop';
const formatSeconds = second => `${Math.round(second * 100) / 100}`;
const hitSlop = {
top: 12,
bottom: 12
};
export let HapticModeEnum = /*#__PURE__*/function (HapticModeEnum) {
HapticModeEnum["NONE"] = "none";
HapticModeEnum["STEP"] = "step";
HapticModeEnum["BOTH"] = "both";
return HapticModeEnum;
}({});
export let PanDirectionEnum = /*#__PURE__*/function (PanDirectionEnum) {
PanDirectionEnum[PanDirectionEnum["START"] = 0] = "START";
PanDirectionEnum[PanDirectionEnum["LEFT"] = 1] = "LEFT";
PanDirectionEnum[PanDirectionEnum["RIGHT"] = 2] = "RIGHT";
PanDirectionEnum[PanDirectionEnum["END"] = 3] = "END";
return PanDirectionEnum;
}({});
const defaultTheme = {
minimumTrackTintColor: palette.Main,
maximumTrackTintColor: palette.Gray,
cacheTrackTintColor: palette.DeepGray,
bubbleBackgroundColor: palette.Main,
bubbleTextColor: palette.White,
heartbeatColor: palette.LightGray
};
export const Slider = /*#__PURE__*/memo(function Slider({
bubble,
bubbleContainerStyle,
bubbleMaxWidth = 100,
bubbleOffsetX = 0,
bubbleTextStyle,
bubbleTranslateY = -25,
bubbleWidth = 0,
cache,
containerStyle,
disable = false,
disableTapEvent = false,
disableTrackFollow = false,
disableTrackPress = false,
hapticMode = 'none',
isScrubbing,
markStyle,
markWidth = 4,
maximumValue,
minimumValue,
onHapticFeedback,
onSlidingComplete,
onSlidingStart,
onTap,
onValueChange,
panDirectionValue,
panHitSlop = hitSlop,
progress,
renderContainer,
renderBubble,
renderThumb,
renderMark,
renderTrack,
setBubbleText,
sliderHeight = 5,
step: propsStep,
steps,
stepTimingOptions = false,
style,
testID,
theme,
thumbScaleValue,
thumbWidth = 15,
thumbTouchSize = thumbWidth,
snapToStep = false,
forceSnapToStep = false,
activeOffsetX,
activeOffsetY,
failOffsetX,
failOffsetY,
heartbeat = false,
snapThreshold = 0,
snapThresholdMode = 'absolute',
isRTL = I18nManager.isRTL
}) {
const step = propsStep || steps;
const snappingEnabled = (snapToStep || forceSnapToStep) && !!step;
const bubbleRef = useRef(null);
const isScrubbingInner = useSharedValue(false);
const prevX = useSharedValue(0);
const isTouchInThumbRange = useSharedValue(false);
const thumbIndex = useSharedValue(0);
const [sliderWidth, setSliderWidth] = useState(0);
const width = useSharedValue(0);
const thumbValue = useSharedValue(0);
const bubbleOpacity = useSharedValue(0);
const markLeftArr = useSharedValue([]);
const isTriggedHaptic = useSharedValue(false);
const _theme = {
...defaultTheme,
...theme
};
const sliderTotalValue = useDerivedValue(() => {
'worklet';
return maximumValue.value - minimumValue.value;
}, []);
const progressToValue = value => {
'worklet';
if (sliderTotalValue.value === 0) {
return 0;
}
return (value - minimumValue.value) / sliderTotalValue.value * (width.value - thumbWidth);
};
const animatedSeekStyle = useAnimatedStyle(() => {
let seekWidth = 0;
// when you set step
if (snappingEnabled && markLeftArr.value.length >= step) {
seekWidth = markLeftArr.value[thumbIndex.value] + thumbWidth / 2;
} else {
seekWidth = progressToValue(progress.value) + thumbWidth / 2;
}
return {
width: snappingEnabled && stepTimingOptions ? withTiming(clamp(seekWidth, 0, width.value), stepTimingOptions) : clamp(seekWidth, 0, width.value)
};
}, [progress, minimumValue, maximumValue, width, markLeftArr, snappingEnabled]);
const animatedThumbStyle = useAnimatedStyle(() => {
let translateX = 0;
// when you set step
const subtractedWidth = width.value - thumbWidth;
if (snappingEnabled && markLeftArr.value.length >= step) {
translateX = stepTimingOptions ? withTiming(markLeftArr.value[thumbIndex.value], stepTimingOptions) : markLeftArr.value[thumbIndex.value];
} else if (disableTrackFollow && isScrubbingInner.value) {
translateX = clamp(thumbValue.value, 0, width.value ? subtractedWidth : 0);
} else {
translateX = clamp(progressToValue(progress.value), 0, width.value ? subtractedWidth : 0);
}
return {
transform: [{
translateX: isRTL ? subtractedWidth - translateX : translateX
}, {
scale: withTiming(thumbScaleValue ? thumbScaleValue.value : 1, {
duration: 100
})
}]
};
}, [progress, minimumValue, maximumValue, width, snappingEnabled, isRTL]);
const animatedBubbleStyle = useAnimatedStyle(() => {
let translateX = 0;
// when set step
if (snappingEnabled && markLeftArr.value.length >= step) {
translateX = markLeftArr.value[thumbIndex.value] + thumbWidth / 2;
} else {
translateX = thumbValue.value + thumbWidth / 2;
}
translateX = isRTL ? width.value - translateX : translateX;
const minX = bubbleWidth / 2 - thumbWidth / 2;
const maxX = width.value - bubbleWidth / 2;
return {
opacity: bubbleOpacity.value,
transform: [{
translateY: bubbleTranslateY
}, {
translateX: snappingEnabled && stepTimingOptions ? withTiming(clamp(translateX, minX, maxX), stepTimingOptions) : clamp(translateX, minX, maxX)
}, {
scale: bubbleOpacity.value
}]
};
}, [bubbleTranslateY, bubbleWidth, width, snappingEnabled, isRTL]);
const animatedCacheXStyle = useAnimatedStyle(() => {
const cacheX = cache?.value && sliderTotalValue.value ? cache?.value / sliderTotalValue.value * width.value : 0;
return {
width: cacheX
};
}, [cache, sliderTotalValue, width]);
const animatedHeartbeatStyle = useAnimatedStyle(() => {
// Goes to one and zero continuously
const opacity = heartbeat ? withSequence(withTiming(1, {
duration: 1000
}), withRepeat(withTiming(0, {
duration: 1000
}), -1, true)) : withTiming(0, {
duration: 500
});
return {
width: sliderWidth,
opacity
};
}, [sliderWidth, heartbeat]);
const onSlideAcitve = useCallback(seconds => {
const bubbleText = bubble ? bubble?.(seconds) : formatSeconds(seconds);
onValueChange?.(seconds);
setBubbleText ? setBubbleText(bubbleText) : bubbleRef.current?.setText(bubbleText);
}, [bubble, onValueChange, setBubbleText]);
/**
* convert Sharevalue to callback seconds
* @returns number
*/
const shareValueToSeconds = useCallback(() => {
'worklet';
if (snappingEnabled) {
return clamp(minimumValue.value + thumbIndex.value / step * (maximumValue.value - minimumValue.value), minimumValue.value, maximumValue.value);
} else {
const sliderPercent = clamp(thumbValue.value / (width.value - thumbWidth), 0, 1);
return minimumValue.value + clamp(sliderPercent * sliderTotalValue.value, 0, sliderTotalValue.value);
}
}, [maximumValue, minimumValue, sliderTotalValue, step, thumbIndex, thumbValue, thumbWidth, width, snappingEnabled]);
/**
* convert [x] position to progress
* @returns number
*/
const xToProgress = useCallback(x => {
'worklet';
if (snappingEnabled && markLeftArr.value.length >= step) {
return markLeftArr.value[thumbIndex.value];
} else {
return minimumValue.value + x / (width.value - thumbWidth) * sliderTotalValue.value;
}
}, [markLeftArr, sliderTotalValue, step, thumbIndex, thumbWidth, width, minimumValue, snappingEnabled]);
const nearestMarkX = useDerivedValue(() => {
'worklet';
if (!step) {
return 0;
}
const stepSize = (width.value - thumbWidth) / step;
const currentStep = Math.abs(Math.round(thumbValue.value / stepSize));
// calculate nearest mark position
return currentStep * stepSize;
}, [thumbValue, width, thumbWidth, step]);
const thresholdDistance = useDerivedValue(() => {
'worklet';
if (!step) {
return 0;
}
const stepSize = (width.value - thumbWidth) / step;
return snapThresholdMode === 'percentage' ? stepSize * snapThreshold : snapThreshold;
}, [snapThreshold]);
/**
* change slide value
*/
const onActiveSlider = useCallback(x => {
'worklet';
isScrubbingInner.value = true;
if (isScrubbing) {
isScrubbing.value = true;
}
if (snappingEnabled) {
const index = markLeftArr.value.findIndex(item => item >= x);
const arrNext = markLeftArr.value[index];
const arrPrev = markLeftArr.value[index - 1];
// Computing step boundaries
const currentX = (arrNext + arrPrev) / 2;
const thumbIndexPrev = thumbIndex.value;
if (x - thumbWidth / 2 > currentX) {
thumbIndex.value = index;
} else {
if (index - 1 === -1) {
thumbIndex.value = 0;
} else if (index - 1 < -1) {
thumbIndex.value = step;
} else {
thumbIndex.value = index - 1;
}
}
// Determine trigger haptics callback
if (thumbIndexPrev !== thumbIndex.value && hapticMode === HapticModeEnum.STEP && onHapticFeedback) {
runOnJS(onHapticFeedback)();
isTriggedHaptic.value = true;
} else {
isTriggedHaptic.value = false;
}
if (!disableTrackFollow) {
progress.value = shareValueToSeconds();
}
} else {
if (step && snapThreshold) {
// calculate distance to nearest mark
const distance = Math.abs(x - nearestMarkX.value);
// if distance <= snapThreshold, snap to nearest mark
if (distance <= thresholdDistance.value) {
thumbValue.value = nearestMarkX.value;
} else {
thumbValue.value = clamp(x, 0, width.value - thumbWidth);
}
} else {
thumbValue.value = clamp(x, 0, width.value - thumbWidth);
}
if (!disableTrackFollow) {
progress.value = xToProgress(x);
}
// Determines whether the thumb slides to both ends
if (x <= 0 || x >= width.value - thumbWidth) {
if (!isTriggedHaptic.value && hapticMode === HapticModeEnum.BOTH && onHapticFeedback) {
runOnJS(onHapticFeedback)();
isTriggedHaptic.value = true;
}
} else {
isTriggedHaptic.value = false;
}
}
runOnJS(onSlideAcitve)(shareValueToSeconds());
}, [isScrubbingInner, isScrubbing, snappingEnabled, markLeftArr, thumbIndex, thumbWidth, hapticMode, onHapticFeedback, onSlideAcitve, shareValueToSeconds, step, isTriggedHaptic, snapThreshold, disableTrackFollow, width, nearestMarkX, thresholdDistance, thumbValue, progress, xToProgress]);
const thumbPosition = useDerivedValue(() => {
return (snappingEnabled && markLeftArr.value.length >= step ? markLeftArr.value[thumbIndex.value] : thumbValue.value) || 0;
}, [thumbIndex, thumbValue, snappingEnabled, markLeftArr, step]);
const onGestureEvent = useMemo(() => {
const gesture = Gesture.Pan().hitSlop(panHitSlop).onBegin(({
x: xValue
}) => {
const x = isRTL ? width.value - xValue : xValue;
if (disableTrackPress) {
isTouchInThumbRange.value = Math.abs(x - thumbPosition.value) <= thumbTouchSize;
}
}).onStart(() => {
if (disable) {
return;
}
isScrubbingInner.value = false;
if (isScrubbing) {
isScrubbing.value = true;
}
if (panDirectionValue) {
panDirectionValue.value = PanDirectionEnum.START;
prevX.value = 0;
}
if (onSlidingStart) {
runOnJS(onSlidingStart)();
}
}).onUpdate(({
x: xValue
}) => {
const x = isRTL ? width.value - xValue : xValue;
if (disable || !isTouchInThumbRange.value && disableTrackPress) {
return;
}
if (panDirectionValue) {
panDirectionValue.value = prevX.value - x > 0 ? PanDirectionEnum.LEFT : PanDirectionEnum.RIGHT;
prevX.value = x;
}
bubbleOpacity.value = withSpring(1);
onActiveSlider(x);
}).onEnd(({
x: xValue
}) => {
const x = isRTL ? width.value - xValue : xValue;
isScrubbingInner.value = false;
if (disable) {
return;
}
if (isScrubbing) {
isScrubbing.value = false;
}
if (panDirectionValue) {
panDirectionValue.value = PanDirectionEnum.END;
}
if (step && snapThreshold) {
const distance = Math.abs(x - nearestMarkX.value);
// if distance <= snapThreshold, snap to nearest mark
if (distance <= thresholdDistance.value) {
progress.value = xToProgress(nearestMarkX.value);
} else {}
}
bubbleOpacity.value = withSpring(0);
if (disableTrackFollow) {
progress.value = xToProgress(x);
}
if (onSlidingComplete) {
runOnJS(onSlidingComplete)(shareValueToSeconds());
}
});
if (activeOffsetX) {
gesture.activeOffsetX(activeOffsetX);
}
if (activeOffsetY) {
gesture.activeOffsetY(activeOffsetY);
}
if (failOffsetX) {
gesture.failOffsetX(failOffsetX);
}
if (failOffsetY) {
gesture.failOffsetY(failOffsetY);
}
return gesture;
}, [panHitSlop, activeOffsetX, activeOffsetY, failOffsetX, failOffsetY, isTouchInThumbRange, thumbPosition, thumbTouchSize, disable, isScrubbingInner, isScrubbing, panDirectionValue, onSlidingStart, prevX, bubbleOpacity, onActiveSlider, step, snapThreshold, disableTrackFollow, onSlidingComplete, nearestMarkX, thresholdDistance, progress, xToProgress, shareValueToSeconds, disableTrackPress, isRTL, width]);
const onSingleTapEvent = useMemo(() => Gesture.Tap().hitSlop(panHitSlop).onEnd(({
x: xValue
}, isFinished) => {
const x = isRTL ? width.value - xValue : xValue;
if (onTap) {
runOnJS(onTap)();
}
if (disable || disableTapEvent) {
return;
}
if (isFinished) {
onActiveSlider(x);
}
isScrubbingInner.value = true;
if (isScrubbing) {
isScrubbing.value = true;
}
bubbleOpacity.value = withSpring(0);
if (onSlidingComplete) {
runOnJS(onSlidingComplete)(shareValueToSeconds());
}
}).onFinalize(() => {
if (isScrubbing) {
isScrubbing.value = false;
}
}), [bubbleOpacity, disable, disableTapEvent, isScrubbing, isScrubbingInner, onActiveSlider, onSlidingComplete, onTap, panHitSlop, shareValueToSeconds, isRTL, width]);
const gesture = useMemo(() => Gesture.Race(onSingleTapEvent, onGestureEvent), [onGestureEvent, onSingleTapEvent]);
// setting markLeftArr
useAnimatedReaction(() => {
if (snappingEnabled) {
return new Array(step + 1).fill(0).map((_, i) => {
// Calculate the actual available width for each step.
const availableWidth = width.value - thumbWidth;
// Calculate the spacing of each step.
const stepSize = availableWidth / step;
// Calculate the position of each mark.
return i * stepSize;
});
}
return [];
}, data => {
markLeftArr.value = data;
if (snappingEnabled) {
const index = Math.round((progress.value - minimumValue.value) / (maximumValue.value - minimumValue.value) * step);
thumbIndex.value = clamp(index, 0, step);
}
}, [thumbWidth, markWidth, step, progress, width, snappingEnabled]);
// setting thumbIndex
useAnimatedReaction(() => {
if (isScrubbingInner.value) {
return undefined;
}
if (!snappingEnabled) {
return undefined;
}
const currentIndex = Math.round((progress.value - minimumValue.value) / (maximumValue.value - minimumValue.value) * step);
return clamp(currentIndex, 0, step);
}, data => {
if (data !== undefined && !isNaN(data)) {
thumbIndex.value = data;
}
}, [thumbWidth, maximumValue, minimumValue, step, progress, width, snappingEnabled]);
// setting thumbValue
useAnimatedReaction(() => {
if (isScrubbingInner.value) {
return undefined;
}
if (snappingEnabled) {
return undefined;
}
const currentValue = progressToValue(progress.value);
return clamp(currentValue, 0, width.value - thumbWidth);
}, data => {
if (data !== undefined && !isNaN(data)) {
thumbValue.value = data;
}
}, [thumbWidth, maximumValue, minimumValue, step, progress, width, snappingEnabled]);
const onLayout = useCallback(({
nativeEvent
}) => {
const layoutWidth = nativeEvent.layout.width;
width.value = layoutWidth;
setSliderWidth(layoutWidth);
}, [width]);
return /*#__PURE__*/React.createElement(GestureDetector, {
gesture: gesture
}, /*#__PURE__*/React.createElement(HitSlop, panHitSlop, /*#__PURE__*/React.createElement(Animated.View, {
testID: testID,
style: [styles.view, {
height: sliderHeight
}, style],
hitSlop: panHitSlop,
onLayout: onLayout
}, renderContainer ? renderContainer({
style: StyleSheet.flatten([styles.slider, {
height: sliderHeight,
backgroundColor: _theme.maximumTrackTintColor
}, containerStyle]),
seekStyle: [styles.seek, {
backgroundColor: disable ? _theme.disableMinTrackTintColor : _theme.minimumTrackTintColor
}, animatedSeekStyle],
cacheXStyle: [styles.cache, {
backgroundColor: _theme.cacheTrackTintColor
}, animatedCacheXStyle],
heartbeatStyle: [styles.heartbeat, {
backgroundColor: _theme.heartbeatColor
}, animatedHeartbeatStyle]
}) : /*#__PURE__*/React.createElement(Animated.View, {
style: StyleSheet.flatten([styles.slider, {
height: sliderHeight,
backgroundColor: _theme.maximumTrackTintColor
}, containerStyle])
}, /*#__PURE__*/React.createElement(Animated.View, {
style: [styles.cache, {
backgroundColor: _theme.cacheTrackTintColor
}, animatedCacheXStyle]
}), /*#__PURE__*/React.createElement(Animated.View, {
style: [styles.heartbeat, {
backgroundColor: _theme.heartbeatColor
}, animatedHeartbeatStyle]
}), /*#__PURE__*/React.createElement(Animated.View, {
style: [styles.seek, {
backgroundColor: disable ? _theme.disableMinTrackTintColor : _theme.minimumTrackTintColor
}, animatedSeekStyle]
})), sliderWidth > 0 && step ? new Array(step + 1).fill(0).map((_, i) => {
const markLeft = sliderWidth * (i / step) - i / step * markWidth;
const nextMarkLeft = sliderWidth * ((i + 1) / step);
return /*#__PURE__*/React.createElement(React.Fragment, {
key: i
}, renderTrack && /*#__PURE__*/React.createElement(View, {
style: [styles.customTrackContainer, {
left: markLeft,
width: nextMarkLeft - markLeft
}]
}, renderTrack({
index: i
})), renderMark ? /*#__PURE__*/React.createElement(View, {
style: [styles.customMarkContainer, {
left: markLeft,
width: markWidth
}]
}, renderMark({
index: i
})) : /*#__PURE__*/React.createElement(View, {
style: [styles.mark, {
width: markWidth,
borderRadius: markWidth,
left: markLeft
}, markStyle]
}));
}) : null, /*#__PURE__*/React.createElement(Animated.View, {
style: [styles.thumb, animatedThumbStyle, isRTL ? {
right: 0
} : {
left: 0
}]
}, renderThumb ? renderThumb() : /*#__PURE__*/React.createElement(View, {
style: {
backgroundColor: _theme.minimumTrackTintColor,
height: thumbWidth,
width: thumbWidth,
borderRadius: thumbWidth
}
})), /*#__PURE__*/React.createElement(Animated.View, {
style: [styles.bubble, {
width: bubbleMaxWidth
}, isRTL ? {
right: -bubbleMaxWidth / 2 + bubbleOffsetX
} : {
left: -bubbleMaxWidth / 2 + bubbleOffsetX
}, animatedBubbleStyle]
}, renderBubble ? renderBubble() : /*#__PURE__*/React.createElement(Bubble, {
ref: bubbleRef,
color: _theme.bubbleBackgroundColor,
textColor: _theme.bubbleTextColor,
textStyle: bubbleTextStyle,
containerStyle: bubbleContainerStyle,
bubbleMaxWidth: bubbleMaxWidth
})))));
});
const styles = StyleSheet.create({
slider: {
width: '100%',
overflow: 'hidden'
},
view: {
flex: 1,
alignItems: 'center',
justifyContent: 'center'
},
cache: {
height: '100%',
left: 0,
position: 'absolute'
},
heartbeat: {
height: '100%',
left: 0,
position: 'absolute'
},
seek: {
height: '100%',
maxWidth: '100%',
left: 0,
position: 'absolute'
},
mark: {
height: 4,
backgroundColor: '#fff',
position: 'absolute'
},
customMarkContainer: {
position: 'absolute'
},
thumb: {
position: 'absolute'
},
bubble: {
position: 'absolute'
},
customTrackContainer: {
position: 'absolute',
height: '100%'
}
});
//# sourceMappingURL=slider.js.map