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..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 @@ -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 = "defaultOpacity") + override fun setDefaultOpacity(view: ButtonViewGroup, defaultOpacity: Float) { + view.defaultOpacity = defaultOpacity + } + + @ReactProp(name = "activeOpacity") + override fun setActiveOpacity(view: ButtonViewGroup, targetOpacity: Float) { + view.activeOpacity = targetOpacity + } + + @ReactProp(name = "defaultScale") + override fun setDefaultScale(view: ButtonViewGroup, defaultScale: Float) { + view.defaultScale = defaultScale + } + + @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 = "defaultUnderlayOpacity") + override fun setDefaultUnderlayOpacity(view: ButtonViewGroup, defaultUnderlayOpacity: Float) { + view.defaultUnderlayOpacity = defaultUnderlayOpacity + } + + @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,20 @@ class RNGestureHandlerButtonViewManager : borderBottomRightRadius != 0f var exclusive = true + var animationDuration: Int = 100 + var activeOpacity: Float = 1.0f + var defaultOpacity: Float = 1.0f + var activeScale: Float = 1.0f + var defaultScale: Float = 1.0f + var underlayColor: Int? = null + set(color) = withBackgroundUpdate { + field = color + } + var activeUnderlayOpacity: Float = 0f + var defaultUnderlayOpacity: Float = 0f + set(value) = withBackgroundUpdate { + field = value + } override var pointerEvents: PointerEvents = PointerEvents.AUTO @@ -220,6 +277,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 +390,73 @@ class RNGestureHandlerButtonViewManager : return false } - private fun updateBackgroundColor(backgroundColor: Int, borderDrawable: Drawable, selectable: Drawable?) { + private fun applyStartAnimationState() { + (parent as? ViewGroup)?.let { + if (activeOpacity != 1.0f || defaultOpacity != 1.0f) { + it.alpha = defaultOpacity + } + if (activeScale != 1.0f || defaultScale != 1.0f) { + it.scaleX = defaultScale + it.scaleY = defaultScale + } + } + underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt() + } + + private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) { + 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() + val animators = ArrayList() + if (hasOpacity || hasScale) { + val parent = this.parent as? ViewGroup ?: return + 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())) + } + currentAnimator = AnimatorSet().apply { + playTogether(animators) + duration = animationDuration.toLong() + interpolator = LinearOutSlowInInterpolator() + start() + } + } + + private fun animatePressIn() { + animateTo(activeOpacity, activeScale, activeUnderlayOpacity) + } + + private fun animatePressOut() { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + } + + private fun createUnderlayDrawable(): PaintDrawable { + val drawable = PaintDrawable(underlayColor ?: Color.BLACK) + if (hasBorderRadii) { + drawable.setCornerRadii(buildBorderRadii()) + } + drawable.alpha = (defaultUnderlayOpacity * 255).toInt() + return drawable + } + + private fun updateBackgroundColor( + backgroundColor: Int, + underlay: Drawable, + borderDrawable: Drawable, + selectable: Drawable?, + ) { val colorDrawable = PaintDrawable(backgroundColor) if (hasBorderRadii) { @@ -340,9 +465,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 +490,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 +502,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 +669,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) { 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..e421de01b8 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 defaultOpacity; +@property (nonatomic, assign) CGFloat activeScale; +@property (nonatomic, assign) CGFloat defaultScale; +@property (nonatomic, assign) CGFloat defaultUnderlayOpacity; +@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..c433e64bec 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,81 @@ * `TapGestureHandler` instead of a button which gives much better flexibility as far as * controlling the touch flow. */ -@implementation RNGestureHandlerButton +@implementation RNGestureHandlerButton { + CALayer *_underlayLayer; +} -- (instancetype)init +- (void)commonInit { - self = [super init]; - if (self) { - _hitTestEdgeInsets = UIEdgeInsetsZero; - _userEnabled = YES; - _pointerEvents = RNGestureHandlerPointerEventsAuto; + _hitTestEdgeInsets = UIEdgeInsetsZero; + _userEnabled = YES; + _pointerEvents = RNGestureHandlerPointerEventsAuto; + _animationDuration = 100; + _activeOpacity = 1.0; + _defaultOpacity = 1.0; + _activeScale = 1.0; + _defaultScale = 1.0; + _activeUnderlayOpacity = 0.0; + _defaultUnderlayOpacity = 0.0; + _underlayColor = nil; +#if TARGET_OS_OSX + self.wantsLayer = YES; // Crucial for macOS layer-backing +#endif + + _underlayLayer = [CALayer new]; + _underlayLayer.opacity = 0; + _underlayLayer.backgroundColor = [RNGHColor blackColor].CGColor; + + [self.layer insertSublayer:_underlayLayer atIndex:0]; + #if !TARGET_OS_TV && !TARGET_OS_OSX - [self setExclusiveTouch:YES]; + [self setExclusiveTouch:YES]; + [self addTarget:self action:@selector(handleAnimatePressIn) forControlEvents:UIControlEventTouchDown]; + [self addTarget:self + action:@selector(handleAnimatePressOut) + forControlEvents:UIControlEventTouchUpInside | UIControlEventTouchUpOutside | UIControlEventTouchDragExit | + UIControlEventTouchCancel]; #endif +} + +- (instancetype)init +{ + if (self = [super init]) { + [self commonInit]; } return self; } +- (instancetype)initWithFrame:(CGRect)frame +{ + if (self = [super initWithFrame:frame]) { + [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; + [self.layer insertSublayer:_underlayLayer atIndex:0]; +} +#else +- (void)layout +{ + [super layout]; + _underlayLayer.frame = self.bounds; + [self.layer insertSublayer:_underlayLayer atIndex:0]; +} +#endif + - (BOOL)shouldHandleTouch:(RNGHUIView *)view { if ([view isKindOfClass:[RNGestureHandlerButton class]]) { @@ -78,6 +138,133 @@ - (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:kCAMediaTimingFunctionEaseInEaseOut]; + _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 = _defaultUnderlayOpacity; + +#if !TARGET_OS_OSX + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + target.alpha = _defaultOpacity; + } + if (_activeScale != 1.0 || _defaultScale != 1.0) { + target.layer.transform = CATransform3DMakeScale(_defaultScale, _defaultScale, 1.0); + } +#else + target.wantsLayer = YES; + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + target.alphaValue = _defaultOpacity; + } + if (_activeScale != 1.0 || _defaultScale != 1.0) { + target.layer.transform = RNGHCenterScaleTransform(target.bounds, _defaultScale); + } +#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:^{ + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + target.alpha = opacity; + } + if (_activeScale != 1.0 || _defaultScale != 1.0) { + target.layer.transform = CATransform3DMakeScale(scale, scale, 1.0); + } + } + completion:nil]; +#else + target.wantsLayer = YES; + [NSAnimationContext + runAnimationGroup:^(NSAnimationContext *context) { + context.allowsImplicitAnimation = YES; + context.duration = duration; + context.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; + if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { + target.animator.alphaValue = opacity; + } + if (_activeScale != 1.0 || _defaultScale != 1.0) { + 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 != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_activeUnderlayOpacity]; + } +} + +- (void)handleAnimatePressOut +{ + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity]; + } +} + +#if TARGET_OS_OSX +- (void)mouseDown:(NSEvent *)event +{ + [self handleAnimatePressIn]; + [super mouseDown:event]; +} + +- (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 - (BOOL)pointInside:(CGPoint)point withEvent:(UIEvent *)event { @@ -141,6 +328,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..a87ca71a71 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -63,6 +63,26 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +#if !TARGET_OS_OSX +- (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 + - (void)mountChildComponentView:(RNGHUIView *)childComponentView index:(NSInteger)index { [_buttonView mountChildComponentView:childComponentView index:index]; @@ -214,6 +234,18 @@ - (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.defaultOpacity = newProps.defaultOpacity; + _buttonView.activeScale = newProps.activeScale; + _buttonView.defaultScale = newProps.defaultScale; + _buttonView.defaultUnderlayOpacity = newProps.defaultUnderlayOpacity; + _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; [self setAccessibilityProps:props oldProps:oldProps]; @@ -236,6 +268,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & } [super updateProps:props oldProps:oldProps]; + if (_buttonView.animationTarget != nil) { + [_buttonView applyStartAnimationState]; + } } #if !TARGET_OS_OSX 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 diff --git a/packages/react-native-gesture-handler/src/components/GestureButtons.tsx b/packages/react-native-gesture-handler/src/components/GestureButtons.tsx index 43ed5518a6..2832752605 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: { @@ -186,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) ?? {}; @@ -219,10 +229,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 = { @@ -246,7 +258,9 @@ class InnerBorderlessButton extends React.Component ->((props, ref) => ); +export const LegacyBorderlessButton = ({ + ref, + ...props +}: Omit & { + ref?: React.ForwardedRef> | undefined; +}) => ; export { default as LegacyPureNativeButton } from './GestureHandlerButton'; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 178293b68a..d17843b252 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. + */ + defaultOpacity?: number | undefined; + + /** + * Scale applied to the button when it is not pressed. + */ + defaultScale?: number | undefined; + + /** + * Opacity applied to the underlay when the button is not pressed. + */ + defaultUnderlayOpacity?: number | undefined; + + /** + * Color of the underlay. + */ + underlayColor?: ColorValue | undefined; + /** * Style object, use it to set additional styles. */ @@ -216,6 +256,17 @@ export default function GestureHandlerButton({ style, ...rest }: ButtonProps) { [flattenedStyle] ); + const { defaultOpacity, defaultScale } = rest; + + const buttonRestingStyle = useMemo( + (): ViewStyle => ({ + opacity: defaultOpacity, + transform: + defaultScale !== undefined ? [{ scale: defaultScale }] : undefined, + }), + [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 72efc29f59..7ba5d1a69f 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,102 @@ 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; + defaultOpacity?: number; + defaultScale?: number; + defaultUnderlayOpacity?: number; + underlayColor?: ColorValue; }; -export const ButtonComponent = (props: ButtonProps) => ( - -); +export const ButtonComponent = ({ + enabled = true, + animationDuration = 100, + activeOpacity = 1, + activeScale = 1, + activeUnderlayOpacity = 0, + defaultOpacity = 1, + defaultScale = 1, + defaultUnderlayOpacity = 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 currentUnderlayOpacity = pressed + ? activeUnderlayOpacity + : defaultUnderlayOpacity; + const hasUnderlay = underlayColor != null; + 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[] = []; + if (hasOpacity) { + transitionProps.push(`opacity ${animationDuration}ms ${easing}`); + } + if (hasScale) { + transitionProps.push(`transform ${animationDuration}ms ${easing}`); + } + const transition = transitionProps.join(', '); + + return ( + + {hasUnderlay && ( + + )} + {children} + + ); +}; export default ButtonComponent; diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 4fedd3160e..47d37f37cf 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; + defaultOpacity?: WithDefault; + defaultScale?: WithDefault; + defaultUnderlayOpacity?: WithDefault; + underlayColor?: ColorValue; } export default codegenNativeComponent('RNGestureHandlerButton'); 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..b7dca82abf 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); @@ -98,8 +102,13 @@ const btnStyles = StyleSheet.create({ }); export const RectButton = (props: RectButtonProps) => { - 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; @@ -111,8 +120,6 @@ export const RectButton = (props: RectButtonProps) => { props.onActiveStateChange?.(active); }; - const { children, style, ...rest } = props; - const resolvedStyle = StyleSheet.flatten(style ?? {}); return ( 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..b2d29dae71 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, + | 'defaultOpacity' + | 'defaultScale' + | 'defaultUnderlayOpacity' + | 'activeOpacity' + | 'activeScale' + | 'activeUnderlayOpacity' + >, Omit< NativeWrapperProperties>, 'hitSlop' | 'enabled' 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..3baabbd639 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); } @@ -62,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); } @@ -99,7 +105,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); }