From 3872ac12667a6d158a99b6c9e6dbec826c2237e3 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 09:54:39 +0100 Subject: [PATCH 01/25] Remove legacy view manager --- .../apple/RNGestureHandlerButtonManager.h | 5 -- .../apple/RNGestureHandlerButtonManager.mm | 60 ------------------- 2 files changed, 65 deletions(-) delete mode 100644 packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.h delete mode 100644 packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.mm diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.h deleted file mode 100644 index 3105a9c3dd..0000000000 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.h +++ /dev/null @@ -1,5 +0,0 @@ -#import - -@interface RNGestureHandlerButtonManager : RCTViewManager - -@end diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.mm deleted file mode 100644 index a7cc615816..0000000000 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonManager.mm +++ /dev/null @@ -1,60 +0,0 @@ -#import "RNGestureHandlerButtonManager.h" -#import "RNGestureHandlerButton.h" - -static RNGestureHandlerPointerEvents RCTPointerEventsToEnum(RCTPointerEvents pointerEvents) -{ - switch (pointerEvents) { - case RCTPointerEventsNone: - return RNGestureHandlerPointerEventsNone; - case RCTPointerEventsBoxNone: - return RNGestureHandlerPointerEventsBoxNone; - case RCTPointerEventsBoxOnly: - return RNGestureHandlerPointerEventsBoxOnly; - default: - return RNGestureHandlerPointerEventsAuto; - } -} - -@implementation RNGestureHandlerButtonManager - -RCT_EXPORT_MODULE(RNGestureHandlerButton) - -RCT_CUSTOM_VIEW_PROPERTY(enabled, BOOL, RNGestureHandlerButton) -{ - view.userEnabled = json == nil ? YES : [RCTConvert BOOL:json]; -} - -#if !TARGET_OS_TV && !TARGET_OS_OSX -RCT_CUSTOM_VIEW_PROPERTY(exclusive, BOOL, RNGestureHandlerButton) -{ - [view setExclusiveTouch:json == nil ? YES : [RCTConvert BOOL:json]]; -} -#endif - -RCT_CUSTOM_VIEW_PROPERTY(hitSlop, UIEdgeInsets, RNGestureHandlerButton) -{ - if (json) { - UIEdgeInsets hitSlopInsets = [RCTConvert UIEdgeInsets:json]; - view.hitTestEdgeInsets = - UIEdgeInsetsMake(-hitSlopInsets.top, -hitSlopInsets.left, -hitSlopInsets.bottom, -hitSlopInsets.right); - } else { - view.hitTestEdgeInsets = defaultView.hitTestEdgeInsets; - } -} - -RCT_CUSTOM_VIEW_PROPERTY(pointerEvents, RCTPointerEvents, RNGestureHandlerButton) -{ - if (json) { - RCTPointerEvents pointerEvents = [RCTConvert RCTPointerEvents:json]; - view.pointerEvents = RCTPointerEventsToEnum(pointerEvents); - } else { - view.pointerEvents = RNGestureHandlerPointerEventsAuto; - } -} - -- (RNGHUIView *)view -{ - return (RNGHUIView *)[RNGestureHandlerButton new]; -} - -@end From cdd368132ff633447dc95910ef695696fce7c8ae Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 10:02:51 +0100 Subject: [PATCH 02/25] Update native button spec --- .../src/specs/RNGestureHandlerButtonNativeComponent.ts | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 4fedd3160e..d5cdfa8694 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -22,6 +22,14 @@ interface NativeProps extends ViewProps { 'box-none' | 'none' | 'box-only' | 'auto', 'auto' >; + animationDuration?: WithDefault; + activeOpacity?: WithDefault; + activeScale?: WithDefault; + activeUnderlayOpacity?: WithDefault; + startOpacity?: WithDefault; + startScale?: WithDefault; + startUnderlayOpacity?: WithDefault; + underlayColor?: ColorValue; } export default codegenNativeComponent('RNGestureHandlerButton'); From fb3693af62cce97ea5b714299aa3f88528c0cc0d Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 11:04:40 +0100 Subject: [PATCH 03/25] Implement ios --- .../apple/RNGHUIKit.h | 2 + .../apple/RNGestureHandlerButton.h | 21 ++ .../apple/RNGestureHandlerButton.mm | 187 +++++++++++++++++- .../RNGestureHandlerButtonComponentView.mm | 25 +++ 4 files changed, 228 insertions(+), 7 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGHUIKit.h b/packages/react-native-gesture-handler/apple/RNGHUIKit.h index c5f8b4859f..aa3bd3e256 100644 --- a/packages/react-native-gesture-handler/apple/RNGHUIKit.h +++ b/packages/react-native-gesture-handler/apple/RNGHUIKit.h @@ -7,6 +7,7 @@ typedef UIWindow RNGHWindow; typedef UIScrollView RNGHScrollView; typedef UITouch RNGHUITouch; typedef UIScrollView RNGHUIScrollView; +typedef UIColor RNGHColor; #define RNGHGestureRecognizerStateFailed UIGestureRecognizerStateFailed; #define RNGHGestureRecognizerStatePossible UIGestureRecognizerStatePossible; @@ -23,6 +24,7 @@ typedef NSWindow RNGHWindow; typedef NSScrollView RNGHScrollView; typedef RCTUITouch RNGHUITouch; typedef NSScrollView RNGHUIScrollView; +typedef NSColor RNGHColor; #define RNGHGestureRecognizerStateFailed NSGestureRecognizerStateFailed; #define RNGHGestureRecognizerStatePossible NSGestureRecognizerStatePossible; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index ce0015850f..7fdc71e57e 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -28,6 +28,27 @@ @property (nonatomic) BOOL userEnabled; @property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents; +@property (nonatomic, assign) NSInteger animationDuration; +@property (nonatomic, assign) CGFloat activeOpacity; +@property (nonatomic, assign) CGFloat startOpacity; +@property (nonatomic, assign) CGFloat activeScale; +@property (nonatomic, assign) CGFloat startScale; +@property (nonatomic, assign) CGFloat startUnderlayOpacity; +@property (nonatomic, assign) CGFloat activeUnderlayOpacity; +@property (nonatomic, strong, nullable) RNGHColor *underlayColor; + +/** + * The view that press animations are applied to. Defaults to self; set by the + * Fabric component view to its own instance so animations affect the full wrapper. + */ +@property (nonatomic, weak, nullable) RNGHUIView *animationTarget; + +/** + * Immediately applies the start* values to the animation target and underlay layer. + * Call after props are updated to ensure the idle visual state is correct. + */ +- (void)applyStartAnimationState; + #if TARGET_OS_OSX - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index; - (void)unmountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 57a0412147..bca129eb7f 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -11,6 +11,7 @@ #if !TARGET_OS_OSX #import #else +#import #import #endif @@ -38,22 +39,91 @@ * `TapGestureHandler` instead of a button which gives much better flexibility as far as * controlling the touch flow. */ -@implementation RNGestureHandlerButton +@implementation RNGestureHandlerButton { + CALayer *_underlayLayer; +} + +- (void)commonInit +{ + _hitTestEdgeInsets = UIEdgeInsetsZero; + _userEnabled = YES; + _pointerEvents = RNGestureHandlerPointerEventsAuto; + _animationDuration = 100; + _activeOpacity = 1.0; + _startOpacity = 1.0; + _activeScale = 1.0; + _startScale = 1.0; + _activeUnderlayOpacity = 0.0; + _startUnderlayOpacity = 0.0; + +#if !TARGET_OS_OSX + _underlayColor = [UIColor blackColor]; +#else + _underlayColor = [NSColor blackColor]; + self.wantsLayer = YES; // Crucial for macOS layer-backing +#endif + + _underlayLayer = [CALayer new]; + _underlayLayer.opacity = 0; + +#if !TARGET_OS_OSX + _underlayLayer.backgroundColor = [UIColor blackColor].CGColor; +#else + _underlayLayer.backgroundColor = [NSColor blackColor].CGColor; +#endif + + [self.layer insertSublayer:_underlayLayer atIndex:0]; + +#if !TARGET_OS_TV && !TARGET_OS_OSX + [self setExclusiveTouch:YES]; + [self addTarget:self + action:@selector(handleAnimatePressIn) + forControlEvents:UIControlEventTouchDown | UIControlEventTouchDragEnter]; + [self addTarget:self + action:@selector(handleAnimatePressOut) + forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit | + UIControlEventTouchCancel]; +#endif +} - (instancetype)init { self = [super init]; if (self) { - _hitTestEdgeInsets = UIEdgeInsetsZero; - _userEnabled = YES; - _pointerEvents = RNGestureHandlerPointerEventsAuto; -#if !TARGET_OS_TV && !TARGET_OS_OSX - [self setExclusiveTouch:YES]; -#endif + [self commonInit]; } return self; } +- (instancetype)initWithFrame:(CGRect)frame +{ + self = [super initWithFrame:frame]; + if (self) { + [self commonInit]; + } + return self; +} + +- (void)setUnderlayColor:(RNGHColor *)underlayColor +{ + _underlayColor = underlayColor; + _underlayLayer.backgroundColor = underlayColor.CGColor; +} + +#if !TARGET_OS_OSX +- (void)layoutSubviews +{ + [super layoutSubviews]; + _underlayLayer.frame = self.bounds; +} +#else +- (void)layout +{ + [super layout]; + _underlayLayer.frame = self.bounds; +} +#endif + - (BOOL)shouldHandleTouch:(RNGHUIView *)view { if ([view isKindOfClass:[RNGestureHandlerButton class]]) { @@ -78,6 +148,108 @@ - (BOOL)shouldHandleTouch:(RNGHUIView *)view #endif } +- (void)animateUnderlayToOpacity:(float)toOpacity +{ + CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"opacity"]; + anim.fromValue = @([_underlayLayer.presentationLayer opacity]); + anim.toValue = @(toOpacity); + anim.duration = _animationDuration / 1000.0; + anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionDefault]; + _underlayLayer.opacity = toOpacity; + [_underlayLayer addAnimation:anim forKey:@"opacity"]; +} + +#if TARGET_OS_OSX +static CATransform3D RNGHCenterScaleTransform(NSRect bounds, CGFloat scale) +{ + CGFloat midX = NSMidX(bounds); + CGFloat midY = NSMidY(bounds); + + // translate to center, scale, and translate back + CATransform3D transform = CATransform3DIdentity; + transform = CATransform3DTranslate(transform, midX, midY, 0); + transform = CATransform3DScale(transform, scale, scale, 1.0); + transform = CATransform3DTranslate(transform, -midX, -midY, 0); + + return transform; +} +#endif + +- (void)applyStartAnimationState +{ + RNGHUIView *target = self.animationTarget ?: self; + _underlayLayer.opacity = _startUnderlayOpacity; + +#if !TARGET_OS_OSX + target.alpha = _startOpacity; + target.transform = CGAffineTransformMakeScale(_startScale, _startScale); +#else + target.wantsLayer = YES; + target.alphaValue = _startOpacity; + // Use the centered transform helper + target.layer.transform = RNGHCenterScaleTransform(target.bounds, _startScale); +#endif +} + +- (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGFloat)scale +{ + NSTimeInterval duration = _animationDuration / 1000.0; + +#if !TARGET_OS_OSX + [UIView animateWithDuration:duration + delay:0 + options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState + animations:^{ + target.alpha = opacity; + target.transform = CGAffineTransformMakeScale(scale, scale); + } + completion:nil]; +#else + target.wantsLayer = YES; + [NSAnimationContext + runAnimationGroup:^(NSAnimationContext *context) { + context.allowsImplicitAnimation = YES; + context.duration = duration; + context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + target.animator.alphaValue = opacity; + target.layer.transform = RNGHCenterScaleTransform(target.bounds, scale); + } + completionHandler:nil]; +#endif +} + +- (void)handleAnimatePressIn +{ + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale]; + if (_activeUnderlayOpacity != _startUnderlayOpacity) { + [self animateUnderlayToOpacity:_activeUnderlayOpacity]; + } +} + +- (void)handleAnimatePressOut +{ + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_startOpacity scale:_startScale]; + if (_activeUnderlayOpacity != _startUnderlayOpacity) { + [self animateUnderlayToOpacity:_startUnderlayOpacity]; + } +} + +#if TARGET_OS_OSX +- (void)mouseDown:(NSEvent *)event +{ + [super mouseDown:event]; + [self handleAnimatePressIn]; +} + +- (void)mouseUp:(NSEvent *)event +{ + [super mouseUp:event]; + [self handleAnimatePressOut]; +} +#endif + #if !TARGET_OS_OSX - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { @@ -141,6 +313,7 @@ - (void)displayLayer:(CALayer *)layer const CGFloat scaleFactor = RCTZeroIfNaN(MIN(1, size.width / (2 * radius))); const CGFloat currentBorderRadius = radius * scaleFactor; layer.cornerRadius = currentBorderRadius; + _underlayLayer.cornerRadius = currentBorderRadius; } - (NSString *)accessibilityLabel diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index c52051bfaa..837a93326d 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -63,6 +63,20 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +#if !TARGET_OS_OSX +- (void)willMoveToSuperview:(RNGHUIView *)newSuperview +{ + [super willMoveToSuperview:newSuperview]; + _buttonView.animationTarget = newSuperview; +} +#else +- (void)viewWillMoveToSuperview:(RNGHUIView *)newSuperview +{ + [super viewWillMoveToSuperview:newSuperview]; + _buttonView.animationTarget = newSuperview; +} +#endif + - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index { [_buttonView mountChildComponentView:childComponentView index:index]; @@ -214,6 +228,16 @@ - (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.activeOpacity = newProps.activeOpacity; + _buttonView.startOpacity = newProps.startOpacity; + _buttonView.activeScale = newProps.activeScale; + _buttonView.startScale = newProps.startScale; + _buttonView.startUnderlayOpacity = newProps.startUnderlayOpacity; + _buttonView.activeUnderlayOpacity = newProps.activeUnderlayOpacity; + if (newProps.underlayColor) { + _buttonView.underlayColor = RCTUIColorFromSharedColor(newProps.underlayColor); + } #if !TARGET_OS_TV && !TARGET_OS_OSX _buttonView.exclusiveTouch = newProps.exclusive; [self setAccessibilityProps:props oldProps:oldProps]; @@ -235,6 +259,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } } + [_buttonView applyStartAnimationState]; [super updateProps:props oldProps:oldProps]; } From 80188240108af6a12260aefe66f513f18354a2c1 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 11:32:14 +0100 Subject: [PATCH 04/25] Implement android --- .../RNGestureHandlerButtonViewManager.kt | 135 +++++++++++++++++- 1 file changed, 128 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 dc87058867..7669da9152 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 @@ -1,5 +1,8 @@ package com.swmansion.gesturehandler.react +import android.animation.Animator +import android.animation.AnimatorSet +import android.animation.ObjectAnimator import android.annotation.SuppressLint import android.annotation.TargetApi import android.content.Context @@ -19,10 +22,10 @@ import android.util.TypedValue import android.view.KeyEvent import android.view.MotionEvent import android.view.View -import android.view.View.OnClickListener import android.view.ViewGroup import android.view.accessibility.AccessibilityNodeInfo import androidx.core.view.children +import androidx.interpolator.view.animation.LinearOutSlowInInterpolator import com.facebook.react.R import com.facebook.react.module.annotations.ReactModule import com.facebook.react.uimanager.PixelUtil @@ -133,6 +136,46 @@ class RNGestureHandlerButtonViewManager : view.isSoundEffectsEnabled = !touchSoundDisabled } + @ReactProp(name = "animationDuration") + override fun setAnimationDuration(view: ButtonViewGroup, animationDuration: Int) { + view.animationDuration = animationDuration + } + + @ReactProp(name = "startOpacity") + override fun setStartOpacity(view: ButtonViewGroup, startOpacity: Float) { + view.startOpacity = startOpacity + } + + @ReactProp(name = "activeOpacity") + override fun setActiveOpacity(view: ButtonViewGroup, targetOpacity: Float) { + view.activeOpacity = targetOpacity + } + + @ReactProp(name = "startScale") + override fun setStartScale(view: ButtonViewGroup, startScale: Float) { + view.startScale = startScale + } + + @ReactProp(name = "activeScale") + override fun setActiveScale(view: ButtonViewGroup, activeScale: Float) { + view.activeScale = activeScale + } + + @ReactProp(name = "underlayColor") + override fun setUnderlayColor(view: ButtonViewGroup, underlayColor: Int?) { + view.underlayColor = underlayColor + } + + @ReactProp(name = "startUnderlayOpacity") + override fun setStartUnderlayOpacity(view: ButtonViewGroup, startUnderlayOpacity: Float) { + view.startUnderlayOpacity = startUnderlayOpacity + } + + @ReactProp(name = "activeUnderlayOpacity") + override fun setActiveUnderlayOpacity(view: ButtonViewGroup, activeUnderlayOpacity: Float) { + view.activeUnderlayOpacity = activeUnderlayOpacity + } + @ReactProp(name = ViewProps.POINTER_EVENTS) override fun setPointerEvents(view: ButtonViewGroup, pointerEvents: String?) { view.pointerEvents = when (pointerEvents) { @@ -212,6 +255,17 @@ class RNGestureHandlerButtonViewManager : borderBottomRightRadius != 0f var exclusive = true + var animationDuration: Int = 100 + var activeOpacity: Float = 1.0f + var startOpacity: Float = 1.0f + var activeScale: Float = 1.0f + var startScale: Float = 1.0f + var underlayColor: Int? = null + set(color) = withBackgroundUpdate { + field = color + } + var activeUnderlayOpacity: Float = 0f + var startUnderlayOpacity: Float = 0f override var pointerEvents: PointerEvents = PointerEvents.AUTO @@ -220,6 +274,8 @@ class RNGestureHandlerButtonViewManager : private var lastEventTime = -1L private var lastAction = -1 private var receivedKeyEvent = false + private var currentAnimator: AnimatorSet? = null + private var underlayDrawable: PaintDrawable? = null var isTouched = false @@ -331,7 +387,62 @@ class RNGestureHandlerButtonViewManager : return false } - private fun updateBackgroundColor(backgroundColor: Int, borderDrawable: Drawable, selectable: Drawable?) { + private fun applyStartAnimationState() { + (parent as? ViewGroup)?.let { + it.alpha = startOpacity + it.scaleX = startScale + it.scaleY = startScale + } + underlayDrawable?.alpha = (startUnderlayOpacity * 255).toInt() + } + + private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) { + val hasTransform = activeOpacity != startOpacity || activeScale != startScale + val hasUnderlay = activeUnderlayOpacity != startUnderlayOpacity && underlayDrawable != null + if (!hasTransform && !hasUnderlay) return + + currentAnimator?.cancel() + val animators = ArrayList() + if (hasTransform) { + val parent = this.parent as? ViewGroup ?: return + animators.add(ObjectAnimator.ofFloat(parent, "alpha", opacity)) + animators.add(ObjectAnimator.ofFloat(parent, "scaleX", scale)) + animators.add(ObjectAnimator.ofFloat(parent, "scaleY", scale)) + } + if (hasUnderlay) { + animators.add(ObjectAnimator.ofInt(underlayDrawable!!, "alpha", (underlayOpacity * 255).toInt())) + } + currentAnimator = AnimatorSet().apply { + playTogether(animators) + duration = animationDuration.toLong() + interpolator = LinearOutSlowInInterpolator() + start() + } + } + + private fun animatePressIn() { + animateTo(activeOpacity, activeScale, activeUnderlayOpacity) + } + + private fun animatePressOut() { + animateTo(startOpacity, startScale, startUnderlayOpacity) + } + + private fun createUnderlayDrawable(): PaintDrawable { + val drawable = PaintDrawable(underlayColor ?: Color.BLACK) + if (hasBorderRadii) { + drawable.setCornerRadii(buildBorderRadii()) + } + drawable.alpha = (startUnderlayOpacity * 255).toInt() + return drawable + } + + private fun updateBackgroundColor( + backgroundColor: Int, + underlay: Drawable, + borderDrawable: Drawable, + selectable: Drawable?, + ) { val colorDrawable = PaintDrawable(backgroundColor) if (hasBorderRadii) { @@ -340,9 +451,9 @@ class RNGestureHandlerButtonViewManager : val layerDrawable = LayerDrawable( if (selectable != null) { - arrayOf(colorDrawable, selectable, borderDrawable) + arrayOf(colorDrawable, underlay, selectable, borderDrawable) } else { - arrayOf(colorDrawable, borderDrawable) + arrayOf(colorDrawable, underlay, borderDrawable) }, ) background = layerDrawable @@ -365,6 +476,8 @@ class RNGestureHandlerButtonViewManager : val selectable = createSelectableDrawable() val borderDrawable = createBorderDrawable() + val underlay = createUnderlayDrawable() + underlayDrawable = underlay if (hasBorderRadii && selectable is RippleDrawable) { val mask = PaintDrawable(Color.WHITE) @@ -375,13 +488,15 @@ class RNGestureHandlerButtonViewManager : if (useDrawableOnForeground && Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { foreground = selectable if (buttonBackgroundColor != Color.TRANSPARENT) { - updateBackgroundColor(buttonBackgroundColor, borderDrawable, null) + updateBackgroundColor(buttonBackgroundColor, underlay, borderDrawable, null) } } else if (buttonBackgroundColor == Color.TRANSPARENT && rippleColor == null) { - background = LayerDrawable(arrayOf(selectable, borderDrawable)) + background = LayerDrawable(arrayOf(underlay, selectable, borderDrawable)) } else { - updateBackgroundColor(buttonBackgroundColor, borderDrawable, selectable) + updateBackgroundColor(buttonBackgroundColor, underlay, borderDrawable, selectable) } + + applyStartAnimationState() } private fun createBorderDrawable(): Drawable { @@ -540,6 +655,12 @@ class RNGestureHandlerButtonViewManager : // is null or non-exclusive, assuming it doesn't have pressed children isTouched = pressed super.setPressed(pressed) + + if (pressed) { + animatePressIn() + } else { + animatePressOut() + } } if (!pressed && touchResponder === this) { From 145117ab34b2525bc228d2627b183573e277566f Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 12:12:59 +0100 Subject: [PATCH 05/25] Implement web --- .../components/GestureHandlerButton.web.tsx | 90 ++++++++++++++++++- .../src/web/tools/PointerEventManager.ts | 8 +- 2 files changed, 92 insertions(+), 6 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 72efc29f59..62e5de13fb 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -1,12 +1,94 @@ import * as React from 'react'; -import { View, ViewProps } from 'react-native'; +import { ColorValue, View, ViewProps } from 'react-native'; type ButtonProps = ViewProps & { ref?: React.Ref>; + enabled?: boolean; + animationDuration?: number; + activeOpacity?: number; + activeScale?: number; + activeUnderlayOpacity?: number; + startOpacity?: number; + startScale?: number; + startUnderlayOpacity?: number; + underlayColor?: ColorValue; }; -export const ButtonComponent = (props: ButtonProps) => ( - -); +export const ButtonComponent = ({ + enabled = true, + animationDuration = 100, + activeOpacity = 1, + activeScale = 1, + activeUnderlayOpacity = 0, + startOpacity = 1, + startScale = 1, + startUnderlayOpacity = 0, + underlayColor, + style, + children, + ...rest +}: ButtonProps) => { + const [pressed, setPressed] = React.useState(false); + + const pressIn = React.useCallback(() => { + if (enabled) { + setPressed(true); + } + }, [enabled]); + + const pressOut = React.useCallback(() => { + setPressed(false); + }, []); + + const currentOpacity = pressed ? activeOpacity : startOpacity; + const currentScale = pressed ? activeScale : startScale; + const currentUnderlayOpacity = pressed + ? activeUnderlayOpacity + : startUnderlayOpacity; + const hasUnderlay = underlayColor != null; + + const easing = 'cubic-bezier(0, 0, 0.2, 1)'; + const transition = `opacity ${animationDuration}ms ${easing}, transform ${animationDuration}ms ${easing}`; + + return ( + + {hasUnderlay && ( + + )} + {children} + + ); +}; export default ButtonComponent; diff --git a/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts index 2aa7d63bd3..efabaae1d6 100644 --- a/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts @@ -35,7 +35,10 @@ export default class PointerEventManager extends EventManager { const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.DOWN); const target = event.target as HTMLElement; - if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { + if ( + !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) && + this.view.getAttribute('role') !== 'button' + ) { target.setPointerCapture(adaptedEvent.pointerId); } @@ -99,7 +102,8 @@ export default class PointerEventManager extends EventManager { // God, I do love web development. if ( !target?.hasPointerCapture(event.pointerId) && - !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) + !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) && + this.view.getAttribute('role') !== 'button' ) { target.setPointerCapture(event.pointerId); } From 875a4546a4fe7f6ba73a41e29fe42e1769064c08 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 12:19:48 +0100 Subject: [PATCH 06/25] Update props definition --- .../src/components/GestureHandlerButton.tsx | 40 +++++++++++++++++++ 1 file changed, 40 insertions(+) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 178293b68a..bd80a0cf9f 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -62,6 +62,46 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { */ touchSoundDisabled?: boolean | undefined; + /** + * Duration of the animation when the button is pressed. + */ + animationDuration?: number | undefined; + + /** + * Opacity applied to the button when it is pressed. + */ + activeOpacity?: number | undefined; + + /** + * Scale applied to the button when it is pressed. + */ + activeScale?: number | undefined; + + /** + * Opacity applied to the underlay when the button is pressed. + */ + activeUnderlayOpacity?: number | undefined; + + /** + * Opacity applied to the button when it is not pressed. + */ + startOpacity?: number | undefined; + + /** + * Scale applied to the button when it is not pressed. + */ + startScale?: number | undefined; + + /** + * Opacity applied to the underlay when the button is not pressed. + */ + startUnderlayOpacity?: number | undefined; + + /** + * Color of the underlay. + */ + underlayColor?: ColorValue | undefined; + /** * Style object, use it to set additional styles. */ From 52443ed09461a9214de9edea1e39233092fc19f6 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 15:56:01 +0100 Subject: [PATCH 07/25] Use `nil` as default background color --- .../apple/RNGestureHandlerButton.mm | 7 ++----- .../apple/RNGestureHandlerButtonComponentView.mm | 2 ++ 2 files changed, 4 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index bca129eb7f..ed1ba38694 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -55,11 +55,8 @@ - (void)commonInit _startScale = 1.0; _activeUnderlayOpacity = 0.0; _startUnderlayOpacity = 0.0; - -#if !TARGET_OS_OSX - _underlayColor = [UIColor blackColor]; -#else - _underlayColor = [NSColor blackColor]; + _underlayColor = nil; +#if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing #endif diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 837a93326d..0b7f9a0f7c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -237,6 +237,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _buttonView.activeUnderlayOpacity = newProps.activeUnderlayOpacity; if (newProps.underlayColor) { _buttonView.underlayColor = RCTUIColorFromSharedColor(newProps.underlayColor); + } else { + _buttonView.underlayColor = nil; } #if !TARGET_OS_TV && !TARGET_OS_OSX _buttonView.exclusiveTouch = newProps.exclusive; From 3d2857950be80541deecd374bbc29260e8880703 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Wed, 18 Mar 2026 16:09:13 +0100 Subject: [PATCH 08/25] Fix pointer release --- .../src/web/tools/PointerEventManager.ts | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts b/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts index efabaae1d6..3baabbd639 100644 --- a/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts +++ b/packages/react-native-gesture-handler/src/web/tools/PointerEventManager.ts @@ -65,7 +65,10 @@ export default class PointerEventManager extends EventManager { const adaptedEvent: AdaptedEvent = this.mapEvent(event, EventTypes.UP); const target = event.target as HTMLElement; - if (!POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName)) { + if ( + !POINTER_CAPTURE_EXCLUDE_LIST.has(target.tagName) && + this.view.getAttribute('role') !== 'button' + ) { target.releasePointerCapture(adaptedEvent.pointerId); } From de8a29b489d2a7c2ba1a85ae50ee5eb87e2cb30e Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 10:49:53 +0100 Subject: [PATCH 09/25] Fix layer size --- .../apple/RNGestureHandlerButtonComponentView.mm | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index 0b7f9a0f7c..c8956794c5 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -68,12 +68,18 @@ - (void)willMoveToSuperview:(RNGHUIView *)newSuperview { [super willMoveToSuperview:newSuperview]; _buttonView.animationTarget = newSuperview; + if (newSuperview != nil) { + [_buttonView applyStartAnimationState]; + } } #else - (void)viewWillMoveToSuperview:(RNGHUIView *)newSuperview { [super viewWillMoveToSuperview:newSuperview]; _buttonView.animationTarget = newSuperview; + if (newSuperview != nil) { + [_buttonView applyStartAnimationState]; + } } #endif @@ -261,8 +267,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } } - [_buttonView applyStartAnimationState]; [super updateProps:props oldProps:oldProps]; + if (_buttonView.animationTarget != nil) { + [_buttonView applyStartAnimationState]; + } } #if !TARGET_OS_OSX From be8bff4d44511f72fd1d18eb50e3788519c33071 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 11:11:49 +0100 Subject: [PATCH 10/25] Use layer transform --- .../apple/RNGestureHandlerButton.mm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index ed1ba38694..3e7980e173 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -179,7 +179,7 @@ - (void)applyStartAnimationState #if !TARGET_OS_OSX target.alpha = _startOpacity; - target.transform = CGAffineTransformMakeScale(_startScale, _startScale); + target.layer.transform = CATransform3DMakeScale(_startScale, _startScale, 1.0); #else target.wantsLayer = YES; target.alphaValue = _startOpacity; @@ -198,7 +198,7 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState animations:^{ target.alpha = opacity; - target.transform = CGAffineTransformMakeScale(scale, scale); + target.layer.transform = CATransform3DMakeScale(scale, scale, 1.0); } completion:nil]; #else From 46f95c5780c940a6d58b3e8638f692a5c34f6c7e Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 11:25:51 +0100 Subject: [PATCH 11/25] Don't animate when not set --- .../RNGestureHandlerButtonViewManager.kt | 27 ++++++++++----- .../apple/RNGestureHandlerButton.mm | 33 ++++++++++++++----- .../components/GestureHandlerButton.web.tsx | 20 +++++++---- 3 files changed, 56 insertions(+), 24 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 7669da9152..795fd23b76 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 @@ -389,25 +389,34 @@ class RNGestureHandlerButtonViewManager : private fun applyStartAnimationState() { (parent as? ViewGroup)?.let { - it.alpha = startOpacity - it.scaleX = startScale - it.scaleY = startScale + if (activeOpacity != 1.0f || startOpacity != 1.0f) { + it.alpha = startOpacity + } + if (activeScale != 1.0f || startScale != 1.0f) { + it.scaleX = startScale + it.scaleY = startScale + } } underlayDrawable?.alpha = (startUnderlayOpacity * 255).toInt() } private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) { - val hasTransform = activeOpacity != startOpacity || activeScale != startScale + val hasOpacity = activeOpacity != 1.0f || startOpacity != 1.0f + val hasScale = activeScale != 1.0f || startScale != 1.0f val hasUnderlay = activeUnderlayOpacity != startUnderlayOpacity && underlayDrawable != null - if (!hasTransform && !hasUnderlay) return + if (!hasOpacity && !hasScale && !hasUnderlay) return currentAnimator?.cancel() val animators = ArrayList() - if (hasTransform) { + if (hasOpacity || hasScale) { val parent = this.parent as? ViewGroup ?: return - animators.add(ObjectAnimator.ofFloat(parent, "alpha", opacity)) - animators.add(ObjectAnimator.ofFloat(parent, "scaleX", scale)) - animators.add(ObjectAnimator.ofFloat(parent, "scaleY", scale)) + if (hasOpacity) { + animators.add(ObjectAnimator.ofFloat(parent, "alpha", opacity)) + } + if (hasScale) { + animators.add(ObjectAnimator.ofFloat(parent, "scaleX", scale)) + animators.add(ObjectAnimator.ofFloat(parent, "scaleY", scale)) + } } if (hasUnderlay) { animators.add(ObjectAnimator.ofInt(underlayDrawable!!, "alpha", (underlayOpacity * 255).toInt())) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 3e7980e173..ec0c1bba10 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -178,13 +178,20 @@ - (void)applyStartAnimationState _underlayLayer.opacity = _startUnderlayOpacity; #if !TARGET_OS_OSX - target.alpha = _startOpacity; - target.layer.transform = CATransform3DMakeScale(_startScale, _startScale, 1.0); + if (_activeOpacity != 1.0 || _startOpacity != 1.0) { + target.alpha = _startOpacity; + } + if (_activeScale != 1.0 || _startScale != 1.0) { + target.layer.transform = CATransform3DMakeScale(_startScale, _startScale, 1.0); + } #else target.wantsLayer = YES; - target.alphaValue = _startOpacity; - // Use the centered transform helper - target.layer.transform = RNGHCenterScaleTransform(target.bounds, _startScale); + if (_activeOpacity != 1.0 || _startOpacity != 1.0) { + target.alphaValue = _startOpacity; + } + if (_activeScale != 1.0 || _startScale != 1.0) { + target.layer.transform = RNGHCenterScaleTransform(target.bounds, _startScale); + } #endif } @@ -197,8 +204,12 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF delay:0 options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState animations:^{ - target.alpha = opacity; - target.layer.transform = CATransform3DMakeScale(scale, scale, 1.0); + if (_activeOpacity != 1.0 || _startOpacity != 1.0) { + target.alpha = opacity; + } + if (_activeScale != 1.0 || _startScale != 1.0) { + target.layer.transform = CATransform3DMakeScale(scale, scale, 1.0); + } } completion:nil]; #else @@ -208,8 +219,12 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF context.allowsImplicitAnimation = YES; context.duration = duration; context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - target.animator.alphaValue = opacity; - target.layer.transform = RNGHCenterScaleTransform(target.bounds, scale); + if (_activeOpacity != 1.0 || _startOpacity != 1.0) { + target.animator.alphaValue = opacity; + } + if (_activeScale != 1.0 || _startScale != 1.0) { + target.layer.transform = RNGHCenterScaleTransform(target.bounds, scale); + } } completionHandler:nil]; #endif 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 62e5de13fb..629b4bfe5f 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -40,15 +40,24 @@ export const ButtonComponent = ({ setPressed(false); }, []); - const currentOpacity = pressed ? activeOpacity : startOpacity; - const currentScale = pressed ? activeScale : startScale; const currentUnderlayOpacity = pressed ? activeUnderlayOpacity : startUnderlayOpacity; const hasUnderlay = underlayColor != null; + const hasOpacity = activeOpacity !== 1 || startOpacity !== 1; + const currentOpacity = pressed ? activeOpacity : startOpacity; + const hasScale = activeScale !== 1 || startScale !== 1; + const currentScale = pressed ? activeScale : startScale; const easing = 'cubic-bezier(0, 0, 0.2, 1)'; - const transition = `opacity ${animationDuration}ms ${easing}, transform ${animationDuration}ms ${easing}`; + const transitionProps: string[] = []; + if (hasOpacity) { + transitionProps.push(`opacity ${animationDuration}ms ${easing}`); + } + if (hasScale) { + transitionProps.push(`transform ${animationDuration}ms ${easing}`); + } + const transition = transitionProps.join(', '); return ( Date: Thu, 19 Mar 2026 11:26:40 +0100 Subject: [PATCH 12/25] Apply resting styles in React --- .../src/components/GestureHandlerButton.tsx | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index bd80a0cf9f..4559e1e0e6 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -256,6 +256,16 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { [flattenedStyle] ); + const { startOpacity, startScale } = rest; + + const buttonRestingStyle = useMemo( + (): ViewStyle => ({ + opacity: startOpacity, + transform: startScale !== undefined ? [{ scale: startScale }] : undefined, + }), + [startOpacity, startScale] + ); + return ( From cce5ca11ff9ce63d1df96ccebbc2ea719c1997ed Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 11:52:40 +0100 Subject: [PATCH 13/25] Omit the new props from existing buttons --- .../src/v3/components/GestureButtons.tsx | 12 ++++++++---- .../src/v3/components/GestureButtonsProps.ts | 10 +++++++++- 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx index fc6eee6ebc..c6834a6a8e 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtons.tsx @@ -5,6 +5,7 @@ import GestureHandlerButton from '../../components/GestureHandlerButton'; import type { BaseButtonProps, BorderlessButtonProps, + RawButtonProps, RectButtonProps, } from './GestureButtonsProps'; @@ -13,10 +14,13 @@ import type { NativeHandlerData } from '../hooks/gestures/native/NativeTypes'; type CallbackEventType = GestureEvent; -export const RawButton = createNativeWrapper(GestureHandlerButton, { - shouldCancelWhenOutside: false, - shouldActivateOnStart: false, -}); +export const RawButton = createNativeWrapper( + GestureHandlerButton, + { + shouldCancelWhenOutside: false, + shouldActivateOnStart: false, + } +); export const BaseButton = (props: BaseButtonProps) => { const longPressDetected = useRef(false); diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts index 250a4b42df..be0583f5b5 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts @@ -5,7 +5,15 @@ import GestureHandlerButton, { } from '../../components/GestureHandlerButton'; export interface RawButtonProps - extends ButtonProps, + extends Omit< + ButtonProps, + | 'startOpacity' + | 'startScale' + | 'startUnderlayOpacity' + | 'activeOpacity' + | 'activeScale' + | 'activeUnderlayOpacity' + >, Omit< NativeWrapperProperties>, 'hitSlop' | 'enabled' From 0adf9da5329d5f64d469599dd8e71765fdd1e195 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 12:03:27 +0100 Subject: [PATCH 14/25] Align interpolation function --- .../apple/RNGestureHandlerButton.mm | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index ec0c1bba10..ac0d46d2b7 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -151,7 +151,7 @@ - (void)animateUnderlayToOpacity:(float)toOpacity anim.fromValue = @([_underlayLayer.presentationLayer opacity]); anim.toValue = @(toOpacity); anim.duration = _animationDuration / 1000.0; - anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionDefault]; + anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; _underlayLayer.opacity = toOpacity; [_underlayLayer addAnimation:anim forKey:@"opacity"]; } From 71e8692d0fa92c0da07f01160cff67093bc6a924 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 12:06:43 +0100 Subject: [PATCH 15/25] Keep underlay layer at the bottom --- .../apple/RNGestureHandlerButton.mm | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index ac0d46d2b7..fe68a7e7c9 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -112,12 +112,14 @@ - (void)layoutSubviews { [super layoutSubviews]; _underlayLayer.frame = self.bounds; + [self.layer insertSublayer:_underlayLayer atIndex:0]; } #else - (void)layout { [super layout]; _underlayLayer.frame = self.bounds; + [self.layer insertSublayer:_underlayLayer atIndex:0]; } #endif From f202327d4d348d94bdc3e08f47a1fcb1a45f90dd Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 12:28:51 +0100 Subject: [PATCH 16/25] Change order --- .../apple/RNGestureHandlerButton.mm | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index fe68a7e7c9..e6397a75d3 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -253,14 +253,14 @@ - (void)handleAnimatePressOut #if TARGET_OS_OSX - (void)mouseDown:(NSEvent *)event { - [super mouseDown:event]; [self handleAnimatePressIn]; + [super mouseDown:event]; } - (void)mouseUp:(NSEvent *)event { - [super mouseUp:event]; [self handleAnimatePressOut]; + [super mouseUp:event]; } #endif From 89eaa206c5dd5005e1ac96098b39b25a94f581e7 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 12:34:47 +0100 Subject: [PATCH 17/25] Update background on start opacity change --- .../gesturehandler/react/RNGestureHandlerButtonViewManager.kt | 3 +++ 1 file changed, 3 insertions(+) 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 795fd23b76..d2865e960d 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 @@ -266,6 +266,9 @@ class RNGestureHandlerButtonViewManager : } var activeUnderlayOpacity: Float = 0f var startUnderlayOpacity: Float = 0f + set(value) = withBackgroundUpdate { + field = value + } override var pointerEvents: PointerEvents = PointerEvents.AUTO From e10e612f5057491289317a0b03d7b9db37bc487c Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 12:47:42 +0100 Subject: [PATCH 18/25] Update legacy types --- .../src/components/GestureButtons.tsx | 54 +++++++++++-------- 1 file changed, 33 insertions(+), 21 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx index 43ed5518a6..e2c2636c48 100644 --- a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx @@ -17,15 +17,19 @@ import type { LegacyRectButtonProps, BorderlessButtonWithRefProps, LegacyBorderlessButtonProps, + LegacyRawButtonProps, } from './GestureButtonsProps'; /** * @deprecated use `RawButton` instead */ -export const LegacyRawButton = createNativeWrapper(GestureHandlerButton, { - shouldCancelWhenOutside: false, - shouldActivateOnStart: false, -}); +export const LegacyRawButton = createNativeWrapper( + GestureHandlerButton, + { + shouldCancelWhenOutside: false, + shouldActivateOnStart: false, + } +); class InnerBaseButton extends React.Component { static defaultProps = { @@ -144,15 +148,19 @@ const AnimatedInnerBaseButton = /** * @deprecated use `BaseButton` instead */ -export const LegacyBaseButton = React.forwardRef< - React.ComponentType, - Omit ->((props, ref) => ); - -const AnimatedBaseButton = React.forwardRef< - React.ComponentType, - Animated.AnimatedProps ->((props, ref) => ); +export const LegacyBaseButton = ({ + ref, + ...props +}: Omit & { + ref?: React.ForwardedRef> | undefined; +}) => ; + +const AnimatedBaseButton = ({ + ref, + ...props +}: Animated.AnimatedProps & { + ref?: React.ForwardedRef> | undefined; +}) => ; const btnStyles = StyleSheet.create({ underlay: { @@ -219,10 +227,12 @@ class InnerRectButton extends React.Component { /** * @deprecated use `RectButton` instead */ -export const LegacyRectButton = React.forwardRef< - React.ComponentType, - Omit ->((props, ref) => ); +export const LegacyRectButton = ({ + ref, + ...props +}: Omit & { + ref?: React.ForwardedRef> | undefined; +}) => ; class InnerBorderlessButton extends React.Component { static defaultProps = { @@ -263,9 +273,11 @@ class InnerBorderlessButton extends React.Component ->((props, ref) => ); +export const LegacyBorderlessButton = ({ + ref, + ...props +}: Omit & { + ref?: React.ForwardedRef> | undefined; +}) => ; export { default as LegacyPureNativeButton } from './GestureHandlerButton'; From 5a1423e282a1a3ddc158403f07224a5d72203992 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 12:51:16 +0100 Subject: [PATCH 19/25] Don't pass activeOpacity to native view --- .../src/components/GestureButtons.tsx | 4 +++- .../src/v3/components/GestureButtons.tsx | 11 +++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx index e2c2636c48..13d996bb99 100644 --- a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx @@ -256,7 +256,9 @@ class InnerBorderlessButton extends React.Component { - const activeOpacity = props.activeOpacity ?? 0.105; - const underlayColor = props.underlayColor ?? 'black'; + const { + children, + style, + activeOpacity = 0.105, + underlayColor = 'black', + ...rest + } = props; const opacity = useRef(new Animated.Value(0)).current; @@ -115,8 +120,6 @@ export const RectButton = (props: RectButtonProps) => { props.onActiveStateChange?.(active); }; - const { children, style, ...rest } = props; - const resolvedStyle = StyleSheet.flatten(style ?? {}); return ( From 86813834143788be7643b8188d58e0f9d89268a8 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Thu, 19 Mar 2026 15:03:17 +0100 Subject: [PATCH 20/25] `start*` -> `default*` --- .../RNGestureHandlerButtonViewManager.kt | 46 +++++++++---------- .../apple/RNGestureHandlerButton.h | 6 +-- .../apple/RNGestureHandlerButton.mm | 40 ++++++++-------- .../RNGestureHandlerButtonComponentView.mm | 6 +-- .../src/components/GestureHandlerButton.tsx | 15 +++--- .../components/GestureHandlerButton.web.tsx | 22 ++++----- .../RNGestureHandlerButtonNativeComponent.ts | 6 +-- .../src/v3/components/GestureButtonsProps.ts | 6 +-- 8 files changed, 74 insertions(+), 73 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 d2865e960d..fd5825231e 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 @@ -141,9 +141,9 @@ class RNGestureHandlerButtonViewManager : view.animationDuration = animationDuration } - @ReactProp(name = "startOpacity") - override fun setStartOpacity(view: ButtonViewGroup, startOpacity: Float) { - view.startOpacity = startOpacity + @ReactProp(name = "defaultOpacity") + override fun setDefaultOpacity(view: ButtonViewGroup, defaultOpacity: Float) { + view.defaultOpacity = defaultOpacity } @ReactProp(name = "activeOpacity") @@ -151,9 +151,9 @@ class RNGestureHandlerButtonViewManager : view.activeOpacity = targetOpacity } - @ReactProp(name = "startScale") - override fun setStartScale(view: ButtonViewGroup, startScale: Float) { - view.startScale = startScale + @ReactProp(name = "defaultScale") + override fun setDefaultScale(view: ButtonViewGroup, defaultScale: Float) { + view.defaultScale = defaultScale } @ReactProp(name = "activeScale") @@ -166,9 +166,9 @@ class RNGestureHandlerButtonViewManager : view.underlayColor = underlayColor } - @ReactProp(name = "startUnderlayOpacity") - override fun setStartUnderlayOpacity(view: ButtonViewGroup, startUnderlayOpacity: Float) { - view.startUnderlayOpacity = startUnderlayOpacity + @ReactProp(name = "defaultUnderlayOpacity") + override fun setDefaultUnderlayOpacity(view: ButtonViewGroup, defaultUnderlayOpacity: Float) { + view.defaultUnderlayOpacity = defaultUnderlayOpacity } @ReactProp(name = "activeUnderlayOpacity") @@ -257,15 +257,15 @@ class RNGestureHandlerButtonViewManager : var exclusive = true var animationDuration: Int = 100 var activeOpacity: Float = 1.0f - var startOpacity: Float = 1.0f + var defaultOpacity: Float = 1.0f var activeScale: Float = 1.0f - var startScale: Float = 1.0f + var defaultScale: Float = 1.0f var underlayColor: Int? = null set(color) = withBackgroundUpdate { field = color } var activeUnderlayOpacity: Float = 0f - var startUnderlayOpacity: Float = 0f + var defaultUnderlayOpacity: Float = 0f set(value) = withBackgroundUpdate { field = value } @@ -392,21 +392,21 @@ class RNGestureHandlerButtonViewManager : private fun applyStartAnimationState() { (parent as? ViewGroup)?.let { - if (activeOpacity != 1.0f || startOpacity != 1.0f) { - it.alpha = startOpacity + if (activeOpacity != 1.0f || defaultOpacity != 1.0f) { + it.alpha = defaultOpacity } - if (activeScale != 1.0f || startScale != 1.0f) { - it.scaleX = startScale - it.scaleY = startScale + if (activeScale != 1.0f || defaultScale != 1.0f) { + it.scaleX = defaultScale + it.scaleY = defaultScale } } - underlayDrawable?.alpha = (startUnderlayOpacity * 255).toInt() + underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt() } private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) { - val hasOpacity = activeOpacity != 1.0f || startOpacity != 1.0f - val hasScale = activeScale != 1.0f || startScale != 1.0f - val hasUnderlay = activeUnderlayOpacity != startUnderlayOpacity && underlayDrawable != null + val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f + val hasScale = activeScale != 1.0f || defaultScale != 1.0f + val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null if (!hasOpacity && !hasScale && !hasUnderlay) return currentAnimator?.cancel() @@ -437,7 +437,7 @@ class RNGestureHandlerButtonViewManager : } private fun animatePressOut() { - animateTo(startOpacity, startScale, startUnderlayOpacity) + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) } private fun createUnderlayDrawable(): PaintDrawable { @@ -445,7 +445,7 @@ class RNGestureHandlerButtonViewManager : if (hasBorderRadii) { drawable.setCornerRadii(buildBorderRadii()) } - drawable.alpha = (startUnderlayOpacity * 255).toInt() + drawable.alpha = (defaultUnderlayOpacity * 255).toInt() return drawable } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 7fdc71e57e..e421de01b8 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -30,10 +30,10 @@ @property (nonatomic, assign) NSInteger animationDuration; @property (nonatomic, assign) CGFloat activeOpacity; -@property (nonatomic, assign) CGFloat startOpacity; +@property (nonatomic, assign) CGFloat defaultOpacity; @property (nonatomic, assign) CGFloat activeScale; -@property (nonatomic, assign) CGFloat startScale; -@property (nonatomic, assign) CGFloat startUnderlayOpacity; +@property (nonatomic, assign) CGFloat defaultScale; +@property (nonatomic, assign) CGFloat defaultUnderlayOpacity; @property (nonatomic, assign) CGFloat activeUnderlayOpacity; @property (nonatomic, strong, nullable) RNGHColor *underlayColor; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index e6397a75d3..5b7a885dda 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -50,11 +50,11 @@ - (void)commonInit _pointerEvents = RNGestureHandlerPointerEventsAuto; _animationDuration = 100; _activeOpacity = 1.0; - _startOpacity = 1.0; + _defaultOpacity = 1.0; _activeScale = 1.0; - _startScale = 1.0; + _defaultScale = 1.0; _activeUnderlayOpacity = 0.0; - _startUnderlayOpacity = 0.0; + _defaultUnderlayOpacity = 0.0; _underlayColor = nil; #if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing @@ -177,22 +177,22 @@ static CATransform3D RNGHCenterScaleTransform(NSRect bounds, CGFloat scale) - (void)applyStartAnimationState { RNGHUIView *target = self.animationTarget ?: self; - _underlayLayer.opacity = _startUnderlayOpacity; + _underlayLayer.opacity = _defaultUnderlayOpacity; #if !TARGET_OS_OSX - if (_activeOpacity != 1.0 || _startOpacity != 1.0) { - target.alpha = _startOpacity; + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + target.alpha = _defaultOpacity; } - if (_activeScale != 1.0 || _startScale != 1.0) { - target.layer.transform = CATransform3DMakeScale(_startScale, _startScale, 1.0); + if (_activeScale != 1.0 || _defaultScale != 1.0) { + target.layer.transform = CATransform3DMakeScale(_defaultScale, _defaultScale, 1.0); } #else target.wantsLayer = YES; - if (_activeOpacity != 1.0 || _startOpacity != 1.0) { - target.alphaValue = _startOpacity; + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + target.alphaValue = _defaultOpacity; } - if (_activeScale != 1.0 || _startScale != 1.0) { - target.layer.transform = RNGHCenterScaleTransform(target.bounds, _startScale); + if (_activeScale != 1.0 || _defaultScale != 1.0) { + target.layer.transform = RNGHCenterScaleTransform(target.bounds, _defaultScale); } #endif } @@ -206,10 +206,10 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF delay:0 options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState animations:^{ - if (_activeOpacity != 1.0 || _startOpacity != 1.0) { + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { target.alpha = opacity; } - if (_activeScale != 1.0 || _startScale != 1.0) { + if (_activeScale != 1.0 || _defaultScale != 1.0) { target.layer.transform = CATransform3DMakeScale(scale, scale, 1.0); } } @@ -221,10 +221,10 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF context.allowsImplicitAnimation = YES; context.duration = duration; context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; - if (_activeOpacity != 1.0 || _startOpacity != 1.0) { + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { target.animator.alphaValue = opacity; } - if (_activeScale != 1.0 || _startScale != 1.0) { + if (_activeScale != 1.0 || _defaultScale != 1.0) { target.layer.transform = RNGHCenterScaleTransform(target.bounds, scale); } } @@ -236,7 +236,7 @@ - (void)handleAnimatePressIn { RNGHUIView *target = self.animationTarget ?: self; [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale]; - if (_activeUnderlayOpacity != _startUnderlayOpacity) { + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { [self animateUnderlayToOpacity:_activeUnderlayOpacity]; } } @@ -244,9 +244,9 @@ - (void)handleAnimatePressIn - (void)handleAnimatePressOut { RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_startOpacity scale:_startScale]; - if (_activeUnderlayOpacity != _startUnderlayOpacity) { - [self animateUnderlayToOpacity:_startUnderlayOpacity]; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity]; } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index c8956794c5..a87ca71a71 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -236,10 +236,10 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & _buttonView.userEnabled = newProps.enabled; _buttonView.animationDuration = newProps.animationDuration; _buttonView.activeOpacity = newProps.activeOpacity; - _buttonView.startOpacity = newProps.startOpacity; + _buttonView.defaultOpacity = newProps.defaultOpacity; _buttonView.activeScale = newProps.activeScale; - _buttonView.startScale = newProps.startScale; - _buttonView.startUnderlayOpacity = newProps.startUnderlayOpacity; + _buttonView.defaultScale = newProps.defaultScale; + _buttonView.defaultUnderlayOpacity = newProps.defaultUnderlayOpacity; _buttonView.activeUnderlayOpacity = newProps.activeUnderlayOpacity; if (newProps.underlayColor) { _buttonView.underlayColor = RCTUIColorFromSharedColor(newProps.underlayColor); diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 4559e1e0e6..d17843b252 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -85,17 +85,17 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { /** * Opacity applied to the button when it is not pressed. */ - startOpacity?: number | undefined; + defaultOpacity?: number | undefined; /** * Scale applied to the button when it is not pressed. */ - startScale?: number | undefined; + defaultScale?: number | undefined; /** * Opacity applied to the underlay when the button is not pressed. */ - startUnderlayOpacity?: number | undefined; + defaultUnderlayOpacity?: number | undefined; /** * Color of the underlay. @@ -256,14 +256,15 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { [flattenedStyle] ); - const { startOpacity, startScale } = rest; + const { defaultOpacity, defaultScale } = rest; const buttonRestingStyle = useMemo( (): ViewStyle => ({ - opacity: startOpacity, - transform: startScale !== undefined ? [{ scale: startScale }] : undefined, + opacity: defaultOpacity, + transform: + defaultScale !== undefined ? [{ scale: defaultScale }] : undefined, }), - [startOpacity, startScale] + [defaultOpacity, defaultScale] ); return ( 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 629b4bfe5f..7ba5d1a69f 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -8,9 +8,9 @@ type ButtonProps = ViewProps & { activeOpacity?: number; activeScale?: number; activeUnderlayOpacity?: number; - startOpacity?: number; - startScale?: number; - startUnderlayOpacity?: number; + defaultOpacity?: number; + defaultScale?: number; + defaultUnderlayOpacity?: number; underlayColor?: ColorValue; }; @@ -20,9 +20,9 @@ export const ButtonComponent = ({ activeOpacity = 1, activeScale = 1, activeUnderlayOpacity = 0, - startOpacity = 1, - startScale = 1, - startUnderlayOpacity = 0, + defaultOpacity = 1, + defaultScale = 1, + defaultUnderlayOpacity = 0, underlayColor, style, children, @@ -42,12 +42,12 @@ export const ButtonComponent = ({ const currentUnderlayOpacity = pressed ? activeUnderlayOpacity - : startUnderlayOpacity; + : defaultUnderlayOpacity; const hasUnderlay = underlayColor != null; - const hasOpacity = activeOpacity !== 1 || startOpacity !== 1; - const currentOpacity = pressed ? activeOpacity : startOpacity; - const hasScale = activeScale !== 1 || startScale !== 1; - const currentScale = pressed ? activeScale : startScale; + const hasOpacity = activeOpacity !== 1 || defaultOpacity !== 1; + const currentOpacity = pressed ? activeOpacity : defaultOpacity; + const hasScale = activeScale !== 1 || defaultScale !== 1; + const currentScale = pressed ? activeScale : defaultScale; const easing = 'cubic-bezier(0, 0, 0.2, 1)'; const transitionProps: string[] = []; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index d5cdfa8694..47d37f37cf 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -26,9 +26,9 @@ interface NativeProps extends ViewProps { activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault; - startOpacity?: WithDefault; - startScale?: WithDefault; - startUnderlayOpacity?: WithDefault; + defaultOpacity?: WithDefault; + defaultScale?: WithDefault; + defaultUnderlayOpacity?: WithDefault; underlayColor?: ColorValue; } diff --git a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts index be0583f5b5..b2d29dae71 100644 --- a/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts +++ b/packages/react-native-gesture-handler/src/v3/components/GestureButtonsProps.ts @@ -7,9 +7,9 @@ import GestureHandlerButton, { export interface RawButtonProps extends Omit< ButtonProps, - | 'startOpacity' - | 'startScale' - | 'startUnderlayOpacity' + | 'defaultOpacity' + | 'defaultScale' + | 'defaultUnderlayOpacity' | 'activeOpacity' | 'activeScale' | 'activeUnderlayOpacity' From 4de84a729c4634dae2d26c81eb169881ff755972 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 20 Mar 2026 10:02:49 +0100 Subject: [PATCH 21/25] Don't pass activeOpacity in legacy rect button --- .../src/components/GestureButtons.tsx | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx index 13d996bb99..2832752605 100644 --- a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx @@ -194,7 +194,9 @@ class InnerRectButton extends React.Component { }; override render() { - const { children, style, ...rest } = this.props; + // Move activeOpacity out of the rest props to avoid passing it to the native component + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const { children, style, activeOpacity, ...rest } = this.props; const resolvedStyle = StyleSheet.flatten(style) ?? {}; From 5be1308409983452fd4d923867046858b3a66ec0 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 20 Mar 2026 10:04:20 +0100 Subject: [PATCH 22/25] Wrap return in curly braces --- .../gesturehandler/react/RNGestureHandlerButtonViewManager.kt | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) 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 fd5825231e..7a013ad041 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 @@ -407,7 +407,9 @@ class RNGestureHandlerButtonViewManager : val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f val hasScale = activeScale != 1.0f || defaultScale != 1.0f val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null - if (!hasOpacity && !hasScale && !hasUnderlay) return + if (!hasOpacity && !hasScale && !hasUnderlay) { + return + } currentAnimator?.cancel() val animators = ArrayList() From df6fde72500dba037ab39f74d950f81aea3254e4 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 20 Mar 2026 10:08:31 +0100 Subject: [PATCH 23/25] Use aliased color --- .../apple/RNGestureHandlerButton.mm | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 5b7a885dda..68f2f2b550 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -62,12 +62,7 @@ - (void)commonInit _underlayLayer = [CALayer new]; _underlayLayer.opacity = 0; - -#if !TARGET_OS_OSX - _underlayLayer.backgroundColor = [UIColor blackColor].CGColor; -#else - _underlayLayer.backgroundColor = [NSColor blackColor].CGColor; -#endif + _underlayLayer.backgroundColor = [RNGHColor blackColor].CGColor; [self.layer insertSublayer:_underlayLayer atIndex:0]; From 2efcfb6fd6c4df3be4e5767df4d3dedbdfacf47d Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 20 Mar 2026 10:10:43 +0100 Subject: [PATCH 24/25] Move self assignment to the if expressions --- .../apple/RNGestureHandlerButton.mm | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 68f2f2b550..ed6fbde095 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -80,8 +80,7 @@ - (void)commonInit - (instancetype)init { - self = [super init]; - if (self) { + if (self = [super init]) { [self commonInit]; } return self; @@ -89,8 +88,7 @@ - (instancetype)init - (instancetype)initWithFrame:(CGRect)frame { - self = [super initWithFrame:frame]; - if (self) { + if (self = [super initWithFrame:frame]) { [self commonInit]; } return self; From d01547eb2d7040bf5f268d417f96d46d3cd553f4 Mon Sep 17 00:00:00 2001 From: Jakub Piasecki Date: Fri, 20 Mar 2026 11:05:38 +0100 Subject: [PATCH 25/25] Animate out when dragged outside --- .../apple/RNGestureHandlerButton.mm | 14 +++++++++++--- 1 file changed, 11 insertions(+), 3 deletions(-) diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index ed6fbde095..c433e64bec 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -68,9 +68,7 @@ - (void)commonInit #if !TARGET_OS_TV && !TARGET_OS_OSX [self setExclusiveTouch:YES]; - [self addTarget:self - action:@selector(handleAnimatePressIn) - forControlEvents:UIControlEventTouchDown | UIControlEventTouchDragEnter]; + [self addTarget:self action:@selector(handleAnimatePressIn) forControlEvents:UIControlEventTouchDown]; [self addTarget:self action:@selector(handleAnimatePressOut) forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit | @@ -255,6 +253,16 @@ - (void)mouseUp:(NSEvent *)event [self handleAnimatePressOut]; [super mouseUp:event]; } + +- (void)mouseDragged:(NSEvent *)event +{ + NSPoint locationInWindow = [event locationInWindow]; + NSPoint locationInView = [self convertPoint:locationInWindow fromView:nil]; + + if (!NSPointInRect(locationInView, self.bounds)) { + [self handleAnimatePressOut]; + } +} #endif #if !TARGET_OS_OSX