diff --git a/apps/common-app/src/new_api/components/button_underlay/index.tsx b/apps/common-app/src/new_api/components/button_underlay/index.tsx index 1c4c54409c..fa3661615d 100644 --- a/apps/common-app/src/new_api/components/button_underlay/index.tsx +++ b/apps/common-app/src/new_api/components/button_underlay/index.tsx @@ -8,8 +8,10 @@ import { const UNDERLAY_PROPS = { underlayColor: 'red', - activeUnderlayOpacity: 0.5, - animationDuration: 200, + activeUnderlayOpacity: 0.2, + activeScale: 0.9, + pressAndHoldAnimationDuration: 200, + tapAnimationDuration: 100, rippleColor: 'transparent', } as const; diff --git a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt index 31428bed99..b26abdbba6 100644 --- a/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt +++ b/packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt @@ -15,6 +15,7 @@ import android.graphics.drawable.RippleDrawable import android.graphics.drawable.ShapeDrawable import android.graphics.drawable.shapes.RectShape import android.os.Build +import android.os.SystemClock import android.util.TypedValue import android.view.KeyEvent import android.view.MotionEvent @@ -266,9 +267,14 @@ class RNGestureHandlerButtonViewManager : view.isSoundEffectsEnabled = !touchSoundDisabled } - @ReactProp(name = "animationDuration") - override fun setAnimationDuration(view: ButtonViewGroup, animationDuration: Int) { - view.animationDuration = animationDuration + @ReactProp(name = "pressAndHoldAnimationDuration") + override fun setPressAndHoldAnimationDuration(view: ButtonViewGroup, pressAndHoldAnimationDuration: Int) { + view.pressAndHoldAnimationDuration = pressAndHoldAnimationDuration + } + + @ReactProp(name = "tapAnimationDuration") + override fun setTapAnimationDuration(view: ButtonViewGroup, tapAnimationDuration: Int) { + view.tapAnimationDuration = tapAnimationDuration } @ReactProp(name = "defaultOpacity") @@ -346,7 +352,9 @@ class RNGestureHandlerButtonViewManager : var useBorderlessDrawable = false var exclusive = true - var animationDuration: Int = 100 + var tapAnimationDuration: Int = 100 + var pressAndHoldAnimationDuration: Int = -1 + get() = if (field < 0) tapAnimationDuration else field var activeOpacity: Float = 1.0f var defaultOpacity: Float = 1.0f var activeScale: Float = 1.0f @@ -369,6 +377,8 @@ class RNGestureHandlerButtonViewManager : private var receivedKeyEvent = false private var currentAnimator: AnimatorSet? = null private var underlayDrawable: PaintDrawable? = null + private var pressInTimestamp = 0L + private var pendingPressOut: Runnable? = null // When non-null the ripple is drawn in dispatchDraw (above background, below children). // When null the ripple lives on the foreground drawable instead. @@ -487,7 +497,7 @@ class RNGestureHandlerButtonViewManager : underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt() } - private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) { + private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float, durationMs: Long) { val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f val hasScale = activeScale != 1.0f || defaultScale != 1.0f val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null @@ -509,18 +519,41 @@ class RNGestureHandlerButtonViewManager : } currentAnimator = AnimatorSet().apply { playTogether(animators) - duration = animationDuration.toLong() + duration = durationMs interpolator = LinearOutSlowInInterpolator() start() } } private fun animatePressIn() { - animateTo(activeOpacity, activeScale, activeUnderlayOpacity) + pendingPressOut?.let { + handler.removeCallbacks(it) + pendingPressOut = null + } + pressInTimestamp = SystemClock.uptimeMillis() + animateTo(activeOpacity, activeScale, activeUnderlayOpacity, pressAndHoldAnimationDuration.toLong()) } private fun animatePressOut() { - animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity) + pendingPressOut?.let { handler.removeCallbacks(it) } + val elapsed = SystemClock.uptimeMillis() - pressInTimestamp + + if (elapsed >= pressAndHoldAnimationDuration) { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldAnimationDuration.toLong()) + // elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play + } else if (elapsed * 2 >= tapAnimationDuration) { + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed) + } else { + val remaining = tapAnimationDuration - elapsed + animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining) + + val runnable = Runnable { + pendingPressOut = null + animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapAnimationDuration.toLong()) + } + pendingPressOut = runnable + handler.postDelayed(runnable, remaining) + } } private fun createUnderlayDrawable(): PaintDrawable { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 2672d0b37a..839877120a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -27,7 +27,8 @@ @property (nonatomic) BOOL userEnabled; @property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents; -@property (nonatomic, assign) NSInteger animationDuration; +@property (nonatomic, assign) NSInteger pressAndHoldAnimationDuration; +@property (nonatomic, assign) NSInteger tapAnimationDuration; @property (nonatomic, assign) CGFloat activeOpacity; @property (nonatomic, assign) CGFloat defaultOpacity; @property (nonatomic, assign) CGFloat activeScale; diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 078e543056..7b0e5f075c 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -44,15 +44,20 @@ @implementation RNGestureHandlerButton { CALayer *_underlayLayer; CGFloat _underlayCornerRadii[8]; // [tlH, tlV, trH, trV, blH, blV, brH, brV] outer radii in points UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset + NSTimeInterval _pressInTimestamp; + dispatch_block_t _pendingPressOutBlock; } +@synthesize pressAndHoldAnimationDuration = _pressAndHoldAnimationDuration; + - (void)commonInit { _isTouchInsideBounds = NO; _hitTestEdgeInsets = UIEdgeInsetsZero; _userEnabled = YES; _pointerEvents = RNGestureHandlerPointerEventsAuto; - _animationDuration = 100; + _pressAndHoldAnimationDuration = -1; + _tapAnimationDuration = 100; _activeOpacity = 1.0; _defaultOpacity = 1.0; _activeScale = 1.0; @@ -60,6 +65,8 @@ - (void)commonInit _activeUnderlayOpacity = 0.0; _defaultUnderlayOpacity = 0.0; _underlayColor = nil; + _pressInTimestamp = 0; + _pendingPressOutBlock = nil; #if TARGET_OS_OSX self.wantsLayer = YES; // Crucial for macOS layer-backing #endif @@ -96,6 +103,11 @@ - (instancetype)initWithFrame:(CGRect)frame return self; } +- (NSInteger)pressAndHoldAnimationDuration +{ + return _pressAndHoldAnimationDuration < 0 ? _tapAnimationDuration : _pressAndHoldAnimationDuration; +} + - (void)setUnderlayColor:(RNGHColor *)underlayColor { _underlayColor = underlayColor; @@ -149,12 +161,15 @@ - (BOOL)shouldHandleTouch:(RNGHUIView *)view #endif } -- (void)animateUnderlayToOpacity:(float)toOpacity +- (void)animateUnderlayToOpacity:(float)toOpacity duration:(NSTimeInterval)durationMs { + _underlayLayer.opacity = [_underlayLayer.presentationLayer opacity]; + [_underlayLayer removeAllAnimations]; + CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"opacity"]; - anim.fromValue = @([_underlayLayer.presentationLayer opacity]); + anim.fromValue = @(_underlayLayer.opacity); anim.toValue = @(toOpacity); - anim.duration = _animationDuration / 1000.0; + anim.duration = durationMs / 1000.0; anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut]; _underlayLayer.opacity = toOpacity; [_underlayLayer addAnimation:anim forKey:@"opacity"]; @@ -199,14 +214,21 @@ - (void)applyStartAnimationState #endif } -- (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGFloat)scale +- (void)animateTarget:(RNGHUIView *)target + toOpacity:(CGFloat)opacity + scale:(CGFloat)scale + duration:(NSTimeInterval)durationMs { - NSTimeInterval duration = _animationDuration / 1000.0; + target.layer.transform = target.layer.presentationLayer.transform; + NSTimeInterval duration = durationMs / 1000.0; #if !TARGET_OS_OSX + target.alpha = target.layer.presentationLayer.opacity; + [target.layer removeAllAnimations]; + [UIView animateWithDuration:duration delay:0 - options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState + options:UIViewAnimationOptionCurveEaseInOut animations:^{ if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) { target.alpha = opacity; @@ -218,6 +240,9 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF completion:nil]; #else target.wantsLayer = YES; + target.alphaValue = target.layer.presentationLayer.opacity; + [target.layer removeAllAnimations]; + [NSAnimationContext runAnimationGroup:^(NSAnimationContext *context) { context.allowsImplicitAnimation = YES; @@ -236,19 +261,72 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF - (void)handleAnimatePressIn { + if (_pendingPressOutBlock) { + dispatch_block_cancel(_pendingPressOutBlock); + _pendingPressOutBlock = nil; + } + _pressInTimestamp = CACurrentMediaTime(); RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale]; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:self.pressAndHoldAnimationDuration]; if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_activeUnderlayOpacity]; + [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:self.pressAndHoldAnimationDuration]; } } - (void)handleAnimatePressOut { - RNGHUIView *target = self.animationTarget ?: self; - [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale]; - if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { - [self animateUnderlayToOpacity:_defaultUnderlayOpacity]; + if (_pendingPressOutBlock) { + dispatch_block_cancel(_pendingPressOutBlock); + } + + NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0; + NSInteger pressAndHoldAnimationDuration = self.pressAndHoldAnimationDuration; + + if (elapsed >= pressAndHoldAnimationDuration) { + // Press-in animation fully finished, animate out in pressAndHoldAnimationDuration + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:pressAndHoldAnimationDuration]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:pressAndHoldAnimationDuration]; + } + // elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play + } else if (elapsed * 2 >= _tapAnimationDuration) { + // Past minimum but press-in animation still playing, animate out in elapsed time + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:elapsed]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:elapsed]; + } + } else { + // Before minimum duration, finish press-in in remaining time then animate out in minDuration + NSTimeInterval remaining = _tapAnimationDuration - elapsed; + + RNGHUIView *target = self.animationTarget ?: self; + [self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:remaining]; + if (_activeUnderlayOpacity != _defaultUnderlayOpacity) { + [self animateUnderlayToOpacity:_activeUnderlayOpacity duration:remaining]; + } + + __weak auto weakSelf = self; + _pendingPressOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{ + __strong auto strongSelf = weakSelf; + if (strongSelf) { + strongSelf->_pendingPressOutBlock = nil; + RNGHUIView *target = strongSelf.animationTarget ?: strongSelf; + [strongSelf animateTarget:target + toOpacity:strongSelf->_defaultOpacity + scale:strongSelf->_defaultScale + duration:strongSelf->_tapAnimationDuration]; + if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) { + [strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity + duration:strongSelf->_tapAnimationDuration]; + } + } + }); + dispatch_after( + dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)), + dispatch_get_main_queue(), + _pendingPressOutBlock); } } diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index e840cfb296..effc841a03 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -237,7 +237,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & const auto &newProps = *std::static_pointer_cast(props); _buttonView.userEnabled = newProps.enabled; - _buttonView.animationDuration = newProps.animationDuration; + _buttonView.pressAndHoldAnimationDuration = newProps.pressAndHoldAnimationDuration; + _buttonView.tapAnimationDuration = newProps.tapAnimationDuration; _buttonView.activeOpacity = newProps.activeOpacity; _buttonView.defaultOpacity = newProps.defaultOpacity; _buttonView.activeScale = newProps.activeScale; diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx index 7e13963721..8b50a4bba1 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx @@ -59,9 +59,18 @@ export interface ButtonProps extends ViewProps, AccessibilityProps { touchSoundDisabled?: boolean | undefined; /** - * Duration of the animation when the button is pressed. + * Duration of the press-in animation when the button is held down, in + * milliseconds. Defaults to `tapAnimationDuration` when not set (or set + * to -1). */ - animationDuration?: number | undefined; + pressAndHoldAnimationDuration?: number | undefined; + + /** + * Minimum duration (in milliseconds) that the press animation must run + * before the press-out animation is allowed to start. Ensures the pressed + * state is visible on quick taps. Defaults to 100ms. + */ + tapAnimationDuration?: number | undefined; /** * Opacity applied to the button when it is pressed. diff --git a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx index 7ba5d1a69f..b857459274 100644 --- a/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx +++ b/packages/react-native-gesture-handler/src/components/GestureHandlerButton.web.tsx @@ -4,7 +4,8 @@ import { ColorValue, View, ViewProps } from 'react-native'; type ButtonProps = ViewProps & { ref?: React.Ref>; enabled?: boolean; - animationDuration?: number; + pressAndHoldAnimationDuration?: number; + tapAnimationDuration?: number; activeOpacity?: number; activeScale?: number; activeUnderlayOpacity?: number; @@ -16,7 +17,8 @@ type ButtonProps = ViewProps & { export const ButtonComponent = ({ enabled = true, - animationDuration = 100, + pressAndHoldAnimationDuration: pressAndHoldAnimationDurationProp = -1, + tapAnimationDuration = 100, activeOpacity = 1, activeScale = 1, activeUnderlayOpacity = 0, @@ -28,17 +30,63 @@ export const ButtonComponent = ({ children, ...rest }: ButtonProps) => { + const pressAndHoldAnimationDuration = + pressAndHoldAnimationDurationProp < 0 + ? tapAnimationDuration + : pressAndHoldAnimationDurationProp; + const [pressed, setPressed] = React.useState(false); + const [currentDuration, setCurrentDuration] = React.useState( + pressAndHoldAnimationDuration + ); + const pressInTimestamp = React.useRef(0); + const pressOutTimer = React.useRef | null>( + null + ); + + React.useEffect(() => { + return () => { + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + } + }; + }, []); const pressIn = React.useCallback(() => { if (enabled) { + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + pressOutTimer.current = null; + } + pressInTimestamp.current = performance.now(); + setCurrentDuration(pressAndHoldAnimationDuration); setPressed(true); } - }, [enabled]); + }, [enabled, pressAndHoldAnimationDuration]); const pressOut = React.useCallback(() => { - setPressed(false); - }, []); + if (pressOutTimer.current != null) { + clearTimeout(pressOutTimer.current); + } + const elapsed = performance.now() - pressInTimestamp.current; + + if (elapsed >= pressAndHoldAnimationDuration) { + setCurrentDuration(pressAndHoldAnimationDuration); + setPressed(false); + // elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play + } else if (elapsed * 2 >= tapAnimationDuration) { + setCurrentDuration(elapsed); + setPressed(false); + } else { + // Let the in-progress CSS press-in transition continue; schedule press-out after remaining time + const remaining = tapAnimationDuration - elapsed; + pressOutTimer.current = setTimeout(() => { + pressOutTimer.current = null; + setCurrentDuration(tapAnimationDuration); + setPressed(false); + }, remaining); + } + }, [pressAndHoldAnimationDuration, tapAnimationDuration]); const currentUnderlayOpacity = pressed ? activeUnderlayOpacity @@ -49,13 +97,13 @@ export const ButtonComponent = ({ const hasScale = activeScale !== 1 || defaultScale !== 1; const currentScale = pressed ? activeScale : defaultScale; - const easing = 'cubic-bezier(0, 0, 0.2, 1)'; + const easing = 'cubic-bezier(0.5, 1, 0.89, 1)'; const transitionProps: string[] = []; if (hasOpacity) { - transitionProps.push(`opacity ${animationDuration}ms ${easing}`); + transitionProps.push(`opacity ${currentDuration}ms ${easing}`); } if (hasScale) { - transitionProps.push(`transform ${animationDuration}ms ${easing}`); + transitionProps.push(`transform ${currentDuration}ms ${easing}`); } const transition = transitionProps.join(', '); @@ -89,7 +137,7 @@ export const ButtonComponent = ({ backgroundColor: underlayColor as string, opacity: currentUnderlayOpacity, // @ts-ignore - web-only CSS properties - transition: `opacity ${animationDuration}ms ${easing}`, + transition: `opacity ${currentDuration}ms ${easing}`, pointerEvents: 'none', }} /> diff --git a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts index 6ab8bcaca7..bd65658e24 100644 --- a/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts +++ b/packages/react-native-gesture-handler/src/specs/RNGestureHandlerButtonNativeComponent.ts @@ -19,7 +19,8 @@ interface NativeProps extends ViewProps { 'box-none' | 'none' | 'box-only' | 'auto', 'auto' >; - animationDuration?: WithDefault; + pressAndHoldAnimationDuration?: WithDefault; + tapAnimationDuration?: WithDefault; activeOpacity?: WithDefault; activeScale?: WithDefault; activeUnderlayOpacity?: WithDefault;