Skip to content

Commit 38025e7

Browse files
authored
[Native] Snap Touchable state on sub-frame animations (#4181)
## Description Updates how `Touchable` handles animations: - On Android, when the animation would take less than a single frame, apply the values immediately. `ObjectAnimator` would run the animation on the next frame, and if a new animation was started immediately after (like a quick tap in a scroll view), it would read the "resting" state instead of the one just applied. - On iOS, do the same thing and read values from the presentation layer only when an animation is running (similarly to Android, the animation would start on the next frame). - On Android, it also changes `LinearOutSlowInInterpolator` to `FastOutSlowInInterpolator` to better match the animation curve from web and iOS ## Test plan |-|Before|After| |-|-|-| |Android|<video src="https://github.com/user-attachments/assets/be32d3e3-8e6a-4697-8c53-5f727c7cb6d4" />|<video src="https://github.com/user-attachments/assets/4b025d79-3caf-42da-b317-f13f56c83927" />| |iOS|<video src="https://github.com/user-attachments/assets/dc6199db-a3e8-4c3d-ab41-8ec90d0ed829" />|<video src="https://github.com/user-attachments/assets/9ad97e93-c66f-4146-9f3c-114c9e381130" />| (Yes, iOS before is being pressed on constantly, it's just animating default -> default) ```jsx import * as React from 'react'; import { StyleSheet } from 'react-native'; import { ScrollView, Touchable } from 'react-native-gesture-handler'; export default function TapToLikeExample() { return ( <ScrollView style={styles.container} contentContainerStyle={{ flexGrow: 1 }}> <Touchable style={{ width: 200, height: 40, backgroundColor: 'red' }} activeOpacity={0.2} animationDuration={{ in: 0, out: 250 }}></Touchable> </ScrollView> ); } const styles = StyleSheet.create({ container: { gap: 24, padding: 24, }, }); ```
1 parent f2159b8 commit 38025e7

3 files changed

Lines changed: 109 additions & 9 deletions

File tree

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

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package com.swmansion.gesturehandler.react
22

33
import android.content.Context
4+
import android.view.Display
45
import android.view.MotionEvent
56
import android.view.accessibility.AccessibilityManager
67
import com.facebook.react.bridge.ReactContext
@@ -15,3 +16,23 @@ fun Context.isScreenReaderOn() =
1516
fun MotionEvent.isHoverAction(): Boolean = action == MotionEvent.ACTION_HOVER_MOVE ||
1617
action == MotionEvent.ACTION_HOVER_ENTER ||
1718
action == MotionEvent.ACTION_HOVER_EXIT
19+
20+
val Display.minimumFrameTime: Float
21+
get() {
22+
val supportedModes = this.supportedModes
23+
var maxRefreshRate = 0f
24+
25+
supportedModes.forEach { mode ->
26+
if (mode.refreshRate > maxRefreshRate) {
27+
maxRefreshRate = mode.refreshRate
28+
}
29+
}
30+
31+
val effectiveRefreshRate = when {
32+
maxRefreshRate > 0f -> maxRefreshRate
33+
refreshRate > 0f -> refreshRate
34+
else -> 60f
35+
}
36+
37+
return 1000.0f / effectiveRefreshRate
38+
}

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

Lines changed: 24 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,7 @@ import android.view.View
2323
import android.view.ViewGroup
2424
import android.view.accessibility.AccessibilityNodeInfo
2525
import androidx.core.view.children
26-
import androidx.interpolator.view.animation.LinearOutSlowInInterpolator
26+
import androidx.interpolator.view.animation.FastOutSlowInInterpolator
2727
import com.facebook.react.R
2828
import com.facebook.react.module.annotations.ReactModule
2929
import com.facebook.react.uimanager.BackgroundStyleApplicator
@@ -546,6 +546,28 @@ class RNGestureHandlerButtonViewManager :
546546
}
547547

548548
currentAnimator?.cancel()
549+
currentAnimator = null
550+
551+
// Sub-frame durations: snap directly. ObjectAnimator with duration 0
552+
// still defers its property write to the next frame callback, so if a
553+
// follow-up animateTo() cancels it in the same frame the property never
554+
// lands on its target and the next animator captures a stale starting
555+
// value (e.g. an instant press-in followed by press-out in the same
556+
// frame, leaving the press-out to animate default → default).
557+
if (durationMs < (display?.minimumFrameTime ?: 16f)) {
558+
if (hasOpacity) {
559+
alpha = opacity
560+
}
561+
if (hasScale) {
562+
scaleX = scale
563+
scaleY = scale
564+
}
565+
if (hasUnderlay) {
566+
underlayDrawable!!.alpha = (underlayOpacity * 255).toInt()
567+
}
568+
return
569+
}
570+
549571
val animators = ArrayList<Animator>()
550572
if (hasOpacity) {
551573
animators.add(ObjectAnimator.ofFloat(this, "alpha", opacity))
@@ -560,7 +582,7 @@ class RNGestureHandlerButtonViewManager :
560582
currentAnimator = AnimatorSet().apply {
561583
playTogether(animators)
562584
duration = durationMs
563-
interpolator = LinearOutSlowInInterpolator()
585+
interpolator = FastOutSlowInInterpolator()
564586
start()
565587
}
566588
}

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

Lines changed: 64 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -258,19 +258,60 @@ - (void)applyStartAnimationState
258258
#endif
259259
}
260260

261+
// Duration of a single frame at the current screen's max refresh rate, in ms.
262+
- (NSTimeInterval)minFrameDurationMs
263+
{
264+
#if !TARGET_OS_OSX
265+
UIScreen *screen = self.window.screen ?: UIScreen.mainScreen;
266+
NSInteger maxFps = screen.maximumFramesPerSecond;
267+
#else
268+
NSScreen *screen = self.window.screen ?: NSScreen.mainScreen;
269+
NSInteger maxFps = 60;
270+
if (@available(macOS 12.0, *)) {
271+
maxFps = screen.maximumFramesPerSecond;
272+
}
273+
#endif
274+
return maxFps > 0 ? 1000.0 / (NSTimeInterval)maxFps : 1000.0 / 60.0;
275+
}
276+
261277
- (void)animateTarget:(RNGHUIView *)target
262278
toOpacity:(CGFloat)opacity
263279
scale:(CGFloat)scale
264280
duration:(NSTimeInterval)durationMs
265281
{
266-
target.layer.transform =
267-
target.layer.presentationLayer ? target.layer.presentationLayer.transform : target.layer.transform;
268-
NSTimeInterval duration = durationMs / 1000.0;
282+
CALayer *layer = target.layer;
283+
CALayer *presentation = layer.presentationLayer;
284+
NSTimeInterval snapThresholdMs = [self minFrameDurationMs];
285+
286+
// Only snap to the presentation layer when an animation is in flight,
287+
// that's the only case where it tells us something the model layer doesn't.
288+
BOOL hasInFlightAnimation = presentation != nil && layer.animationKeys.count > 0;
289+
if (hasInFlightAnimation) {
290+
layer.transform = presentation.transform;
291+
}
269292

270293
#if !TARGET_OS_OSX
271-
target.alpha = target.layer.presentationLayer ? target.layer.presentationLayer.opacity : target.alpha;
272-
[target.layer removeAllAnimations];
294+
if (hasInFlightAnimation) {
295+
target.alpha = presentation.opacity;
296+
}
297+
[layer removeAllAnimations];
298+
299+
// Sub-frame durations: snap with implicit actions disabled instead of
300+
// routing through UIView.animate. Same rationale as animateUnderlayToOpacity.
301+
if (durationMs < snapThresholdMs) {
302+
[CATransaction begin];
303+
[CATransaction setDisableActions:YES];
304+
if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) {
305+
target.alpha = opacity;
306+
}
307+
if (_activeScale != 1.0 || _defaultScale != 1.0) {
308+
layer.transform = CATransform3DMakeScale(scale, scale, 1.0);
309+
}
310+
[CATransaction commit];
311+
return;
312+
}
273313

314+
NSTimeInterval duration = durationMs / 1000.0;
274315
[UIView animateWithDuration:duration
275316
delay:0
276317
options:UIViewAnimationOptionCurveEaseInOut
@@ -285,9 +326,25 @@ - (void)animateTarget:(RNGHUIView *)target
285326
completion:nil];
286327
#else
287328
target.wantsLayer = YES;
288-
target.alphaValue = target.layer.presentationLayer ? target.layer.presentationLayer.opacity : target.alphaValue;
289-
[target.layer removeAllAnimations];
329+
if (hasInFlightAnimation) {
330+
target.alphaValue = presentation.opacity;
331+
}
332+
[layer removeAllAnimations];
333+
334+
if (durationMs < snapThresholdMs) {
335+
[CATransaction begin];
336+
[CATransaction setDisableActions:YES];
337+
if (_activeOpacity != 1.0 || _defaultOpacity != 1.0) {
338+
target.alphaValue = opacity;
339+
}
340+
if (_activeScale != 1.0 || _defaultScale != 1.0) {
341+
layer.transform = RNGHCenterScaleTransform(target.bounds, scale);
342+
}
343+
[CATransaction commit];
344+
return;
345+
}
290346

347+
NSTimeInterval duration = durationMs / 1000.0;
291348
[NSAnimationContext
292349
runAnimationGroup:^(NSAnimationContext *context) {
293350
context.allowsImplicitAnimation = YES;

0 commit comments

Comments
 (0)