From cb1fc328d944c62d5b46e228748c14bcea8fc936 Mon Sep 17 00:00:00 2001 From: adids1221 Date: Sun, 15 Mar 2026 17:12:03 +0200 Subject: [PATCH 1/3] feat: add useRelativeDrag prop for relative drag on Slider Adds a new useRelativeDrag prop (default false). When enabled, 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. Made-with: Cursor --- .../src/components/slider/index.tsx | 62 ++++++++++++++++--- .../src/components/slider/slider.api.json | 6 ++ .../src/components/slider/types.ts | 5 ++ 3 files changed, 66 insertions(+), 7 deletions(-) diff --git a/packages/react-native-ui-lib/src/components/slider/index.tsx b/packages/react-native-ui-lib/src/components/slider/index.tsx index 0460b86c47..156fa93c6b 100644 --- a/packages/react-native-ui-lib/src/components/slider/index.tsx +++ b/packages/react-native-ui-lib/src/components/slider/index.tsx @@ -74,6 +74,7 @@ class Slider extends PureComponent { private minThumb = React.createRef(); private activeThumbRef: React.RefObject; private panResponder; + private containerPanResponder; private minTrack = React.createRef(); private _minTrackStyles: MinTrackStyle = {}; @@ -97,6 +98,7 @@ class Slider extends PureComponent { private dimensionsChangeListener: any; private didMount: boolean; + private _containerDragInitialValue = 0; constructor(props: InternalSliderProps) { super(props); @@ -123,6 +125,16 @@ class Slider extends PureComponent { onPanResponderEnd: () => true, onPanResponderTerminationRequest: () => false }); + + this.containerPanResponder = PanResponder.create({ + onMoveShouldSetPanResponder: this.handleMoveShouldSetPanResponder, + onPanResponderGrant: this.handleContainerGrant, + onPanResponderMove: this.handleContainerMove, + onPanResponderRelease: this.handleContainerEnd, + onStartShouldSetPanResponder: () => true, + onPanResponderEnd: () => true, + onPanResponderTerminationRequest: () => false + }); } reset() { @@ -237,6 +249,32 @@ class Slider extends PureComponent { this.onSeekEnd(); }; + handleContainerGrant = () => { + this._containerDragInitialValue = this.getValueForX(this._x); + this.onSeekStart(); + }; + + handleContainerMove = (_e: GestureResponderEvent, gestureState: PanResponderGestureState) => { + if (this.props.disabled) { + return; + } + const {minimumValue, maximumValue} = this.props; + const range = this.getRange(); + const containerWidth = this.state.containerSize.width; + const dx = gestureState.dx * (Constants.isRTL && !this.disableRTL ? -1 : 1); + const deltaValue = (dx / containerWidth) * range; + const newValue = _.clamp(this._containerDragInitialValue + deltaValue, minimumValue, maximumValue); + const newX = this.getXForValue(newValue); + this.set_x(newX); + this.moveTo(newX); + this.updateValue(newX); + }; + + handleContainerEnd = () => { + this.bounceToStep(); + this.onSeekEnd(); + }; + /* Actions */ setActiveThumb = (ref: React.RefObject) => { @@ -591,23 +629,27 @@ class Slider extends PureComponent { /* Renders */ renderMinThumb = () => { + const {useRelativeDrag} = this.props; return ( ); }; renderThumb = () => { + const {useRelativeDrag} = this.props; return ( ); }; @@ -664,20 +706,26 @@ class Slider extends PureComponent { } render() { - const {containerStyle, testID, migrate} = this.props; + const {containerStyle, testID, migrate, useRelativeDrag} = this.props; if (migrate) { return ; } + const containerGestureProps = useRelativeDrag + ? this.containerPanResponder.panHandlers + : { + onStartShouldSetResponder: this.handleContainerShouldSetResponder, + onResponderRelease: this.handleTrackPress + }; + return ( {this.renderTrack()} diff --git a/packages/react-native-ui-lib/src/components/slider/slider.api.json b/packages/react-native-ui-lib/src/components/slider/slider.api.json index 9a7537d0da..71da738993 100644 --- a/packages/react-native-ui-lib/src/components/slider/slider.api.json +++ b/packages/react-native-ui-lib/src/components/slider/slider.api.json @@ -140,6 +140,12 @@ "type": "string", "description": "The component test id" }, + { + "name": "useRelativeDrag", + "type": "boolean", + "description": "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.", + "default": "false" + }, { "name": "migrate", "type": "boolean", diff --git a/packages/react-native-ui-lib/src/components/slider/types.ts b/packages/react-native-ui-lib/src/components/slider/types.ts index 3349cb294d..c7c1b1d0ee 100644 --- a/packages/react-native-ui-lib/src/components/slider/types.ts +++ b/packages/react-native-ui-lib/src/components/slider/types.ts @@ -97,6 +97,11 @@ export type SliderProps = Omit & { * The slider's test identifier */ testID?: string; + /** + * 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; /** * Whether to use the new Slider implementation using Reanimated */ From a588a7d88a2843330b0d591119be1b8227c27934 Mon Sep 17 00:00:00 2001 From: adids1221 Date: Mon, 23 Mar 2026 11:50:36 +0200 Subject: [PATCH 2/3] feat: add useRelativeDrag prop to Incubator.Slider --- .../src/incubator/slider/Thumb.tsx | 4 +- .../src/incubator/slider/index.tsx | 67 +++++++++++++++++-- 2 files changed, 65 insertions(+), 6 deletions(-) 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 38d7af1b7e..530d80cab8 100644 --- a/packages/react-native-ui-lib/src/incubator/slider/Thumb.tsx +++ b/packages/react-native-ui-lib/src/incubator/slider/Thumb.tsx @@ -53,7 +53,8 @@ const Thumb = (props: ThumbProps) => { stepInterpolatedValue, gap = 0, secondary, - enableShadow + enableShadow, + pointerEvents } = props; const rtlFix = Constants.isRTL ? -1 : 1; @@ -117,6 +118,7 @@ const Thumb = (props: ThumbProps) => { { accessible = true, testID, enableThumbShadow = true, + useRelativeDrag, throttleTime = 200 } = themeProps; @@ -373,6 +380,37 @@ const Slider = React.memo((props: Props) => { } }; + const containerDragStartOffset = useSharedValue(0); + + 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; + _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(snapToStep); + containerGesture.enabled(!disabled && !!useRelativeDrag); + const trackAnimatedStyles = useAnimatedStyle(() => { if (useRange) { return { @@ -399,6 +437,7 @@ const Slider = React.memo((props: Props) => { onSeekEnd={_onSeekEnd} shouldDisableRTL={shouldDisableRTL} disabled={disabled} + pointerEvents={useRelativeDrag ? 'none' : undefined} disableActiveStyling={disableActiveStyling} defaultStyle={_thumbStyle} activeStyle={_activeThumbStyle} @@ -415,7 +454,7 @@ 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 +499,10 @@ const styles = StyleSheet.create({ height: THUMB_SIZE + SHADOW_RADIUS, justifyContent: 'center' }, + gestureContainer: { + flex: 1, + justifyContent: 'center' + }, disableRTL: { transform: [{scaleX: -1}] }, From 11d44aa61ba3ed534f23e14aab1e8e4fd20e43dd Mon Sep 17 00:00:00 2001 From: adids1221 Date: Mon, 23 Mar 2026 12:12:03 +0200 Subject: [PATCH 3/3] fix: animate thumb scale during useRelativeDrag for Slider and Incubator.Slider --- .../src/components/slider/Thumb.tsx | 7 ++++++- .../src/components/slider/index.tsx | 16 ++++++++++++++-- .../src/incubator/slider/Thumb.tsx | 11 +++++++---- .../src/incubator/slider/index.tsx | 8 +++++++- 4 files changed, 34 insertions(+), 8 deletions(-) diff --git a/packages/react-native-ui-lib/src/components/slider/Thumb.tsx b/packages/react-native-ui-lib/src/components/slider/Thumb.tsx index 17716efc8b..4811656ce1 100644 --- a/packages/react-native-ui-lib/src/components/slider/Thumb.tsx +++ b/packages/react-native-ui-lib/src/components/slider/Thumb.tsx @@ -39,6 +39,10 @@ export interface ThumbProps extends ViewProps { disabled?: boolean; /** ref to thumb component */ ref?: React.RefObject; + /** + * External scale animation value (used by useRelativeDrag) + */ + scaleAnimation?: Animated.Value; } type ThumbStyle = {style?: StyleProp; left?: StyleProp}; @@ -58,6 +62,7 @@ const Thumb = forwardRef((props: ThumbProps, ref: any) => { thumbHitSlop, onTouchStart, onTouchEnd, + scaleAnimation, ...others } = props; @@ -142,7 +147,7 @@ const Thumb = forwardRef((props: ThumbProps, ref: any) => { { transform: [ { - scale: thumbScaleAnimation.current + scale: scaleAnimation ?? thumbScaleAnimation.current } ] } diff --git a/packages/react-native-ui-lib/src/components/slider/index.tsx b/packages/react-native-ui-lib/src/components/slider/index.tsx index 156fa93c6b..5307b8920b 100644 --- a/packages/react-native-ui-lib/src/components/slider/index.tsx +++ b/packages/react-native-ui-lib/src/components/slider/index.tsx @@ -99,6 +99,7 @@ class Slider extends PureComponent { private didMount: boolean; private _containerDragInitialValue = 0; + private _containerThumbScale = new Animated.Value(1); constructor(props: InternalSliderProps) { super(props); @@ -251,6 +252,7 @@ class Slider extends PureComponent { handleContainerGrant = () => { this._containerDragInitialValue = this.getValueForX(this._x); + this.animateContainerThumbScale(1.5); this.onSeekStart(); }; @@ -272,9 +274,18 @@ class Slider extends PureComponent { handleContainerEnd = () => { this.bounceToStep(); + this.animateContainerThumbScale(1); this.onSeekEnd(); }; + animateContainerThumbScale = (toValue: number) => { + Animated.timing(this._containerThumbScale, { + toValue, + duration: 100, + useNativeDriver: true + }).start(); + }; + /* Actions */ setActiveThumb = (ref: React.RefObject) => { @@ -602,7 +613,7 @@ class Slider extends PureComponent { }; getThumbProps = () => { - const {thumbStyle, activeThumbStyle, disableActiveStyling, disabled, thumbTintColor, thumbHitSlop} = this.props; + const {thumbStyle, activeThumbStyle, disableActiveStyling, disabled, thumbTintColor, thumbHitSlop, useRelativeDrag} = this.props; const {thumbSize} = this.state; const verticalHitslop = Math.max(0, (48 - thumbSize.height) / 2); const horizontalHitslop = Math.max(0, (48 - thumbSize.width) / 2); @@ -623,7 +634,8 @@ class Slider extends PureComponent { activeThumbStyle, disableActiveStyling, thumbHitSlop: thumbHitSlop ?? calculatedHitSlop, - onLayout: this.onThumbLayout + onLayout: this.onThumbLayout, + scaleAnimation: useRelativeDrag ? this._containerThumbScale : undefined }; }; 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 530d80cab8..dcb0e99d82 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; @@ -54,7 +55,8 @@ const Thumb = (props: ThumbProps) => { gap = 0, secondary, enableShadow, - pointerEvents + pointerEvents, + isActive } = props; const rtlFix = Constants.isRTL ? -1 : 1; @@ -94,15 +96,16 @@ const Thumb = (props: ThumbProps) => { offset.value = Math.round(offset.value / stepInterpolatedValue.value) * stepInterpolatedValue.value; } }); - gesture.enabled(!disabled); + gesture.enabled(!disabled && pointerEvents !== 'none'); 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 9e66ee32e5..fe29d95ec9 100644 --- a/packages/react-native-ui-lib/src/incubator/slider/index.tsx +++ b/packages/react-native-ui-lib/src/incubator/slider/index.tsx @@ -381,6 +381,7 @@ const Slider = React.memo((props: Props) => { }; const containerDragStartOffset = useSharedValue(0); + const isContainerDragging = useSharedValue(false); const clampOffset = (offset: number) => { 'worklet'; @@ -398,6 +399,7 @@ const Slider = React.memo((props: Props) => { const containerGesture = Gesture.Pan() .onBegin(() => { containerDragStartOffset.value = defaultThumbOffset.value; + isContainerDragging.value = true; _onSeekStart(); }) .onUpdate(e => { @@ -408,7 +410,10 @@ const Slider = React.memo((props: Props) => { defaultThumbOffset.value = clampOffset(containerDragStartOffset.value + dx); }) .onEnd(() => _onSeekEnd()) - .onFinalize(snapToStep); + .onFinalize(() => { + isContainerDragging.value = false; + snapToStep(); + }); containerGesture.enabled(!disabled && !!useRelativeDrag); const trackAnimatedStyles = useAnimatedStyle(() => { @@ -438,6 +443,7 @@ const Slider = React.memo((props: Props) => { shouldDisableRTL={shouldDisableRTL} disabled={disabled} pointerEvents={useRelativeDrag ? 'none' : undefined} + isActive={useRelativeDrag ? isContainerDragging : undefined} disableActiveStyling={disableActiveStyling} defaultStyle={_thumbStyle} activeStyle={_activeThumbStyle}