From 8bceb77cc752fabc806e0bcaf1911fabd619032f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Mon, 30 Mar 2026 16:13:10 +0200 Subject: [PATCH 1/9] Add minimumAnimationDuration prop --- .../components/button_underlay/index.tsx | 1 + .../RNGestureHandlerButtonViewManager.kt | 29 +++++++++++++++- .../apple/RNGestureHandlerButton.h | 1 + .../apple/RNGestureHandlerButton.mm | 34 ++++++++++++++++--- .../RNGestureHandlerButtonComponentView.mm | 1 + .../src/components/GestureHandlerButton.tsx | 9 ++++- .../components/GestureHandlerButton.web.tsx | 34 +++++++++++++++++-- .../RNGestureHandlerButtonNativeComponent.ts | 1 + 8 files changed, 102 insertions(+), 8 deletions(-) diff --git a/apps/common-app/src/new_api/components/button_underlay/index.tsx b/apps/common-app/src/new_api/components/button_underlay/index.tsx index 1c4c54409c..bc9abb849f 100644 --- a/apps/common-app/src/new_api/components/button_underlay/index.tsx +++ b/apps/common-app/src/new_api/components/button_underlay/index.tsx @@ -10,6 +10,7 @@ const UNDERLAY_PROPS = { underlayColor: 'red', activeUnderlayOpacity: 0.5, animationDuration: 200, + minimumAnimationDuration: 100, rippleColor: 'transparent', } as const; diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 31428bed99..317e7d9337 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -15,6 +15,7 @@ import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape import android.os.Build +import android.os.SystemClock import android.util.TypedValue import android.view.KeyEvent import android.view.MotionEvent @@ -43,6 +44,7 @@ import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerDelegate import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerInterface import com.swmansion.gesturehandler.core.NativeViewGestureHandler import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup +import kotlin.math.min @ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS) class RNGestureHandlerButtonViewManager : @@ -271,6 +273,11 @@ class RNGestureHandlerButtonViewManager : view.animationDuration = animationDuration } + @ReactProp(name = "minimumAnimationDuration") + override fun setMinimumAnimationDuration(view: ButtonViewGroup, minimumAnimationDuration: Int) { + view.minimumAnimationDuration = minimumAnimationDuration + } + @ReactProp(name = "defaultOpacity") override fun setDefaultOpacity(view: ButtonViewGroup, defaultOpacity: Float) { view.defaultOpacity = defaultOpacity @@ -347,6 +354,7 @@ class RNGestureHandlerButtonViewManager : var exclusive = true var animationDuration: Int = 100 + var minimumAnimationDuration: Int = 0 var activeOpacity: Float = 1.0f var defaultOpacity: Float = 1.0f var activeScale: Float = 1.0f @@ -369,6 +377,8 @@ class RNGestureHandlerButtonViewManager : private var receivedKeyEvent = false private var currentAnimator: AnimatorSet? = null private var underlayDrawable: PaintDrawable? = null + private var pressInTimestamp = 0L + private var pendingPressOut: Runnable? = null // When non-null the ripple is drawn in dispatchDraw (above background, below children). // When null the ripple lives on the foreground drawable instead. @@ -516,11 +526,28 @@ class RNGestureHandlerButtonViewManager : } private fun animatePressIn() { + pendingPressOut?.let { + handler.removeCallbacks(it) + pendingPressOut = null + } + pressInTimestamp = SystemClock.uptimeMillis() animateTo(activeOpacity, activeScale, activeUnderlayOpacity) } private fun animatePressOut() { - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + val animationTime = SystemClock.uptimeMillis() - pressInTimestamp + val remainingAnimationTime = min(animationDuration, minimumAnimationDuration) - animationTime + + if (remainingAnimationTime <= 0) { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + } else { + val runnable = Runnable { + pendingPressOut = null + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + } + pendingPressOut = runnable + handler.postDelayed(runnable, remainingAnimationTime) + } } private fun createUnderlayDrawable(): PaintDrawable { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 2672d0b37a..752511f558 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -28,6 +28,7 @@ @property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents; @property (nonatomic, assign) NSInteger animationDuration; +@property (nonatomic, assign) NSInteger minimumAnimationDuration; @property (nonatomic, assign) CGFloat activeOpacity; @property (nonatomic, assign) CGFloat defaultOpacity; @property (nonatomic, assign) CGFloat activeScale; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 7510c156d1..15b034691b 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -43,6 +43,8 @@ @implementation RNGestureHandlerButton { CALayer *_underlayLayer; CGFloat _underlayCornerRadii[8]; // [tlH, tlV, trH, trV, blH, blV, brH, brV] outer radii in points UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset + NSTimeInterval _pressInTimestamp; + BOOL _pendingPressOut; } - (void)commonInit @@ -57,7 +59,10 @@ - (void)commonInit _defaultScale = 1.0; _activeUnderlayOpacity = 0.0; _defaultUnderlayOpacity = 0.0; + _minimumAnimationDuration = 0; _underlayColor = nil; + _pressInTimestamp = 0; + _pendingPressOut = NO; #if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing #endif @@ -234,6 +239,8 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF - (void)handleAnimatePressIn { + _pendingPressOut = NO; + _pressInTimestamp = CACurrentMediaTime(); RNGHUIView *target = self.animationTarget ?: self; [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { @@ -243,10 +250,29 @@ - (void)handleAnimatePressIn - (void)handleAnimatePressOut { - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity]; + NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; + NSTimeInterval remaining = MIN(_animationDuration, _minimumAnimationDuration) - elapsed; + + if (remaining <= 0) { + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity]; + } + } else { + _pendingPressOut = YES; + __weak auto weakSelf = self; + dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{ + __strong auto strongSelf = weakSelf; + if (strongSelf && strongSelf->_pendingPressOut) { + strongSelf->_pendingPressOut = NO; + RNGHUIView *target = strongSelf.animationTarget ?: strongSelf; + [strongSelf animateTarget:target toOpacity:strongSelf->_defaultOpacity scale:strongSelf->_defaultScale]; + if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) { + [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity]; + } + } + }); } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index e840cfb296..fc69ffb9e1 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -238,6 +238,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _buttonView.userEnabled = newProps.enabled; _buttonView.animationDuration = newProps.animationDuration; + _buttonView.minimumAnimationDuration = newProps.minimumAnimationDuration; _buttonView.activeOpacity = newProps.activeOpacity; _buttonView.defaultOpacity = newProps.defaultOpacity; _buttonView.activeScale = newProps.activeScale; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 7e13963721..8da81a38d0 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -59,10 +59,17 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { touchSoundDisabled?: boolean | undefined; /** - * Duration of the animation when the button is pressed. + * Duration of the animation when the button is pressed in milliseconds. Defaults to 100ms. */ animationDuration?: number | undefined; + /** + * Minimum duration (in milliseconds) that the press-in animation must run + * before the press-out animation is allowed to start. Useful for ensuring + * the pressed state is visible on quick taps. Defaults to 50ms. + */ + minimumAnimationDuration?: number | undefined; + /** * Opacity applied to the button when it is pressed. */ diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 7ba5d1a69f..c20584617f 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -5,6 +5,7 @@ type ButtonProps = ViewProps & { ref?: React.Ref>; enabled?: boolean; animationDuration?: number; + minimumAnimationDuration?: number; activeOpacity?: number; activeScale?: number; activeUnderlayOpacity?: number; @@ -17,6 +18,7 @@ type ButtonProps = ViewProps & { export const ButtonComponent = ({ enabled = true, animationDuration = 100, + minimumAnimationDuration = 0, activeOpacity = 1, activeScale = 1, activeUnderlayOpacity = 0, @@ -29,16 +31,44 @@ export const ButtonComponent = ({ ...rest }: ButtonProps) => { const [pressed, setPressed] = React.useState(false); + const pressInTimestamp = React.useRef(0); + const pressOutTimer = React.useRef | null>( + null + ); + + React.useEffect(() => { + return () => { + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + } + }; + }, []); const pressIn = React.useCallback(() => { if (enabled) { + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + pressInTimestamp.current = Date.now(); setPressed(true); } }, [enabled]); const pressOut = React.useCallback(() => { - setPressed(false); - }, []); + const elapsed = Date.now() - pressInTimestamp.current; + const remaining = + Math.min(animationDuration, minimumAnimationDuration) - elapsed; + + if (remaining > 0) { + pressOutTimer.current = setTimeout(() => { + pressOutTimer.current = null; + setPressed(false); + }, remaining); + } else { + setPressed(false); + } + }, [animationDuration, minimumAnimationDuration]); const currentUnderlayOpacity = pressed ? activeUnderlayOpacity diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 6ab8bcaca7..ba9064d946 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -20,6 +20,7 @@ interface NativeProps extends ViewProps { 'auto' >; animationDuration?: WithDefault; + minimumAnimationDuration?: WithDefault; activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault; From c57169255f3ed6fc8b20963dcc59341301d8798f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Tue, 31 Mar 2026 16:21:36 +0200 Subject: [PATCH 2/9] Update minimumAnimationDuration behavior --- .../components/button_underlay/index.tsx | 3 +- .../RNGestureHandlerButtonViewManager.kt | 24 +++--- .../apple/RNGestureHandlerButton.mm | 80 ++++++++++++++----- .../components/GestureHandlerButton.web.tsx | 31 ++++--- 4 files changed, 96 insertions(+), 42 deletions(-) diff --git a/apps/common-app/src/new_api/components/button_underlay/index.tsx b/apps/common-app/src/new_api/components/button_underlay/index.tsx index bc9abb849f..43aac8ed2a 100644 --- a/apps/common-app/src/new_api/components/button_underlay/index.tsx +++ b/apps/common-app/src/new_api/components/button_underlay/index.tsx @@ -8,7 +8,8 @@ import { const UNDERLAY_PROPS = { underlayColor: 'red', - activeUnderlayOpacity: 0.5, + activeUnderlayOpacity: 0.2, + activeScale: 0.9, animationDuration: 200, minimumAnimationDuration: 100, rippleColor: 'transparent', diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 317e7d9337..a081d71ebd 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -44,7 +44,6 @@ import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerDelegate import com.facebook.react.viewmanagers.RNGestureHandlerButtonManagerInterface import com.swmansion.gesturehandler.core.NativeViewGestureHandler import com.swmansion.gesturehandler.react.RNGestureHandlerButtonViewManager.ButtonViewGroup -import kotlin.math.min @ReactModule(name = RNGestureHandlerButtonViewManager.REACT_CLASS) class RNGestureHandlerButtonViewManager : @@ -497,7 +496,7 @@ class RNGestureHandlerButtonViewManager : underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt() } - private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) { + private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float, durationMs: Long) { val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f val hasScale = activeScale != 1.0f || defaultScale != 1.0f val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null @@ -519,7 +518,7 @@ class RNGestureHandlerButtonViewManager : } currentAnimator = AnimatorSet().apply { playTogether(animators) - duration = animationDuration.toLong() + duration = durationMs interpolator = LinearOutSlowInInterpolator() start() } @@ -531,22 +530,27 @@ class RNGestureHandlerButtonViewManager : pendingPressOut = null } pressInTimestamp = SystemClock.uptimeMillis() - animateTo(activeOpacity, activeScale, activeUnderlayOpacity) + animateTo(activeOpacity, activeScale, activeUnderlayOpacity, animationDuration.toLong()) } private fun animatePressOut() { - val animationTime = SystemClock.uptimeMillis() - pressInTimestamp - val remainingAnimationTime = min(animationDuration, minimumAnimationDuration) - animationTime + val elapsed = SystemClock.uptimeMillis() - pressInTimestamp - if (remainingAnimationTime <= 0) { - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + if (elapsed >= animationDuration) { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, animationDuration.toLong()) + } else if (elapsed * 2 >= minimumAnimationDuration) { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed) } else { + val remaining = minimumAnimationDuration - elapsed + pendingPressOut?.let { handler.removeCallbacks(it) } + animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining) + val runnable = Runnable { pendingPressOut = null - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, minimumAnimationDuration.toLong()) } pendingPressOut = runnable - handler.postDelayed(runnable, remainingAnimationTime) + handler.postDelayed(runnable, remaining) } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index c2abd6c288..a25c039446 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -45,7 +45,7 @@ @implementation RNGestureHandlerButton { CGFloat _underlayCornerRadii[8]; // [tlH, tlV, trH, trV, blH, blV, brH, brV] outer radii in points UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset NSTimeInterval _pressInTimestamp; - BOOL _pendingPressOut; + dispatch_block_t _pendingPressOutBlock; } - (void)commonInit @@ -64,7 +64,7 @@ - (void)commonInit _minimumAnimationDuration = 0; _underlayColor = nil; _pressInTimestamp = 0; - _pendingPressOut = NO; + _pendingPressOutBlock = nil; #if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing #endif @@ -154,12 +154,15 @@ - (BOOL)shouldHandleTouch:(RNGHUIView *)view #endif } -- (void)animateUnderlayToOpacity:(float)toOpacity +- (void)animateUnderlayToOpacity:(float)toOpacity duration:(NSTimeInterval)durationMs { + _underlayLayer.opacity = [_underlayLayer.presentationLayer opacity]; + [_underlayLayer removeAllAnimations]; + CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"opacity"]; - anim.fromValue = @([_underlayLayer.presentationLayer opacity]); + anim.fromValue = @(_underlayLayer.opacity); anim.toValue = @(toOpacity); - anim.duration = _animationDuration / 1000.0; + anim.duration = durationMs / 1000.0; anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; _underlayLayer.opacity = toOpacity; [_underlayLayer addAnimation:anim forKey:@"opacity"]; @@ -204,14 +207,21 @@ - (void)applyStartAnimationState #endif } -- (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGFloat)scale +- (void)animateTarget:(RNGHUIView *)target + toOpacity:(CGFloat)opacity + scale:(CGFloat)scale + duration:(NSTimeInterval)durationMs { - NSTimeInterval duration = _animationDuration / 1000.0; + target.layer.transform = target.layer.presentationLayer.transform; + target.alpha = target.layer.presentationLayer.opacity; + [target.layer removeAllAnimations]; + + NSTimeInterval duration = durationMs / 1000.0; #if !TARGET_OS_OSX [UIView animateWithDuration:duration delay:0 - options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState + options:UIViewAnimationOptionCurveEaseInOut animations:^{ if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { target.alpha = opacity; @@ -241,40 +251,68 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF - (void)handleAnimatePressIn { - _pendingPressOut = NO; + if (_pendingPressOutBlock) { + dispatch_block_cancel(_pendingPressOutBlock); + _pendingPressOutBlock = nil; + } _pressInTimestamp = CACurrentMediaTime(); RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale]; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:_animationDuration]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity]; + [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:_animationDuration]; } } - (void)handleAnimatePressOut { NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; - NSTimeInterval remaining = MIN(_animationDuration, _minimumAnimationDuration) - elapsed; - if (remaining <= 0) { + if (elapsed >= _animationDuration) { + // Press-in animation fully finished, animate out in _animationDuration + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:_animationDuration]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:_animationDuration]; + } + } else if (elapsed * 2 >= _minimumAnimationDuration) { + // Past minimum but press-in animation still playing, animate out in elapsed time RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale]; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:elapsed]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity]; + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:elapsed]; } } else { - _pendingPressOut = YES; + // Before minimum duration, finish press-in in remaining time then animate out in _minimumAnimationDuration + NSTimeInterval remaining = _minimumAnimationDuration - elapsed; + if (_pendingPressOutBlock) { + dispatch_block_cancel(_pendingPressOutBlock); + } + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:remaining]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:remaining]; + } + __weak auto weakSelf = self; - dispatch_after(dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)), dispatch_get_main_queue(), ^{ + _pendingPressOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ __strong auto strongSelf = weakSelf; - if (strongSelf && strongSelf->_pendingPressOut) { - strongSelf->_pendingPressOut = NO; + if (strongSelf) { + strongSelf->_pendingPressOutBlock = nil; RNGHUIView *target = strongSelf.animationTarget ?: strongSelf; - [strongSelf animateTarget:target toOpacity:strongSelf->_defaultOpacity scale:strongSelf->_defaultScale]; + [strongSelf animateTarget:target + toOpacity:strongSelf->_defaultOpacity + scale:strongSelf->_defaultScale + duration:strongSelf->_minimumAnimationDuration]; if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) { - [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity]; + [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity + duration:strongSelf->_minimumAnimationDuration]; } } }); + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)), + dispatch_get_main_queue(), + _pendingPressOutBlock); } } diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index c20584617f..59ea98b787 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -31,6 +31,8 @@ export const ButtonComponent = ({ ...rest }: ButtonProps) => { const [pressed, setPressed] = React.useState(false); + const [currentDuration, setCurrentDuration] = + React.useState(animationDuration); const pressInTimestamp = React.useRef(0); const pressOutTimer = React.useRef | null>( null @@ -51,22 +53,31 @@ export const ButtonComponent = ({ pressOutTimer.current = null; } pressInTimestamp.current = Date.now(); + setCurrentDuration(animationDuration); setPressed(true); } - }, [enabled]); + }, [enabled, animationDuration]); const pressOut = React.useCallback(() => { const elapsed = Date.now() - pressInTimestamp.current; - const remaining = - Math.min(animationDuration, minimumAnimationDuration) - elapsed; - if (remaining > 0) { + if (elapsed >= animationDuration) { + setCurrentDuration(animationDuration); + setPressed(false); + } else if (elapsed * 2 >= minimumAnimationDuration) { + setCurrentDuration(elapsed); + setPressed(false); + } else { + // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time + const remaining = minimumAnimationDuration - elapsed; + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + } pressOutTimer.current = setTimeout(() => { pressOutTimer.current = null; + setCurrentDuration(minimumAnimationDuration); setPressed(false); }, remaining); - } else { - setPressed(false); } }, [animationDuration, minimumAnimationDuration]); @@ -79,13 +90,13 @@ export const ButtonComponent = ({ const hasScale = activeScale !== 1 || defaultScale !== 1; const currentScale = pressed ? activeScale : defaultScale; - const easing = 'cubic-bezier(0, 0, 0.2, 1)'; + const easing = 'cubic-bezier(0.5, 1, 0.89, 1)'; const transitionProps: string[] = []; if (hasOpacity) { - transitionProps.push(`opacity ${animationDuration}ms ${easing}`); + transitionProps.push(`opacity ${currentDuration}ms ${easing}`); } if (hasScale) { - transitionProps.push(`transform ${animationDuration}ms ${easing}`); + transitionProps.push(`transform ${currentDuration}ms ${easing}`); } const transition = transitionProps.join(', '); @@ -119,7 +130,7 @@ export const ButtonComponent = ({ backgroundColor: underlayColor as string, opacity: currentUnderlayOpacity, // @ts-ignore - web-only CSS properties - transition: `opacity ${animationDuration}ms ${easing}`, + transition: `opacity ${currentDuration}ms ${easing}`, pointerEvents: 'none', }} /> From af7ea633b735f4900e164acde9fe3239d0c767c4 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 10:48:18 +0200 Subject: [PATCH 3/9] Fallback to animationDuration to minimumAnimationDuration --- .../RNGestureHandlerButtonViewManager.kt | 6 +++-- .../apple/RNGestureHandlerButton.mm | 27 ++++++++++++------- .../components/GestureHandlerButton.web.tsx | 10 +++++-- .../RNGestureHandlerButtonNativeComponent.ts | 4 +-- 4 files changed, 32 insertions(+), 15 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index a081d71ebd..f1733cd4e5 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -352,8 +352,9 @@ class RNGestureHandlerButtonViewManager : var useBorderlessDrawable = false var exclusive = true - var animationDuration: Int = 100 - var minimumAnimationDuration: Int = 0 + var minimumAnimationDuration: Int = 100 + var animationDuration: Int = -1 + get() = if (field < 0) minimumAnimationDuration else field var activeOpacity: Float = 1.0f var defaultOpacity: Float = 1.0f var activeScale: Float = 1.0f @@ -538,6 +539,7 @@ class RNGestureHandlerButtonViewManager : if (elapsed >= animationDuration) { animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, animationDuration.toLong()) + // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play } else if (elapsed * 2 >= minimumAnimationDuration) { animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed) } else { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index a25c039446..6f94ce8e92 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -48,20 +48,22 @@ @implementation RNGestureHandlerButton { dispatch_block_t _pendingPressOutBlock; } +@synthesize animationDuration = _animationDuration; + - (void)commonInit { _isTouchInsideBounds = NO; _hitTestEdgeInsets = UIEdgeInsetsZero; _userEnabled = YES; _pointerEvents = RNGestureHandlerPointerEventsAuto; - _animationDuration = 100; + _animationDuration = -1; + _minimumAnimationDuration = 100; _activeOpacity = 1.0; _defaultOpacity = 1.0; _activeScale = 1.0; _defaultScale = 1.0; _activeUnderlayOpacity = 0.0; _defaultUnderlayOpacity = 0.0; - _minimumAnimationDuration = 0; _underlayColor = nil; _pressInTimestamp = 0; _pendingPressOutBlock = nil; @@ -101,6 +103,11 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +- (NSInteger)animationDuration +{ + return _animationDuration < 0 ? _minimumAnimationDuration : _animationDuration; +} + - (void)setUnderlayColor:(RNGHColor *)underlayColor { _underlayColor = underlayColor; @@ -257,23 +264,25 @@ - (void)handleAnimatePressIn } _pressInTimestamp = CACurrentMediaTime(); RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:_animationDuration]; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:self.animationDuration]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:_animationDuration]; + [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:self.animationDuration]; } } - (void)handleAnimatePressOut { NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; + NSInteger animationDuration = self.animationDuration; - if (elapsed >= _animationDuration) { - // Press-in animation fully finished, animate out in _animationDuration + if (elapsed >= animationDuration) { + // Press-in animation fully finished, animate out in animationDuration RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:_animationDuration]; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:animationDuration]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:_animationDuration]; + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:animationDuration]; } + // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play } else if (elapsed * 2 >= _minimumAnimationDuration) { // Past minimum but press-in animation still playing, animate out in elapsed time RNGHUIView *target = self.animationTarget ?: self; @@ -282,7 +291,7 @@ - (void)handleAnimatePressOut [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:elapsed]; } } else { - // Before minimum duration, finish press-in in remaining time then animate out in _minimumAnimationDuration + // Before minimum duration, finish press-in in remaining time then animate out in minDuration NSTimeInterval remaining = _minimumAnimationDuration - elapsed; if (_pendingPressOutBlock) { dispatch_block_cancel(_pendingPressOutBlock); diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 59ea98b787..63e2a2b824 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -17,8 +17,8 @@ type ButtonProps = ViewProps & { export const ButtonComponent = ({ enabled = true, - animationDuration = 100, - minimumAnimationDuration = 0, + animationDuration: animationDurationProp = -1, + minimumAnimationDuration = 100, activeOpacity = 1, activeScale = 1, activeUnderlayOpacity = 0, @@ -30,6 +30,11 @@ export const ButtonComponent = ({ children, ...rest }: ButtonProps) => { + const animationDuration = + animationDurationProp < 0 + ? minimumAnimationDuration + : animationDurationProp; + const [pressed, setPressed] = React.useState(false); const [currentDuration, setCurrentDuration] = React.useState(animationDuration); @@ -64,6 +69,7 @@ export const ButtonComponent = ({ if (elapsed >= animationDuration) { setCurrentDuration(animationDuration); setPressed(false); + // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play } else if (elapsed * 2 >= minimumAnimationDuration) { setCurrentDuration(elapsed); setPressed(false); diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index ba9064d946..2678baed93 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -19,8 +19,8 @@ interface NativeProps extends ViewProps { 'box-none' | 'none' | 'box-only' | 'auto', 'auto' >; - animationDuration?: WithDefault; - minimumAnimationDuration?: WithDefault; + animationDuration?: WithDefault; + minimumAnimationDuration?: WithDefault; activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault; From 2d093586e9c7fe7e88f50079e7b778a8db30a793 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 10:59:23 +0200 Subject: [PATCH 4/9] Rename props --- .../RNGestureHandlerButtonViewManager.kt | 32 +++++++-------- .../apple/RNGestureHandlerButton.h | 4 +- .../apple/RNGestureHandlerButton.mm | 32 +++++++-------- .../RNGestureHandlerButtonComponentView.mm | 4 +- .../components/GestureHandlerButton.web.tsx | 39 ++++++++++--------- .../RNGestureHandlerButtonNativeComponent.ts | 4 +- 6 files changed, 58 insertions(+), 57 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index f1733cd4e5..6865097786 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -267,14 +267,14 @@ class RNGestureHandlerButtonViewManager : view.isSoundEffectsEnabled = !touchSoundDisabled } - @ReactProp(name = "animationDuration") - override fun setAnimationDuration(view: ButtonViewGroup, animationDuration: Int) { - view.animationDuration = animationDuration + @ReactProp(name = "pressAndHoldAnimationDuration") + override fun setPressAndHoldAnimationDuration(view: ButtonViewGroup, pressAndHoldAnimationDuration: Int) { + view.pressAndHoldAnimationDuration = pressAndHoldAnimationDuration } - @ReactProp(name = "minimumAnimationDuration") - override fun setMinimumAnimationDuration(view: ButtonViewGroup, minimumAnimationDuration: Int) { - view.minimumAnimationDuration = minimumAnimationDuration + @ReactProp(name = "tapAnimationDuration") + override fun setTapAnimationDuration(view: ButtonViewGroup, tapAnimationDuration: Int) { + view.tapAnimationDuration = tapAnimationDuration } @ReactProp(name = "defaultOpacity") @@ -352,9 +352,9 @@ class RNGestureHandlerButtonViewManager : var useBorderlessDrawable = false var exclusive = true - var minimumAnimationDuration: Int = 100 - var animationDuration: Int = -1 - get() = if (field < 0) minimumAnimationDuration else field + var tapAnimationDuration: Int = 100 + var pressAndHoldAnimationDuration: Int = -1 + get() = if (field < 0) tapAnimationDuration else field var activeOpacity: Float = 1.0f var defaultOpacity: Float = 1.0f var activeScale: Float = 1.0f @@ -531,25 +531,25 @@ class RNGestureHandlerButtonViewManager : pendingPressOut = null } pressInTimestamp = SystemClock.uptimeMillis() - animateTo(activeOpacity, activeScale, activeUnderlayOpacity, animationDuration.toLong()) + animateTo(activeOpacity, activeScale, activeUnderlayOpacity, pressAndHoldAnimationDuration.toLong()) } private fun animatePressOut() { val elapsed = SystemClock.uptimeMillis() - pressInTimestamp - if (elapsed >= animationDuration) { - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, animationDuration.toLong()) - // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play - } else if (elapsed * 2 >= minimumAnimationDuration) { + if (elapsed >= pressAndHoldAnimationDuration) { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldAnimationDuration.toLong()) + // elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play + } else if (elapsed * 2 >= tapAnimationDuration) { animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed) } else { - val remaining = minimumAnimationDuration - elapsed + val remaining = tapAnimationDuration - elapsed pendingPressOut?.let { handler.removeCallbacks(it) } animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining) val runnable = Runnable { pendingPressOut = null - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, minimumAnimationDuration.toLong()) + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong()) } pendingPressOut = runnable handler.postDelayed(runnable, remaining) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 752511f558..839877120a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -27,8 +27,8 @@ @property (nonatomic) BOOL userEnabled; @property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents; -@property (nonatomic, assign) NSInteger animationDuration; -@property (nonatomic, assign) NSInteger minimumAnimationDuration; +@property (nonatomic, assign) NSInteger pressAndHoldAnimationDuration; +@property (nonatomic, assign) NSInteger tapAnimationDuration; @property (nonatomic, assign) CGFloat activeOpacity; @property (nonatomic, assign) CGFloat defaultOpacity; @property (nonatomic, assign) CGFloat activeScale; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 6f94ce8e92..2e63600a2c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -48,7 +48,7 @@ @implementation RNGestureHandlerButton { dispatch_block_t _pendingPressOutBlock; } -@synthesize animationDuration = _animationDuration; +@synthesize pressAndHoldAnimationDuration = _pressAndHoldAnimationDuration; - (void)commonInit { @@ -56,8 +56,8 @@ - (void)commonInit _hitTestEdgeInsets = UIEdgeInsetsZero; _userEnabled = YES; _pointerEvents = RNGestureHandlerPointerEventsAuto; - _animationDuration = -1; - _minimumAnimationDuration = 100; + _pressAndHoldAnimationDuration = -1; + _tapAnimationDuration = 100; _activeOpacity = 1.0; _defaultOpacity = 1.0; _activeScale = 1.0; @@ -103,9 +103,9 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } -- (NSInteger)animationDuration +- (NSInteger)pressAndHoldAnimationDuration { - return _animationDuration < 0 ? _minimumAnimationDuration : _animationDuration; + return _pressAndHoldAnimationDuration < 0 ? _tapAnimationDuration : _pressAndHoldAnimationDuration; } - (void)setUnderlayColor:(RNGHColor *)underlayColor @@ -264,26 +264,26 @@ - (void)handleAnimatePressIn } _pressInTimestamp = CACurrentMediaTime(); RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:self.animationDuration]; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:self.pressAndHoldAnimationDuration]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:self.animationDuration]; + [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:self.pressAndHoldAnimationDuration]; } } - (void)handleAnimatePressOut { NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; - NSInteger animationDuration = self.animationDuration; + NSInteger pressAndHoldAnimationDuration = self.pressAndHoldAnimationDuration; - if (elapsed >= animationDuration) { - // Press-in animation fully finished, animate out in animationDuration + if (elapsed >= pressAndHoldAnimationDuration) { + // Press-in animation fully finished, animate out in pressAndHoldAnimationDuration RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:animationDuration]; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:pressAndHoldAnimationDuration]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:animationDuration]; + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:pressAndHoldAnimationDuration]; } // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play - } else if (elapsed * 2 >= _minimumAnimationDuration) { + } else if (elapsed * 2 >= _tapAnimationDuration) { // Past minimum but press-in animation still playing, animate out in elapsed time RNGHUIView *target = self.animationTarget ?: self; [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:elapsed]; @@ -292,7 +292,7 @@ - (void)handleAnimatePressOut } } else { // Before minimum duration, finish press-in in remaining time then animate out in minDuration - NSTimeInterval remaining = _minimumAnimationDuration - elapsed; + NSTimeInterval remaining = _tapAnimationDuration - elapsed; if (_pendingPressOutBlock) { dispatch_block_cancel(_pendingPressOutBlock); } @@ -311,10 +311,10 @@ - (void)handleAnimatePressOut [strongSelf animateTarget:target toOpacity:strongSelf->_defaultOpacity scale:strongSelf->_defaultScale - duration:strongSelf->_minimumAnimationDuration]; + duration:strongSelf->_tapAnimationDuration]; if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) { [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity - duration:strongSelf->_minimumAnimationDuration]; + duration:strongSelf->_tapAnimationDuration]; } } }); diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index fc69ffb9e1..effc841a03 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -237,8 +237,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & const auto &newProps = *std::static_pointer_cast(props); _buttonView.userEnabled = newProps.enabled; - _buttonView.animationDuration = newProps.animationDuration; - _buttonView.minimumAnimationDuration = newProps.minimumAnimationDuration; + _buttonView.pressAndHoldAnimationDuration = newProps.pressAndHoldAnimationDuration; + _buttonView.tapAnimationDuration = newProps.tapAnimationDuration; _buttonView.activeOpacity = newProps.activeOpacity; _buttonView.defaultOpacity = newProps.defaultOpacity; _buttonView.activeScale = newProps.activeScale; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 63e2a2b824..528d8a11b1 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -4,8 +4,8 @@ import { ColorValue, View, ViewProps } from 'react-native'; type ButtonProps = ViewProps & { ref?: React.Ref>; enabled?: boolean; - animationDuration?: number; - minimumAnimationDuration?: number; + pressAndHoldAnimationDuration?: number; + tapAnimationDuration?: number; activeOpacity?: number; activeScale?: number; activeUnderlayOpacity?: number; @@ -17,8 +17,8 @@ type ButtonProps = ViewProps & { export const ButtonComponent = ({ enabled = true, - animationDuration: animationDurationProp = -1, - minimumAnimationDuration = 100, + pressAndHoldAnimationDuration: pressAndHoldAnimationDurationProp = -1, + tapAnimationDuration = 100, activeOpacity = 1, activeScale = 1, activeUnderlayOpacity = 0, @@ -30,14 +30,15 @@ export const ButtonComponent = ({ children, ...rest }: ButtonProps) => { - const animationDuration = - animationDurationProp < 0 - ? minimumAnimationDuration - : animationDurationProp; + const pressAndHoldAnimationDuration = + pressAndHoldAnimationDurationProp < 0 + ? tapAnimationDuration + : pressAndHoldAnimationDurationProp; const [pressed, setPressed] = React.useState(false); - const [currentDuration, setCurrentDuration] = - React.useState(animationDuration); + const [currentDuration, setCurrentDuration] = React.useState( + pressAndHoldAnimationDuration + ); const pressInTimestamp = React.useRef(0); const pressOutTimer = React.useRef | null>( null @@ -58,34 +59,34 @@ export const ButtonComponent = ({ pressOutTimer.current = null; } pressInTimestamp.current = Date.now(); - setCurrentDuration(animationDuration); + setCurrentDuration(pressAndHoldAnimationDuration); setPressed(true); } - }, [enabled, animationDuration]); + }, [enabled, pressAndHoldAnimationDuration]); const pressOut = React.useCallback(() => { const elapsed = Date.now() - pressInTimestamp.current; - if (elapsed >= animationDuration) { - setCurrentDuration(animationDuration); + if (elapsed >= pressAndHoldAnimationDuration) { + setCurrentDuration(pressAndHoldAnimationDuration); setPressed(false); - // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play - } else if (elapsed * 2 >= minimumAnimationDuration) { + // elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play + } else if (elapsed * 2 >= tapAnimationDuration) { setCurrentDuration(elapsed); setPressed(false); } else { // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time - const remaining = minimumAnimationDuration - elapsed; + const remaining = tapAnimationDuration - elapsed; if (pressOutTimer.current != null) { clearTimeout(pressOutTimer.current); } pressOutTimer.current = setTimeout(() => { pressOutTimer.current = null; - setCurrentDuration(minimumAnimationDuration); + setCurrentDuration(tapAnimationDuration); setPressed(false); }, remaining); } - }, [animationDuration, minimumAnimationDuration]); + }, [pressAndHoldAnimationDuration, tapAnimationDuration]); const currentUnderlayOpacity = pressed ? activeUnderlayOpacity diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 2678baed93..bd65658e24 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -19,8 +19,8 @@ interface NativeProps extends ViewProps { 'box-none' | 'none' | 'box-only' | 'auto', 'auto' >; - animationDuration?: WithDefault; - minimumAnimationDuration?: WithDefault; + pressAndHoldAnimationDuration?: WithDefault; + tapAnimationDuration?: WithDefault; activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault; From 0522f2fd095452a10df324136e35140c3939a80e Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 11:10:12 +0200 Subject: [PATCH 5/9] Fix macos build --- .../apple/RNGestureHandlerButton.mm | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 2e63600a2c..b982f35256 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -220,12 +220,12 @@ - (void)animateTarget:(RNGHUIView *)target duration:(NSTimeInterval)durationMs { target.layer.transform = target.layer.presentationLayer.transform; - target.alpha = target.layer.presentationLayer.opacity; - [target.layer removeAllAnimations]; - NSTimeInterval duration = durationMs / 1000.0; #if !TARGET_OS_OSX + target.alpha = target.layer.presentationLayer.opacity; + [target.layer removeAllAnimations]; + [UIView animateWithDuration:duration delay:0 options:UIViewAnimationOptionCurveEaseInOut @@ -240,6 +240,9 @@ - (void)animateTarget:(RNGHUIView *)target completion:nil]; #else target.wantsLayer = YES; + target.alphaValue = target.layer.presentationLayer.opacity; + [target.layer removeAllAnimations]; + [NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) { context.allowsImplicitAnimation = YES; From f56465372a54fe1a1795fa3efe5b853bb250626c Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 11:12:58 +0200 Subject: [PATCH 6/9] Update example --- .../src/new_api/components/button_underlay/index.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/common-app/src/new_api/components/button_underlay/index.tsx b/apps/common-app/src/new_api/components/button_underlay/index.tsx index 43aac8ed2a..fa3661615d 100644 --- a/apps/common-app/src/new_api/components/button_underlay/index.tsx +++ b/apps/common-app/src/new_api/components/button_underlay/index.tsx @@ -10,8 +10,8 @@ const UNDERLAY_PROPS = { underlayColor: 'red', activeUnderlayOpacity: 0.2, activeScale: 0.9, - animationDuration: 200, - minimumAnimationDuration: 100, + pressAndHoldAnimationDuration: 200, + tapAnimationDuration: 100, rippleColor: 'transparent', } as const; From fdcc8cc3ba5df913ffbedb956f66fc3e29cc9ae0 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 11:46:40 +0200 Subject: [PATCH 7/9] Update jsdoc --- .../src/components/GestureHandlerButton.tsx | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 8da81a38d0..8b50a4bba1 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -59,16 +59,18 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { touchSoundDisabled?: boolean | undefined; /** - * Duration of the animation when the button is pressed in milliseconds. Defaults to 100ms. + * Duration of the press-in animation when the button is held down, in + * milliseconds. Defaults to `tapAnimationDuration` when not set (or set + * to -1). */ - animationDuration?: number | undefined; + pressAndHoldAnimationDuration?: number | undefined; /** - * Minimum duration (in milliseconds) that the press-in animation must run - * before the press-out animation is allowed to start. Useful for ensuring - * the pressed state is visible on quick taps. Defaults to 50ms. + * Minimum duration (in milliseconds) that the press animation must run + * before the press-out animation is allowed to start. Ensures the pressed + * state is visible on quick taps. Defaults to 100ms. */ - minimumAnimationDuration?: number | undefined; + tapAnimationDuration?: number | undefined; /** * Opacity applied to the button when it is pressed. From 2142a8c70392c65b669b64887f695a847ef97c2c Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 11:50:45 +0200 Subject: [PATCH 8/9] Always cancel scheduled press out --- .../react/RNGestureHandlerButtonViewManager.kt | 2 +- .../apple/RNGestureHandlerButton.mm | 8 +++++--- .../src/components/GestureHandlerButton.web.tsx | 6 +++--- 3 files changed, 9 insertions(+), 7 deletions(-) diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 6865097786..b26abdbba6 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -535,6 +535,7 @@ class RNGestureHandlerButtonViewManager : } private fun animatePressOut() { + pendingPressOut?.let { handler.removeCallbacks(it) } val elapsed = SystemClock.uptimeMillis() - pressInTimestamp if (elapsed >= pressAndHoldAnimationDuration) { @@ -544,7 +545,6 @@ class RNGestureHandlerButtonViewManager : animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed) } else { val remaining = tapAnimationDuration - elapsed - pendingPressOut?.let { handler.removeCallbacks(it) } animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining) val runnable = Runnable { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index b982f35256..7b0e5f075c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -275,6 +275,10 @@ - (void)handleAnimatePressIn - (void)handleAnimatePressOut { + if (_pendingPressOutBlock) { + dispatch_block_cancel(_pendingPressOutBlock); + } + NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; NSInteger pressAndHoldAnimationDuration = self.pressAndHoldAnimationDuration; @@ -296,9 +300,7 @@ - (void)handleAnimatePressOut } else { // Before minimum duration, finish press-in in remaining time then animate out in minDuration NSTimeInterval remaining = _tapAnimationDuration - elapsed; - if (_pendingPressOutBlock) { - dispatch_block_cancel(_pendingPressOutBlock); - } + RNGHUIView *target = self.animationTarget ?: self; [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:remaining]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 528d8a11b1..593d8e9bd0 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -65,6 +65,9 @@ export const ButtonComponent = ({ }, [enabled, pressAndHoldAnimationDuration]); const pressOut = React.useCallback(() => { + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + } const elapsed = Date.now() - pressInTimestamp.current; if (elapsed >= pressAndHoldAnimationDuration) { @@ -77,9 +80,6 @@ export const ButtonComponent = ({ } else { // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time const remaining = tapAnimationDuration - elapsed; - if (pressOutTimer.current != null) { - clearTimeout(pressOutTimer.current); - } pressOutTimer.current = setTimeout(() => { pressOutTimer.current = null; setCurrentDuration(tapAnimationDuration); From eafff80961c1056512c41d12fe15e8376b1e2041 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 1 Apr 2026 11:54:08 +0200 Subject: [PATCH 9/9] Use performance.now --- .../src/components/GestureHandlerButton.web.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 593d8e9bd0..b857459274 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -58,7 +58,7 @@ export const ButtonComponent = ({ clearTimeout(pressOutTimer.current); pressOutTimer.current = null; } - pressInTimestamp.current = Date.now(); + pressInTimestamp.current = performance.now(); setCurrentDuration(pressAndHoldAnimationDuration); setPressed(true); } @@ -68,7 +68,7 @@ export const ButtonComponent = ({ if (pressOutTimer.current != null) { clearTimeout(pressOutTimer.current); } - const elapsed = Date.now() - pressInTimestamp.current; + const elapsed = performance.now() - pressInTimestamp.current; if (elapsed >= pressAndHoldAnimationDuration) { setCurrentDuration(pressAndHoldAnimationDuration);