diff --git a/packages/react-native-ui-lib/src/incubator/slider/Thumb.tsx b/packages/react-native-ui-lib/src/incubator/slider/Thumb.tsx index d8b374f591..6bc3e5de2e 100644 --- a/packages/react-native-ui-lib/src/incubator/slider/Thumb.tsx +++ b/packages/react-native-ui-lib/src/incubator/slider/Thumb.tsx @@ -24,6 +24,7 @@ interface ThumbProps extends ViewProps { onSeekStart?: () => void; onSeekEnd?: () => void; enableShadow?: boolean; + isActive: SharedValue; } const SHADOW_RADIUS = 4; @@ -53,7 +54,8 @@ const Thumb = (props: ThumbProps) => { stepInterpolatedValue, gap = 0, secondary, - enableShadow + enableShadow, + isActive } = props; const rtlFix = Constants.isRTL ? -1 : 1; @@ -96,12 +98,13 @@ const Thumb = (props: ThumbProps) => { gesture.enabled(!disabled); const animatedStyle = useAnimatedStyle(() => { - const customStyle = isPressed.value ? activeStyle?.value : defaultStyle?.value; + const active = isPressed.value || isActive.value; + const customStyle = active ? activeStyle?.value : defaultStyle?.value; return { ...customStyle, transform: [ {translateX: (offset.value - thumbSize.value.width / 2) * rtlFix}, - {scale: withSpring(!disableActiveStyling && isPressed.value ? 1.3 : 1)} + {scale: withSpring(!disableActiveStyling && active ? 1.3 : 1)} ] }; }); diff --git a/packages/react-native-ui-lib/src/incubator/slider/index.tsx b/packages/react-native-ui-lib/src/incubator/slider/index.tsx index 98f74e6a09..b3261152a1 100644 --- a/packages/react-native-ui-lib/src/incubator/slider/index.tsx +++ b/packages/react-native-ui-lib/src/incubator/slider/index.tsx @@ -2,7 +2,7 @@ import _ from 'lodash'; import React, {ReactElement, useImperativeHandle, useCallback, useMemo, useEffect, useRef} from 'react'; import {StyleSheet, AccessibilityRole, StyleProp, ViewStyle, GestureResponderEvent, LayoutChangeEvent, ViewProps, AccessibilityProps} from 'react-native'; import {useSharedValue, useAnimatedStyle, runOnJS, useAnimatedReaction, withTiming} from 'react-native-reanimated'; -import {GestureHandlerRootView} from 'react-native-gesture-handler'; +import {GestureHandlerRootView, GestureDetector, Gesture} from 'react-native-gesture-handler'; import {forwardRef, ForwardRefInjectedProps, Constants} from '../../commons/new'; import {extractAccessibilityProps} from '../../commons/modifiers'; import {Colors, Spacings} from '../../style'; @@ -15,6 +15,7 @@ import { getValueForOffset, getStepInterpolated } from './SliderPresenter'; +import View from '../../components/view'; import Thumb from './Thumb'; import Track from './Track'; @@ -135,11 +136,16 @@ export interface SliderProps extends AccessibilityProps { * The slider's test identifier */ testID?: string; - /** + /** * Whether to use the new Slider implementation using Reanimated */ migrate?: boolean; - /** + /** + * If true, dragging anywhere on the slider moves the thumb relative to its current position + * instead of snapping to the touch point. Designed for single-thumb mode. + */ + useRelativeDrag?: boolean; + /** * Control the throttle time of the onValueChange and onRangeChange callbacks */ throttleTime?: number; @@ -193,6 +199,7 @@ const Slider = React.memo((props: Props) => { accessible = true, testID, enableThumbShadow = true, + useRelativeDrag, throttleTime = 200 } = themeProps; @@ -373,6 +380,42 @@ const Slider = React.memo((props: Props) => { } }; + const containerDragStartOffset = useSharedValue(0); + const isContainerDragging = useSharedValue(false); + + const clampOffset = (offset: number) => { + 'worklet'; + return Math.max(0, Math.min(trackSize.value.width, offset)); + }; + + const snapToStep = () => { + 'worklet'; + if (shouldBounceToStep) { + const step = stepInterpolatedValue.value; + defaultThumbOffset.value = Math.round(defaultThumbOffset.value / step) * step; + } + }; + + const containerGesture = Gesture.Pan() + .onBegin(() => { + containerDragStartOffset.value = defaultThumbOffset.value; + isContainerDragging.value = true; + _onSeekStart(); + }) + .onUpdate(e => { + if (trackSize.value.width === 0) { + return; + } + const dx = e.translationX * (shouldDisableRTL ? 1 : rtlFix); + defaultThumbOffset.value = clampOffset(containerDragStartOffset.value + dx); + }) + .onEnd(() => _onSeekEnd()) + .onFinalize(() => { + isContainerDragging.value = false; + snapToStep(); + }); + containerGesture.enabled(!disabled && !!useRelativeDrag); + const trackAnimatedStyles = useAnimatedStyle(() => { if (useRange) { return { @@ -399,6 +442,7 @@ const Slider = React.memo((props: Props) => { onSeekEnd={_onSeekEnd} shouldDisableRTL={shouldDisableRTL} disabled={disabled} + isActive={isContainerDragging} disableActiveStyling={disableActiveStyling} defaultStyle={_thumbStyle} activeStyle={_activeThumbStyle} @@ -425,15 +469,29 @@ const Slider = React.memo((props: Props) => { ); }; + const renderSliderContent = () => ( + <> + {_renderTrack()} + {renderThumb(ThumbType.DEFAULT)} + {useRange && renderThumb(ThumbType.RANGE)} + + ); + return ( - {_renderTrack()} - {renderThumb(ThumbType.DEFAULT)} - {useRange && renderThumb(ThumbType.RANGE)} + {useRelativeDrag ? ( + + + {renderSliderContent()} + + + ) : ( + renderSliderContent() + )} ); }); @@ -446,6 +504,10 @@ const styles = StyleSheet.create({ height: THUMB_SIZE + SHADOW_RADIUS, justifyContent: 'center' }, + gestureContainer: { + flex: 1, + justifyContent: 'center' + }, disableRTL: { transform: [{scaleX: -1}] },