Skip to content

Commit d21f1dd

Browse files
authored
[General] Update button's animation duration props (#4046)
## Description Native platforms delay touches when the button is inside a ScrollView. This may cause situations where press-out is triggered immediately after press-in, effectively running with no animation. This PR: - changes the easing on web to better align with native platforms - changes `animationDuration` to `pressAndHoldAnimationDuration` - adds `tapAnimationDuration` `pressAndHoldAnimationDuration` defaults to `tapAnimationDuration` when unspecified; `tapAnimationDuration` defaults to 100ms. Press out handler now has three branches: 1. pressDuration >= `pressAndHoldAnimationDuration` -> press out animation is started immediately with the duration of `pressAndHoldAnimationDuration` 2. pressDuration * 2 in [`tapAnimationDuration`, `pressAndHoldAnimationDuration`] (*2 so there's at least half of `tapAnimationDuration` for the remaining animation) -> press out animation is started immediately with the duration of the press so far 3. pressDuration < `tapAnimationDuration / 2` -> pressIn animation is played fully in the remaining time in `tapAnimationDuration`, and pressOut is scheduled after with the duration of `tapAnimationDuration` ## Test plan Updated the button underlay example
1 parent 72f2d3e commit d21f1dd

8 files changed

Lines changed: 254 additions & 37 deletions

File tree

apps/common-app/src/new_api/components/button_underlay/index.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,10 @@ import {
88

99
const UNDERLAY_PROPS = {
1010
underlayColor: 'red',
11-
activeUnderlayOpacity: 0.5,
12-
animationDuration: 200,
11+
activeUnderlayOpacity: 0.2,
12+
activeScale: 0.9,
13+
pressAndHoldAnimationDuration: 200,
14+
tapAnimationDuration: 100,
1315
rippleColor: 'transparent',
1416
} as const;
1517

packages/react-native-gesture-handler/android/src/main/java/com/swmansion/gesturehandler/react/RNGestureHandlerButtonViewManager.kt

Lines changed: 51 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import android.graphics.drawable.RippleDrawable
1515
import android.graphics.drawable.ShapeDrawable
1616
import android.graphics.drawable.shapes.RectShape
1717
import android.os.Build
18+
import android.os.SystemClock
1819
import android.util.TypedValue
1920
import android.view.KeyEvent
2021
import android.view.MotionEvent
@@ -266,9 +267,14 @@ class RNGestureHandlerButtonViewManager :
266267
view.isSoundEffectsEnabled = !touchSoundDisabled
267268
}
268269

269-
@ReactProp(name = "animationDuration")
270-
override fun setAnimationDuration(view: ButtonViewGroup, animationDuration: Int) {
271-
view.animationDuration = animationDuration
270+
@ReactProp(name = "pressAndHoldAnimationDuration")
271+
override fun setPressAndHoldAnimationDuration(view: ButtonViewGroup, pressAndHoldAnimationDuration: Int) {
272+
view.pressAndHoldAnimationDuration = pressAndHoldAnimationDuration
273+
}
274+
275+
@ReactProp(name = "tapAnimationDuration")
276+
override fun setTapAnimationDuration(view: ButtonViewGroup, tapAnimationDuration: Int) {
277+
view.tapAnimationDuration = if (tapAnimationDuration > 0) tapAnimationDuration else 0
272278
}
273279

274280
@ReactProp(name = "defaultOpacity")
@@ -346,7 +352,9 @@ class RNGestureHandlerButtonViewManager :
346352
var useBorderlessDrawable = false
347353

348354
var exclusive = true
349-
var animationDuration: Int = 100
355+
var tapAnimationDuration: Int = 100
356+
var pressAndHoldAnimationDuration: Int = -1
357+
get() = if (field < 0) tapAnimationDuration else field
350358
var activeOpacity: Float = 1.0f
351359
var defaultOpacity: Float = 1.0f
352360
var activeScale: Float = 1.0f
@@ -369,6 +377,8 @@ class RNGestureHandlerButtonViewManager :
369377
private var receivedKeyEvent = false
370378
private var currentAnimator: AnimatorSet? = null
371379
private var underlayDrawable: PaintDrawable? = null
380+
private var pressInTimestamp = 0L
381+
private var pendingPressOut: Runnable? = null
372382

373383
// When non-null the ripple is drawn in dispatchDraw (above background, below children).
374384
// When null the ripple lives on the foreground drawable instead.
@@ -487,7 +497,7 @@ class RNGestureHandlerButtonViewManager :
487497
underlayDrawable?.alpha = (defaultUnderlayOpacity * 255).toInt()
488498
}
489499

490-
private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float) {
500+
private fun animateTo(opacity: Float, scale: Float, underlayOpacity: Float, durationMs: Long) {
491501
val hasOpacity = activeOpacity != 1.0f || defaultOpacity != 1.0f
492502
val hasScale = activeScale != 1.0f || defaultScale != 1.0f
493503
val hasUnderlay = activeUnderlayOpacity != defaultUnderlayOpacity && underlayDrawable != null
@@ -509,18 +519,43 @@ class RNGestureHandlerButtonViewManager :
509519
}
510520
currentAnimator = AnimatorSet().apply {
511521
playTogether(animators)
512-
duration = animationDuration.toLong()
522+
duration = durationMs
513523
interpolator = LinearOutSlowInInterpolator()
514524
start()
515525
}
516526
}
517527

518528
private fun animatePressIn() {
519-
animateTo(activeOpacity, activeScale, activeUnderlayOpacity)
529+
pendingPressOut?.let {
530+
handler.removeCallbacks(it)
531+
pendingPressOut = null
532+
}
533+
pressInTimestamp = SystemClock.uptimeMillis()
534+
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, pressAndHoldAnimationDuration.toLong())
520535
}
521536

522537
private fun animatePressOut() {
523-
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity)
538+
pendingPressOut?.let { handler.removeCallbacks(it) }
539+
val pressAndHoldMs = pressAndHoldAnimationDuration.toLong()
540+
val tapMs = tapAnimationDuration.toLong()
541+
val elapsed = SystemClock.uptimeMillis() - pressInTimestamp
542+
543+
if (elapsed >= pressAndHoldMs) {
544+
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, pressAndHoldMs)
545+
// elapsed * 2 to ensure there is at least half of the tapAnimationDuration left for the animation to play
546+
} else if (elapsed * 2 >= tapMs) {
547+
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, elapsed)
548+
} else {
549+
val remaining = tapMs - elapsed
550+
animateTo(activeOpacity, activeScale, activeUnderlayOpacity, remaining)
551+
552+
val runnable = Runnable {
553+
pendingPressOut = null
554+
animateTo(defaultOpacity, defaultScale, defaultUnderlayOpacity, tapMs)
555+
}
556+
pendingPressOut = runnable
557+
handler.postDelayed(runnable, remaining)
558+
}
524559
}
525560

526561
private fun createUnderlayDrawable(): PaintDrawable {
@@ -630,6 +665,14 @@ class RNGestureHandlerButtonViewManager :
630665
return drawable
631666
}
632667

668+
override fun onDetachedFromWindow() {
669+
super.onDetachedFromWindow()
670+
pendingPressOut?.let { handler.removeCallbacks(it) }
671+
pendingPressOut = null
672+
currentAnimator?.cancel()
673+
currentAnimator = null
674+
}
675+
633676
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
634677
super.onSizeChanged(w, h, oldw, oldh)
635678
needBackgroundUpdate = true

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@
2727
@property (nonatomic) BOOL userEnabled;
2828
@property (nonatomic, assign) RNGestureHandlerPointerEvents pointerEvents;
2929

30-
@property (nonatomic, assign) NSInteger animationDuration;
30+
@property (nonatomic, assign) NSInteger pressAndHoldAnimationDuration;
31+
@property (nonatomic, assign) NSInteger tapAnimationDuration;
3132
@property (nonatomic, assign) CGFloat activeOpacity;
3233
@property (nonatomic, assign) CGFloat defaultOpacity;
3334
@property (nonatomic, assign) CGFloat activeScale;

packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm

Lines changed: 122 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -44,22 +44,29 @@ @implementation RNGestureHandlerButton {
4444
CALayer *_underlayLayer;
4545
CGFloat _underlayCornerRadii[8]; // [tlH, tlV, trH, trV, blH, blV, brH, brV] outer radii in points
4646
UIEdgeInsets _underlayBorderInsets; // border widths for padding-box inset
47+
NSTimeInterval _pressInTimestamp;
48+
dispatch_block_t _pendingPressOutBlock;
4749
}
4850

51+
@synthesize pressAndHoldAnimationDuration = _pressAndHoldAnimationDuration;
52+
4953
- (void)commonInit
5054
{
5155
_isTouchInsideBounds = NO;
5256
_hitTestEdgeInsets = UIEdgeInsetsZero;
5357
_userEnabled = YES;
5458
_pointerEvents = RNGestureHandlerPointerEventsAuto;
55-
_animationDuration = 100;
59+
_pressAndHoldAnimationDuration = -1;
60+
_tapAnimationDuration = 100;
5661
_activeOpacity = 1.0;
5762
_defaultOpacity = 1.0;
5863
_activeScale = 1.0;
5964
_defaultScale = 1.0;
6065
_activeUnderlayOpacity = 0.0;
6166
_defaultUnderlayOpacity = 0.0;
6267
_underlayColor = nil;
68+
_pressInTimestamp = 0;
69+
_pendingPressOutBlock = nil;
6370
#if TARGET_OS_OSX
6471
self.wantsLayer = YES; // Crucial for macOS layer-backing
6572
#endif
@@ -96,6 +103,40 @@ - (instancetype)initWithFrame:(CGRect)frame
96103
return self;
97104
}
98105

106+
- (void)cancelPendingPressOutAnimation
107+
{
108+
if (_pendingPressOutBlock) {
109+
dispatch_block_cancel(_pendingPressOutBlock);
110+
_pendingPressOutBlock = nil;
111+
}
112+
RNGHUIView *target = self.animationTarget ?: self;
113+
[target.layer removeAllAnimations];
114+
[_underlayLayer removeAllAnimations];
115+
}
116+
117+
#if TARGET_OS_OSX
118+
- (void)viewWillMoveToWindow:(RNGHWindow *)newWindow
119+
{
120+
[super viewWillMoveToWindow:newWindow];
121+
if (newWindow == nil) {
122+
[self cancelPendingPressOutAnimation];
123+
}
124+
}
125+
#else
126+
- (void)willMoveToWindow:(RNGHWindow *)newWindow
127+
{
128+
[super willMoveToWindow:newWindow];
129+
if (newWindow == nil) {
130+
[self cancelPendingPressOutAnimation];
131+
}
132+
}
133+
#endif
134+
135+
- (NSInteger)pressAndHoldAnimationDuration
136+
{
137+
return _pressAndHoldAnimationDuration < 0 ? _tapAnimationDuration : _pressAndHoldAnimationDuration;
138+
}
139+
99140
- (void)setUnderlayColor:(RNGHColor *)underlayColor
100141
{
101142
_underlayColor = underlayColor;
@@ -149,12 +190,16 @@ - (BOOL)shouldHandleTouch:(RNGHUIView *)view
149190
#endif
150191
}
151192

152-
- (void)animateUnderlayToOpacity:(float)toOpacity
193+
- (void)animateUnderlayToOpacity:(float)toOpacity duration:(NSTimeInterval)durationMs
153194
{
195+
_underlayLayer.opacity =
196+
_underlayLayer.presentationLayer ? [_underlayLayer.presentationLayer opacity] : _underlayLayer.opacity;
197+
[_underlayLayer removeAllAnimations];
198+
154199
CABasicAnimation *anim = [CABasicAnimation animationWithKeyPath:@"opacity"];
155-
anim.fromValue = @([_underlayLayer.presentationLayer opacity]);
200+
anim.fromValue = @(_underlayLayer.opacity);
156201
anim.toValue = @(toOpacity);
157-
anim.duration = _animationDuration / 1000.0;
202+
anim.duration = durationMs / 1000.0;
158203
anim.timingFunction = [CAMediaTimingFunction functionWithName:kCAMediaTimingFunctionEaseInEaseOut];
159204
_underlayLayer.opacity = toOpacity;
160205
[_underlayLayer addAnimation:anim forKey:@"opacity"];
@@ -199,14 +244,22 @@ - (void)applyStartAnimationState
199244
#endif
200245
}
201246

202-
- (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGFloat)scale
247+
- (void)animateTarget:(RNGHUIView *)target
248+
toOpacity:(CGFloat)opacity
249+
scale:(CGFloat)scale
250+
duration:(NSTimeInterval)durationMs
203251
{
204-
NSTimeInterval duration = _animationDuration / 1000.0;
252+
target.layer.transform =
253+
target.layer.presentationLayer ? target.layer.presentationLayer.transform : target.layer.transform;
254+
NSTimeInterval duration = durationMs / 1000.0;
205255

206256
#if !TARGET_OS_OSX
257+
target.alpha = target.layer.presentationLayer ? target.layer.presentationLayer.opacity : target.alpha;
258+
[target.layer removeAllAnimations];
259+
207260
[UIView animateWithDuration:duration
208261
delay:0
209-
options:UIViewAnimationOptionCurveEaseInOut | UIViewAnimationOptionBeginFromCurrentState
262+
options:UIViewAnimationOptionCurveEaseInOut
210263
animations:^{
211264
if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) {
212265
target.alpha = opacity;
@@ -218,6 +271,9 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF
218271
completion:nil];
219272
#else
220273
target.wantsLayer = YES;
274+
target.alphaValue = target.layer.presentationLayer ? target.layer.presentationLayer.opacity : target.alphaValue;
275+
[target.layer removeAllAnimations];
276+
221277
[NSAnimationContext
222278
runAnimationGroup:^(NSAnimationContext *context) {
223279
context.allowsImplicitAnimation = YES;
@@ -236,19 +292,72 @@ - (void)animateTarget:(RNGHUIView *)target toOpacity:(CGFloat)opacity scale:(CGF
236292

237293
- (void)handleAnimatePressIn
238294
{
295+
if (_pendingPressOutBlock) {
296+
dispatch_block_cancel(_pendingPressOutBlock);
297+
_pendingPressOutBlock = nil;
298+
}
299+
_pressInTimestamp = CACurrentMediaTime();
239300
RNGHUIView *target = self.animationTarget ?: self;
240-
[self animateTarget:target toOpacity:_activeOpacity scale:_activeScale];
301+
[self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:self.pressAndHoldAnimationDuration];
241302
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
242-
[self animateUnderlayToOpacity:_activeUnderlayOpacity];
303+
[self animateUnderlayToOpacity:_activeUnderlayOpacity duration:self.pressAndHoldAnimationDuration];
243304
}
244305
}
245306

246307
- (void)handleAnimatePressOut
247308
{
248-
RNGHUIView *target = self.animationTarget ?: self;
249-
[self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale];
250-
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
251-
[self animateUnderlayToOpacity:_defaultUnderlayOpacity];
309+
if (_pendingPressOutBlock) {
310+
dispatch_block_cancel(_pendingPressOutBlock);
311+
}
312+
313+
NSTimeInterval elapsed = (CACurrentMediaTime() - _pressInTimestamp) * 1000.0;
314+
NSInteger pressAndHoldAnimationDuration = self.pressAndHoldAnimationDuration;
315+
316+
if (elapsed >= pressAndHoldAnimationDuration) {
317+
// Press-in animation fully finished, animate out in pressAndHoldAnimationDuration
318+
RNGHUIView *target = self.animationTarget ?: self;
319+
[self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:pressAndHoldAnimationDuration];
320+
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
321+
[self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:pressAndHoldAnimationDuration];
322+
}
323+
// elapsed * 2 to ensure there is at least half of the minDuration left for the animation to play
324+
} else if (elapsed * 2 >= _tapAnimationDuration) {
325+
// Past minimum but press-in animation still playing, animate out in elapsed time
326+
RNGHUIView *target = self.animationTarget ?: self;
327+
[self animateTarget:target toOpacity:_defaultOpacity scale:_defaultScale duration:elapsed];
328+
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
329+
[self animateUnderlayToOpacity:_defaultUnderlayOpacity duration:elapsed];
330+
}
331+
} else {
332+
// Before minimum duration, finish press-in in remaining time then animate out in minDuration
333+
NSTimeInterval remaining = _tapAnimationDuration - elapsed;
334+
335+
RNGHUIView *target = self.animationTarget ?: self;
336+
[self animateTarget:target toOpacity:_activeOpacity scale:_activeScale duration:remaining];
337+
if (_activeUnderlayOpacity != _defaultUnderlayOpacity) {
338+
[self animateUnderlayToOpacity:_activeUnderlayOpacity duration:remaining];
339+
}
340+
341+
__weak auto weakSelf = self;
342+
_pendingPressOutBlock = dispatch_block_create(DISPATCH_BLOCK_ASSIGN_CURRENT, ^{
343+
__strong auto strongSelf = weakSelf;
344+
if (strongSelf) {
345+
strongSelf->_pendingPressOutBlock = nil;
346+
RNGHUIView *target = strongSelf.animationTarget ?: strongSelf;
347+
[strongSelf animateTarget:target
348+
toOpacity:strongSelf->_defaultOpacity
349+
scale:strongSelf->_defaultScale
350+
duration:strongSelf->_tapAnimationDuration];
351+
if (strongSelf->_activeUnderlayOpacity != strongSelf->_defaultUnderlayOpacity) {
352+
[strongSelf animateUnderlayToOpacity:strongSelf->_defaultUnderlayOpacity
353+
duration:strongSelf->_tapAnimationDuration];
354+
}
355+
}
356+
});
357+
dispatch_after(
358+
dispatch_time(DISPATCH_TIME_NOW, (int64_t)(remaining * NSEC_PER_MSEC)),
359+
dispatch_get_main_queue(),
360+
_pendingPressOutBlock);
252361
}
253362
}
254363

packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,8 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
241241
const auto &newProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(props);
242242

243243
_buttonView.userEnabled = newProps.enabled;
244-
_buttonView.animationDuration = newProps.animationDuration;
244+
_buttonView.pressAndHoldAnimationDuration = newProps.pressAndHoldAnimationDuration;
245+
_buttonView.tapAnimationDuration = newProps.tapAnimationDuration > 0 ? newProps.tapAnimationDuration : 0;
245246
_buttonView.activeOpacity = newProps.activeOpacity;
246247
_buttonView.defaultOpacity = newProps.defaultOpacity;
247248
_buttonView.activeScale = newProps.activeScale;

packages/react-native-gesture-handler/src/components/GestureHandlerButton.tsx

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -59,9 +59,18 @@ export interface ButtonProps extends ViewProps, AccessibilityProps {
5959
touchSoundDisabled?: boolean | undefined;
6060

6161
/**
62-
* Duration of the animation when the button is pressed.
62+
* Duration of the press-in animation when the button is held down, in
63+
* milliseconds. Defaults to `tapAnimationDuration` when not set (or set
64+
* to any negative value).
6365
*/
64-
animationDuration?: number | undefined;
66+
pressAndHoldAnimationDuration?: number | undefined;
67+
68+
/**
69+
* Minimum duration (in milliseconds) that the press animation must run
70+
* before the press-out animation is allowed to start. Ensures the pressed
71+
* state is visible on quick taps. Defaults to 100ms.
72+
*/
73+
tapAnimationDuration?: number | undefined;
6574

6675
/**
6776
* Opacity applied to the button when it is pressed.

0 commit comments

Comments
 (0)