Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
48d5421
Revert contents-based button
j-piasecki Mar 26, 2026
feeed9a
Handle missing styles in button
j-piasecki Mar 27, 2026
77edca6
Don't use 0 as "not set"
j-piasecki Mar 30, 2026
e720518
Add catch-all to overflow setter
j-piasecki Mar 30, 2026
dc65f05
Clamp values
j-piasecki Mar 30, 2026
b8f3c25
Update prop priority and defaults
j-piasecki Mar 30, 2026
dbbd398
Clip Android to paddingbox
j-piasecki Mar 30, 2026
533ad6c
Clip iOS to paddingbox
j-piasecki Mar 30, 2026
b8f3f41
Clip underlay on macos
j-piasecki Mar 30, 2026
ad0881b
Fix coordinates on macos
j-piasecki Mar 30, 2026
c433764
Flip coordinate space
j-piasecki Mar 30, 2026
53cf70f
Add example
j-piasecki Mar 30, 2026
5fc983a
Remove redundant clipping
j-piasecki Mar 30, 2026
92852e3
Disable ripple
j-piasecki Mar 30, 2026
c197d85
Format
j-piasecki Mar 30, 2026
8bceb77
Add minimumAnimationDuration prop
j-piasecki Mar 30, 2026
b40c70d
Merge branch 'main' into @jpiasecki/refactor-button
j-piasecki Mar 31, 2026
16805c8
Merge branch '@jpiasecki/refactor-button' into @jpiasecki/add-minimum…
j-piasecki Mar 31, 2026
c571692
Update minimumAnimationDuration behavior
j-piasecki Mar 31, 2026
af7ea63
Fallback to animationDuration to minimumAnimationDuration
j-piasecki Apr 1, 2026
2d09358
Rename props
j-piasecki Apr 1, 2026
0522f2f
Fix macos build
j-piasecki Apr 1, 2026
f564653
Update example
j-piasecki Apr 1, 2026
fdcc8cc
Update jsdoc
j-piasecki Apr 1, 2026
2142a8c
Always cancel scheduled press out
j-piasecki Apr 1, 2026
eafff80
Use performance.now
j-piasecki Apr 1, 2026
f2321f5
Merge branch 'main' into @jpiasecki/refactor-button
j-piasecki Apr 2, 2026
ef19e02
Fix borderless ripple
j-piasecki Apr 2, 2026
80bea36
Apply the resting state correctly
j-piasecki Apr 2, 2026
8cd97a9
Merge branch '@jpiasecki/refactor-button' into @jpiasecki/add-minimum…
j-piasecki Apr 2, 2026
dd16d57
Use longs for timers
j-piasecki Apr 2, 2026
f4ba9a5
Remove pending animations on unmount
j-piasecki Apr 2, 2026
998e28a
Merge branch 'main' into @jpiasecki/add-minimum-animation-duration
j-piasecki Apr 2, 2026
b5b538d
Fix merge
j-piasecki Apr 2, 2026
6b22041
Restore braces
j-piasecki Apr 2, 2026
77a19b3
Fix macos
j-piasecki Apr 2, 2026
db0ab3e
Keep tapAnimationDuration positive
j-piasecki Apr 2, 2026
d7767e5
Check if presentation layer exists
j-piasecki Apr 2, 2026
2f785a0
Keep tapAnimationDuration positive on iOS
j-piasecki Apr 2, 2026
8726b2c
Keep tapAnimationDuration positive on web
j-piasecki Apr 2, 2026
dccd561
Extract common method
j-piasecki Apr 3, 2026
453b5cb
Update comment
j-piasecki Apr 3, 2026
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,
Comment thread
j-piasecki marked this conversation as resolved.
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 = if (tapAnimationDuration > 0) tapAnimationDuration else 0
}

@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
Comment thread
j-piasecki marked this conversation as resolved.
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 thread
j-piasecki marked this conversation as resolved.
// 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,43 @@ 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 pressAndHoldMs = pressAndHoldAnimationDuration.toLong()
val tapMs = tapAnimationDuration.toLong()
val elapsed = SystemClock.uptimeMillis() - pressInTimestamp

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)
}
pendingPressOut = runnable
handler.postDelayed(runnable, remaining)
Comment thread
j-piasecki marked this conversation as resolved.
}
Comment thread
j-piasecki marked this conversation as resolved.
Comment thread
j-piasecki marked this conversation as resolved.
}

private fun createUnderlayDrawable(): PaintDrawable {
Expand Down Expand Up @@ -630,6 +665,14 @@ class RNGestureHandlerButtonViewManager :
return drawable
}

override fun onDetachedFromWindow() {
super.onDetachedFromWindow()
pendingPressOut?.let { handler.removeCallbacks(it) }
pendingPressOut = null
currentAnimator?.cancel()
currentAnimator = null
}

override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
super.onSizeChanged(w, h, oldw, oldh)
needBackgroundUpdate = true
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
135 changes: 122 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,40 @@ - (instancetype)initWithFrame:(CGRect)frame
return self;
}

- (void)cancelPendingPressOutAnimation
{
if (_pendingPressOutBlock) {
dispatch_block_cancel(_pendingPressOutBlock);
_pendingPressOutBlock = nil;
}
RNGHUIView *target = self.animationTarget ?: self;
[target.layer removeAllAnimations];
[_underlayLayer removeAllAnimations];
}

#if TARGET_OS_OSX
- (void)viewWillMoveToWindow:(RNGHWindow *)newWindow
{
[super viewWillMoveToWindow:newWindow];
if (newWindow == nil) {
[self cancelPendingPressOutAnimation];
}
}
#else
- (void)willMoveToWindow:(RNGHWindow *)newWindow
{
[super willMoveToWindow:newWindow];
if (newWindow == nil) {
[self cancelPendingPressOutAnimation];
}
}
Comment thread
j-piasecki marked this conversation as resolved.
#endif

- (NSInteger)pressAndHoldAnimationDuration
{
return _pressAndHoldAnimationDuration < 0 ? _tapAnimationDuration : _pressAndHoldAnimationDuration;
Comment thread
j-piasecki marked this conversation as resolved.
}

- (void)setUnderlayColor:(RNGHColor *)underlayColor
{
_underlayColor = underlayColor;
Expand Down Expand Up @@ -149,12 +190,16 @@ - (BOOL)shouldHandleTouch:(RNGHUIView *)view
#endif
}

- (void)animateUnderlayToOpacity:(float)toOpacity
- (void)animateUnderlayToOpacity:(float)toOpacity duration:(NSTimeInterval)durationMs
{
_underlayLayer.opacity =
_underlayLayer.presentationLayer ? [_underlayLayer.presentationLayer opacity] : _underlayLayer.opacity;
[_underlayLayer removeAllAnimations];

CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"opacity"];
anim.fromValue = @([_underlayLayer.presentationLayer opacity]);
anim.fromValue = @(_underlayLayer.opacity);
Comment thread
j-piasecki marked this conversation as resolved.
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 +244,22 @@ - (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 ? target.layer.presentationLayer.transform : target.layer.transform;
NSTimeInterval duration = durationMs / 1000.0;

#if !TARGET_OS_OSX
target.alpha = target.layer.presentationLayer ? target.layer.presentationLayer.opacity : target.alpha;
[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 +271,9 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF
completion:nil];
#else
target.wantsLayer = YES;
target.alphaValue = target.layer.presentationLayer ? target.layer.presentationLayer.opacity : target.alphaValue;
[target.layer removeAllAnimations];

[NSAnimationContext
runAnimationGroup:^(NSAnimationContext *context) {
context.allowsImplicitAnimation = YES;
Expand All @@ -236,19 +292,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) {
Comment thread
j-piasecki marked this conversation as resolved.
[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 @@ -241,7 +241,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 > 0 ? newProps.tapAnimationDuration : 0;
_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 any negative value).
*/
animationDuration?: number | undefined;
pressAndHoldAnimationDuration?: number | undefined;
Comment thread
j-piasecki marked this conversation as resolved.

/**
* 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