// noinspection JSSuspiciousNameCombination,BadExpressionStatementJS import React, { FC, memo, useCallback, useMemo, useRef, useState } from 'react'; import { Insets, LayoutChangeEvent, StyleProp, StyleSheet, TextStyle, View, ViewStyle } from 'react-native'; import { Gesture, GestureDetector, GestureUpdateEvent, PanGestureHandlerEventPayload, TapGestureHandlerEventPayload, } from 'react-native-gesture-handler'; import type { WithTimingConfig } from 'react-native-reanimated'; import Animated, { runOnJS, useAnimatedReaction, useAnimatedStyle, useDerivedValue, useSharedValue, withRepeat, withSequence, withSpring, withTiming, } from 'react-native-reanimated'; import { Bubble, BubbleRef } from '../src'; import { palette } from '../src/theme/palette'; import { clamp } from '../src/utils'; import { GestureStateChangeEvent } from 'react-native-gesture-handler/src/handlers/gestureHandlerCommon'; const formatSeconds = (second: number) => `${Math.round(second * 100) / 100}`; const hitSlop = { top: 12, bottom: 12, }; export enum HapticModeEnum { NONE = 'none', STEP = 'step', BOTH = 'both', } export enum PanDirectionEnum { START = 0, LEFT = 1, RIGHT = 2, END = 3, } export type SliderThemeType = | { /** * Color to fill the progress in the seekbar */ minimumTrackTintColor?: string; minimumTrackBorderColor?: string; /** * Color to fill the background in the seekbar */ maximumTrackTintColor?: string; /** * Color to fill the cache in the seekbar */ cacheTrackTintColor?: string; /** * Color to fill the bubble backgrouundColor */ bubbleBackgroundColor?: string; /** * Bubble text color */ bubbleTextColor?: string; /** * Disabled color to fill the progress in the seekbar */ disableMinTrackTintColor?: string; /** * Color to fill the heartbeat animation in the seekbar */ heartbeatColor?: string; } | null | undefined; export type AwesomeSliderProps = { /** * Style for the container view */ style?: StyleProp; sliderText?: string; sliderTextStyle?: StyleProp; sliderHeight?: number; containerStyle?: StyleProp; /** * A function that gets the current value of the slider as you slide it, * and returns a string to be used inside the bubble. if not provided it will use the * current value as integer. */ bubble?: (s: number) => string; /** * An Animated.SharedValue from `react-native-reanimated` library which is the * current value of the slider. */ progress: Animated.SharedValue; /** * A Animated.SharedValue from `react-native-reanimated` library which is the * curren value of the cache. the cache is optional and will be rendered behind * the main progress indicator. */ cache?: Animated.SharedValue; /** * An Animated.SharedValue from `react-native-reanimated` library which is the minimum value of the slider. */ minimumValue: Animated.SharedValue; /** * An Animated.SharedValue from `react-native-reanimated` library which is themaximum value of the slider. */ maximumValue: Animated.SharedValue; /** * Callback called when the users starts sliding */ onSlidingStart?: () => void; onSlidingTouchBegin?: (event: PanGestureHandlerEventPayload) => void; onSlidingTouchStart?: (event: PanGestureHandlerEventPayload) => void; onSlidingTouchUpdate?: (event: PanGestureHandlerEventPayload) => void; onSlidingTouchEnd?: ( event: PanGestureHandlerEventPayload | undefined, events: PanGestureHandlerEventPayload[], touchTime: number | undefined // undefined is quick click ) => void; /** * Callback called when slide value change */ onValueChange?: (value: number) => void; /** * Callback called when the users stop sliding. the new value will be passed as argument * @package touchTime undefined is quick click */ onSlidingComplete?: (value: number, events: PanGestureHandlerEventPayload[], touchTime: number | undefined) => void; /** * Render custom Bubble to show when sliding. */ renderBubble?: () => React.ReactNode; /** * This function will be called while sliding, and should set the text inside your custom bubble. */ setBubbleText?: (s: string) => void; /** * Value to pass to the container of the bubble as `translateY` */ bubbleTranslateY?: number; /** * Render custom thumb image. if you need to customize thumb, you also need to set the `thumb width` */ renderThumb?: () => React.ReactNode; /** * Render custom mark element. if you need to customize mark, you also need to set the `mark width` */ renderMark?: ({ index }: { index: number }) => React.ReactNode; /** * Thumb elements width, default 15 */ thumbWidth?: number; /** * Disable slider */ disable?: boolean; /** * Enable tap event change value, default true */ disableTapEvent?: boolean; /** * Bubble elements max width, default 100. */ bubbleMaxWidth?: number; bubbleTextStyle?: StyleProp; bubbleContainerStyle?: StyleProp; /** * By this, you know the slider status as quickly as possible.(This is useful when you doing video-palyer’s scrubber.) */ isScrubbing?: Animated.SharedValue; /** * On tap slider event.(This is useful when you doing video-palyer’s scrubber.) */ onTap?: () => void; /** * By this, you can control thumb’s transform-scale animation. */ thumbScaleValue?: Animated.SharedValue; panHitSlop?: Insets; step?: number; /** * withTiming options when step is defined. if false, no animation will be used. default false. */ stepTimingOptions?: false | WithTimingConfig; markStyle?: StyleProp; markWidth?: number; onHapticFeedback?: () => void; hapticMode?: `${HapticModeEnum}`; theme?: SliderThemeType; /** * Current swipe direction * @enum Animated.SharedValue */ panDirectionValue?: Animated.SharedValue; /** * Disable track follow thumb.(Commonly used in video/audio players) */ disableTrackFollow?: boolean; /** * Bubble width, If you set this value, bubble positioning left & right will be clamp. */ bubbleWidth?: number; bubbleAlwaysShow?: boolean; bubbleUseThumbTranslateX?: boolean; minimumTrackBorderWidth?: number; minimumTrackBorderRadius?: number; /** * set minimum track container outside if true, default false */ minimumTrackContainerOutside?: boolean; testID?: string; snapToStep?: boolean; /** * Range along X axis (in points) where fingers travels without activation of * gesture. Moving outside of this range implies activation of gesture. * * @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#activeoffsetxvalue-number--number */ activeOffsetX?: number | number[]; /** * Range along Y axis (in points) where fingers travels without activation of * gesture. Moving outside of this range implies activation of gesture. * * @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#activeoffsetyvalue-number--number */ activeOffsetY?: number | number[]; /** * When the finger moves outside this range (in points) along X axis and * gesture hasn't yet activated it will fail recognizing the gesture. Range * can be given as an array or a single number. * * @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#failoffsetyvalue-number--number */ failOffsetX?: number | number[]; /** * When the finger moves outside this range (in points) along Y axis and * gesture hasn't yet activated it will fail recognizing the gesture. Range * can be given as an array or a single number * * @see https://docs.swmansion.com/react-native-gesture-handler/docs/gestures/pan-gesture#failoffsetxvalue-number--number */ failOffsetY?: number | number[]; /** * When 'heartbeat' is set to true, the progress bar color will animate back and forth between its current color and the color specified for the heartbeat. */ heartbeat?: boolean; }; const defaultTheme: SliderThemeType = { minimumTrackTintColor: palette.Main, minimumTrackBorderColor: palette.Main, maximumTrackTintColor: palette.Gray, cacheTrackTintColor: palette.DeepGray, bubbleBackgroundColor: palette.Main, bubbleTextColor: palette.White, heartbeatColor: palette.LightGray, }; export const Slider: FC = memo(function Slider({ bubble, bubbleContainerStyle, bubbleMaxWidth = 100, bubbleTextStyle, bubbleTranslateY = -25, bubbleWidth = 0, bubbleAlwaysShow = false, bubbleUseThumbTranslateX = false, cache, containerStyle, disable = false, disableTapEvent = false, disableTrackFollow = false, hapticMode = 'none', isScrubbing, markStyle, markWidth = 4, maximumValue, minimumValue, onHapticFeedback, onSlidingComplete, onSlidingStart, onSlidingTouchBegin, onSlidingTouchStart, onSlidingTouchUpdate, onSlidingTouchEnd, onTap, onValueChange, panDirectionValue, panHitSlop = hitSlop, progress, renderBubble, renderThumb, renderMark, setBubbleText, sliderHeight = 5, sliderText, sliderTextStyle, step, stepTimingOptions = false, style, testID, theme, thumbScaleValue, thumbWidth = 15, snapToStep = true, activeOffsetX, activeOffsetY, failOffsetX, failOffsetY, heartbeat = false, minimumTrackContainerOutside = false, minimumTrackBorderWidth = 0, minimumTrackBorderRadius = 0, }) { const snappingEnabled = snapToStep && step; const panGestureEventArray = useSharedValue([]); const bubbleRef = useRef(null); const isScrubbingInner = useSharedValue(false); const touchTime = useSharedValue(0); const prevX = useSharedValue(0); const defaultThumbIndex = useMemo(() => { if (!snappingEnabled) { return 0; } const index = Math.round( ((progress.value - minimumValue.value) / (maximumValue.value - minimumValue.value)) * step ); return clamp(index, 0, step); }, [maximumValue.value, minimumValue.value, progress.value, snappingEnabled, step]); const thumbIndex = useSharedValue(defaultThumbIndex); const [sliderWidth, setSliderWidth] = useState(0); const width = useSharedValue(0); const thumbValue = useSharedValue(0); const bubbleOpacity = useSharedValue(0); const markLeftArr = useSharedValue([]); const thumbTranslateX = useSharedValue(0); const isTriggedHaptic = useSharedValue(false); const _theme = { ...defaultTheme, ...theme, }; const sliderTotalValue = useDerivedValue(() => { 'worklet'; return maximumValue.value - minimumValue.value; }, []); const progressToValue = (value: number) => { 'worklet'; if (sliderTotalValue.value === 0) { return 0; } return ((value - minimumValue.value) / sliderTotalValue.value) * (width.value - thumbWidth); }; const animatedSeekStyle = useAnimatedStyle(() => { let seekWidth: number; // when you set step if (snappingEnabled && markLeftArr.value.length >= step) { seekWidth = markLeftArr.value[thumbIndex.value]! + thumbWidth / 2; } else { seekWidth = progressToValue(progress.value) + thumbWidth / 2; } sliderTotalValue.value; // hack: force recompute styles when 'sliderTotalValue' changes return { width: snappingEnabled && stepTimingOptions ? withTiming(clamp(seekWidth, 0, width.value), stepTimingOptions) : clamp(seekWidth, 0, width.value), }; }, [progress, minimumValue, maximumValue, width, markLeftArr]); const animatedThumbStyle = useAnimatedStyle(() => { let translateX: number; // when you set step 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 ? width.value - thumbWidth : 0); } else { translateX = clamp(progressToValue(progress.value), 0, width.value ? width.value - thumbWidth : 0); } thumbTranslateX.value = translateX; sliderTotalValue.value; // hack: force recompute styles when 'sliderTotalValue' change return { transform: [ { translateX, }, { scale: withTiming(thumbScaleValue ? thumbScaleValue.value : 1, { duration: 100, }), }, ], }; }, [progress, minimumValue, maximumValue, width.value]); const animatedBubbleStyle = useAnimatedStyle(() => { let translateX: number; // when set step if (snappingEnabled && markLeftArr.value.length >= step) { translateX = markLeftArr.value[thumbIndex.value]! + thumbWidth / 2; } else { translateX = bubbleUseThumbTranslateX ?? false ? thumbTranslateX.value + thumbWidth / 2 : thumbValue.value + thumbWidth / 2; } return { opacity: bubbleAlwaysShow ? 1 : bubbleOpacity.value, transform: [ { translateY: bubbleTranslateY, }, { translateX: snappingEnabled && stepTimingOptions ? withTiming(clamp(translateX, bubbleWidth / 2, width.value - bubbleWidth / 2), stepTimingOptions) : clamp(translateX, bubbleWidth / 2, width.value - bubbleWidth / 2), }, { scale: bubbleAlwaysShow ? 1 : bubbleOpacity.value, }, ], }; }); const animatedCacheXStyle = useAnimatedStyle(() => { const cacheX = cache?.value && sliderTotalValue.value ? (cache?.value / sliderTotalValue.value) * width.value : 0; return { width: cacheX, }; }); 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, }; }); const onSlideAcitve = useCallback( (seconds: number) => { 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.value, minimumValue.value, sliderTotalValue.value, step, thumbIndex.value, thumbValue.value, thumbWidth, width.value, snappingEnabled, ]); /** * convert [x] position to progress * @returns number */ const xToProgress = useCallback( (x: number): number => { 'worklet'; if (snappingEnabled && markLeftArr.value.length >= step) { return markLeftArr.value[thumbIndex.value]!; } else { return minimumValue.value + (x / (width.value - thumbWidth)) * sliderTotalValue.value; } }, [ markLeftArr.value, sliderTotalValue.value, step, thumbIndex.value, thumbWidth, width.value, minimumValue.value, snappingEnabled, ] ); /** * change slide value */ const onActiveSlider = useCallback( (x: number) => { '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; } runOnJS(onSlideAcitve)(shareValueToSeconds()); } 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()); } }, [ disableTrackFollow, hapticMode, isScrubbing, isScrubbingInner, isTriggedHaptic, markLeftArr.value, onHapticFeedback, onSlideAcitve, progress, shareValueToSeconds, step, thumbIndex, thumbValue, thumbWidth, width.value, xToProgress, snappingEnabled, ] ); const onGestureEvent = useMemo(() => { const gesture = Gesture.Pan() .hitSlop(panHitSlop) .onBegin((event: GestureStateChangeEvent) => { if (disable) { return; } panGestureEventArray.value = []; touchTime.value = new Date().getTime(); panGestureEventArray.value.push(event); if (onSlidingTouchBegin) { runOnJS(onSlidingTouchBegin)(event); } }) .onStart((event: GestureStateChangeEvent) => { if (disable) { return; } isScrubbingInner.value = false; if (isScrubbing) { isScrubbing.value = true; } if (panDirectionValue) { panDirectionValue.value = PanDirectionEnum.START; prevX.value = 0; } if (onSlidingStart) { runOnJS(onSlidingStart)(); } panGestureEventArray.value.push(event); if (onSlidingTouchStart) { runOnJS(onSlidingTouchStart)(event); } }) .onUpdate((event: GestureUpdateEvent) => { if (disable) { return; } if (panDirectionValue) { panDirectionValue.value = prevX.value - event.x > 0 ? PanDirectionEnum.LEFT : PanDirectionEnum.RIGHT; prevX.value = event.x; } bubbleOpacity.value = withSpring(1); onActiveSlider(event.x); panGestureEventArray.value.push(event); if (onSlidingTouchUpdate) { runOnJS(onSlidingTouchUpdate)(event); } }) .onEnd((event: GestureStateChangeEvent, _success: boolean) => { isScrubbingInner.value = false; if (disable) { return; } if (isScrubbing) { isScrubbing.value = false; } if (panDirectionValue) { panDirectionValue.value = PanDirectionEnum.END; } bubbleOpacity.value = withSpring(0); if (disableTrackFollow) { progress.value = xToProgress(event.x); } touchTime.value = new Date().getTime() - touchTime.value; panGestureEventArray.value.push(event); if (onSlidingTouchEnd) { runOnJS(onSlidingTouchEnd)(event, panGestureEventArray.value, touchTime.value); } if (onSlidingComplete) { runOnJS(onSlidingComplete)(shareValueToSeconds(), panGestureEventArray.value, touchTime.value); } }); if (activeOffsetX) { gesture.activeOffsetX(activeOffsetX); } if (activeOffsetY) { gesture.activeOffsetY(activeOffsetY); } if (failOffsetX) { gesture.failOffsetX(failOffsetX); } if (failOffsetY) { gesture.failOffsetY(failOffsetY); } return gesture; }, [ touchTime, onSlidingTouchStart, panGestureEventArray, activeOffsetX, activeOffsetY, bubbleOpacity, disable, disableTrackFollow, failOffsetX, failOffsetY, isScrubbing, isScrubbingInner, onActiveSlider, onSlidingComplete, onSlidingStart, onSlidingTouchBegin, onSlidingTouchEnd, onSlidingTouchUpdate, panDirectionValue, panHitSlop, prevX, progress, shareValueToSeconds, xToProgress, ]); const onSingleTapEvent = useMemo( () => Gesture.Tap() .hitSlop(panHitSlop) .onBegin((_event: GestureStateChangeEvent) => { if (disable || disableTapEvent) { return; } }) .onEnd((event: GestureStateChangeEvent, isFinished) => { if (disable || disableTapEvent) { return; } if (onTap) { runOnJS(onTap)(); } if (isFinished) { onActiveSlider(event.x); } isScrubbingInner.value = true; if (isScrubbing) { isScrubbing.value = true; } bubbleOpacity.value = withSpring(0); }), [bubbleOpacity, disable, disableTapEvent, isScrubbing, isScrubbingInner, onActiveSlider, onTap, panHitSlop] ); const gesture = useMemo(() => Gesture.Race(onSingleTapEvent, onGestureEvent), [onGestureEvent, onSingleTapEvent]); // setting markLeftArr useAnimatedReaction( () => { if (snappingEnabled) { return new Array(step + 1).fill(0).map((_, i) => { return Math.round(width.value * (i / step)) - (i / step) * markWidth - Math.round(thumbWidth / 3); }); } return []; }, (data) => { markLeftArr.value = data; }, [thumbWidth, markWidth, step, progress, width] ); // setting thumbValue useAnimatedReaction( () => { if (isScrubbingInner.value) { return undefined; } if (snappingEnabled) { return undefined; } const currentValue = (progress.value / (minimumValue.value + maximumValue.value)) * (width.value - (disableTrackFollow ? thumbWidth : 0)); return clamp(currentValue, 0, width.value - thumbWidth); }, (data) => { if (data !== undefined) { thumbValue.value = data; } }, [thumbWidth, maximumValue, minimumValue, step, progress, width] ); const onLayout = ({ nativeEvent }: LayoutChangeEvent) => { const layoutWidth = nativeEvent.layout.width; width.value = layoutWidth; setSliderWidth(layoutWidth); }; const minimumTrackView = () => { return ( {!!sliderText?.length && {sliderText}} ); }; return ( {!minimumTrackContainerOutside && minimumTrackView()} {minimumTrackContainerOutside && minimumTrackView()} {sliderWidth > 0 && step ? new Array(step + 1).fill(0).map((_, i) => { const left = sliderWidth * (i / step) - (i / step) * markWidth; return renderMark ? ( {renderMark({ index: i })} ) : ( ); }) : null} {renderThumb ? ( renderThumb() ) : ( )} {renderBubble ? ( renderBubble() ) : ( )} ); }); 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', }, seekContainer: { height: '100%', width: '100%', left: 0, position: 'absolute', alignItems: 'center', justifyContent: 'center', }, text: { fontSize: 14, lineHeight: 16, color: '#333', textAlign: 'center', }, seek: { height: '100%', maxWidth: '100%', left: 0, position: 'absolute', }, mark: { height: 4, backgroundColor: '#fff', position: 'absolute', }, customMarkContainer: { position: 'absolute', }, thumb: { position: 'absolute', left: 0, }, bubble: { position: 'absolute', }, });