Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,10 @@ import {

const UNDERLAY_PROPS = {
underlayColor: 'red',
activeUnderlayOpacity: 0.5,
animationDuration: 200,
activeUnderlayOpacity: 0.2,
activeScale: 0.9,
pressAndHoldAnimationDuration: 200,
tapAnimationDuration: 100,
rippleColor: 'transparent',
} as const;

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -266,9 +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 = "tapAnimationDuration")
override fun setTapAnimationDuration(view: ButtonViewGroup, tapAnimationDuration: Int) {
view.tapAnimationDuration = tapAnimationDuration
}

@ReactProp(name = "defaultOpacity")
Expand Down Expand Up @@ -346,7 +352,9 @@ class RNGestureHandlerButtonViewManager :
var useBorderlessDrawable = false

var exclusive = true
var animationDuration: Int = 100
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
Expand All @@ -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

Comment on lines 378 to 382
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

A pending Runnable is now stored in pendingPressOut, but there’s no lifecycle cleanup (e.g., onDetachedFromWindow) to remove it and cancel currentAnimator. If the view is unmounted while a delayed press-out is scheduled (or if tapAnimationDuration is set high), the runnable can keep the view referenced and run animations after detach. Consider overriding onDetachedFromWindow to removeCallbacks for pendingPressOut, null it out, and cancel currentAnimator.

Copilot uses AI. Check for mistakes.
// When non-null the ripple is drawn in dispatchDraw (above background, below children).
// When null the ripple lives on the foreground drawable instead.
Expand Down Expand Up @@ -487,7 +497,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
Expand All @@ -509,18 +519,41 @@ class RNGestureHandlerButtonViewManager :
}
currentAnimator = AnimatorSet().apply {
playTogether(animators)
duration = animationDuration.toLong()
duration = durationMs
interpolator = LinearOutSlowInInterpolator()
start()
}
}

private fun animatePressIn() {
animateTo(activeOpacity, activeScale, activeUnderlayOpacity)
pendingPressOut?.let {
handler.removeCallbacks(it)
pendingPressOut = null
}
pressInTimestamp = SystemClock.uptimeMillis()
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, pressAndHoldAnimationDuration.toLong())
}

private fun animatePressOut() {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity)
pendingPressOut?.let { handler.removeCallbacks(it) }
val elapsed = SystemClock.uptimeMillis() - pressInTimestamp

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 = tapAnimationDuration - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)

val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong())
Comment on lines +540 to +552
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

elapsed is a Long, while pressAndHoldAnimationDuration/tapAnimationDuration are Int. Kotlin doesn’t allow ordering comparisons or arithmetic between Long and Int, so these comparisons will fail to compile. Convert the durations to Long once (e.g., val pressAndHoldMs = pressAndHoldAnimationDuration.toLong(), val tapMs = tapAnimationDuration.toLong()) and use those consistently in comparisons and animator durations.

Suggested change
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 = tapAnimationDuration - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)
val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong())
val pressAndHoldMs = pressAndHoldAnimationDuration.toLong()
val tapMs = tapAnimationDuration.toLong()
if (elapsed >= pressAndHoldMs) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldMs)
// elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play
} else if (elapsed * 2 >= tapMs) {
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed)
} else {
val remaining = tapMs - elapsed
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)
val runnable = Runnable {
pendingPressOut = null
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapMs)

Copilot uses AI. Check for mistakes.
}
pendingPressOut = runnable
handler.postDelayed(runnable, remaining)
Comment on lines +546 to +555
Copy link

Copilot AI Apr 1, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

val remaining = tapAnimationDuration - elapsed mixes Int and Long (won’t compile), and handler.postDelayed(runnable, remaining) expects a non-negative Long delay. After converting tapAnimationDuration to Long, ensure remaining is computed as Long and clamped to >= 0 before calling postDelayed.

Copilot uses AI. Check for mistakes.
}
}

private fun createUnderlayDrawable(): PaintDrawable {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@
@property (nonatomic) BOOL userEnabled;
@property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents;

@property (nonatomic, assign) NSInteger animationDuration;
@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;
Expand Down
104 changes: 91 additions & 13 deletions packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm
Original file line number Diff line number Diff line change
Expand Up @@ -44,22 +44,29 @@ @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;
dispatch_block_t _pendingPressOutBlock;
}

@synthesize pressAndHoldAnimationDuration = _pressAndHoldAnimationDuration;

- (void)commonInit
{
_isTouchInsideBounds = NO;
_hitTestEdgeInsets = UIEdgeInsetsZero;
_userEnabled = YES;
_pointerEvents = RNGestureHandlerPointerEventsAuto;
_animationDuration = 100;
_pressAndHoldAnimationDuration = -1;
_tapAnimationDuration = 100;
_activeOpacity = 1.0;
_defaultOpacity = 1.0;
_activeScale = 1.0;
_defaultScale = 1.0;
_activeUnderlayOpacity = 0.0;
_defaultUnderlayOpacity = 0.0;
_underlayColor = nil;
_pressInTimestamp = 0;
_pendingPressOutBlock = nil;
#if TARGET_OS_OSX
self.wantsLayer = YES; // Crucial for macOS layer-backing
#endif
Expand Down Expand Up @@ -96,6 +103,11 @@ - (instancetype)initWithFrame:(CGRect)frame
return self;
}

- (NSInteger)pressAndHoldAnimationDuration
{
return _pressAndHoldAnimationDuration < 0 ? _tapAnimationDuration : _pressAndHoldAnimationDuration;
}

- (void)setUnderlayColor:(RNGHColor *)underlayColor
{
_underlayColor = underlayColor;
Expand Down Expand Up @@ -149,12 +161,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"];
Expand Down Expand Up @@ -199,14 +214,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;
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 | UIViewAnimationOptionBeginFromCurrentState
options:UIViewAnimationOptionCurveEaseInOut
animations:^{
if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) {
target.alpha = opacity;
Expand All @@ -218,6 +240,9 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF
completion:nil];
#else
target.wantsLayer = YES;
target.alphaValue = target.layer.presentationLayer.opacity;
[target.layer removeAllAnimations];

[NSAnimationContext
runAnimationGroup:^(NSAnimationContext *context) {
context.allowsImplicitAnimation = YES;
Expand All @@ -236,19 +261,72 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF

- (void)handleAnimatePressIn
{
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:self.pressAndHoldAnimationDuration];
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
[self animateUnderlayToOpacity:_activeUnderlayOpacity];
[self animateUnderlayToOpacity:_activeUnderlayOpacity duration:self.pressAndHoldAnimationDuration];
}
}

- (void)handleAnimatePressOut
{
RNGHUIView *target = self.animationTarget ?: self;
[self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale];
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
[self animateUnderlayToOpacity:_defaultUnderlayOpacity];
if (_pendingPressOutBlock) {
dispatch_block_cancel(_pendingPressOutBlock);
}

NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0;
NSInteger pressAndHoldAnimationDuration = self.pressAndHoldAnimationDuration;

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:pressAndHoldAnimationDuration];
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
[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 >= _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];
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
[self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:elapsed];
}
} else {
// Before minimum duration, finish press-in in remaining time then animate out in minDuration
NSTimeInterval remaining = _tapAnimationDuration - elapsed;

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;
_pendingPressOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{
__strong auto strongSelf = weakSelf;
if (strongSelf) {
strongSelf->_pendingPressOutBlock = nil;
RNGHUIView *target = strongSelf.animationTarget ?: strongSelf;
[strongSelf animateTarget:target
toOpacity:strongSelf->_defaultOpacity
scale:strongSelf->_defaultScale
duration:strongSelf->_tapAnimationDuration];
if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) {
[strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity
duration:strongSelf->_tapAnimationDuration];
}
}
});
dispatch_after(
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)),
dispatch_get_main_queue(),
_pendingPressOutBlock);
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -237,7 +237,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
const auto &newProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(props);

_buttonView.userEnabled = newProps.enabled;
_buttonView.animationDuration = newProps.animationDuration;
_buttonView.pressAndHoldAnimationDuration = newProps.pressAndHoldAnimationDuration;
_buttonView.tapAnimationDuration = newProps.tapAnimationDuration;
_buttonView.activeOpacity = newProps.activeOpacity;
_buttonView.defaultOpacity = newProps.defaultOpacity;
_buttonView.activeScale = newProps.activeScale;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -59,9 +59,18 @@ export interface ButtonProps extends ViewProps, AccessibilityProps {
touchSoundDisabled?: boolean | undefined;

/**
* Duration of the animation when the button is pressed.
* 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 animation must run
* before the press-out animation is allowed to start. Ensures the pressed
* state is visible on quick taps. Defaults to 100ms.
*/
tapAnimationDuration?: number | undefined;

/**
* Opacity applied to the button when it is pressed.
Expand Down
Loading
Loading