Skip to content

Commit f071853

Browse files
authored
[iOS] Fix Touchable animation ending in the wrong state (#4099)
## Description When the layout of the touchable changed during the animation, it resulted in the end frame of the view being wrong. This PR updates the button behavior in two ways: 1. Not applying the resting state if none of the resting state props were changed 2. Saving the in-flight transform during a frame update and restoring it after ## Test plan |Before|After| |-|-| |<video src="https://github.com/user-attachments/assets/a631179e-1cef-4689-8d61-5d0a2bf08b65" />|<video src="https://github.com/user-attachments/assets/827574cd-f9eb-4f06-a45f-fd304fe9998c" />| <details> <summary>Expand</summary> ```jsx import React, { useState } from 'react'; import { StyleSheet, Text, View } from 'react-native'; import { Touchable } from 'react-native-gesture-handler'; export default function EmptyExample() { const [isActive, setIsActive] = useState(false); return ( <View style={styles.container}> <Touchable style={[styles.button, isActive && styles.buttonActive]} onPress={() => console.log('pressed')} onPressIn={() => setIsActive(true)} onPressOut={() => setIsActive(false)} activeScale={1.2}> <Text style={styles.label}>{isActive ? 'Highlighted' : 'Press me'}</Text> </Touchable> <Text style={styles.hint}> Press the button, then slowly drag your pointer away from it. </Text> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', padding: 24, gap: 24, }, button: { backgroundColor: '#ddd', paddingHorizontal: 32, paddingVertical: 16, borderRadius: 8, }, buttonActive: { backgroundColor: '#f97316', }, label: { fontSize: 20, fontWeight: '600', }, hint: { textAlign: 'center', opacity: 0.6, fontSize: 14, }, }); ``` </details>
1 parent ce6db39 commit f071853

1 file changed

Lines changed: 40 additions & 3 deletions

File tree

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

Lines changed: 40 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -111,7 +111,33 @@ - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetric
111111
const LayoutMetrics buttonMetrics = [self buildButtonMetrics:layoutMetrics];
112112
const LayoutMetrics oldbuttonMetrics = [self buildButtonMetrics:oldLayoutMetrics];
113113

114+
// The press-in animation sets a scale transform on `self.layer` (animationTarget
115+
// is this wrapper). RN's layout path sets `self.frame = frame`, which is undefined
116+
// behavior when the layer's transform is non-identity, so mid-press child re-layouts
117+
// get squished against the old bounds before snapping to the new ones. Neutralize
118+
// the transform and any in-flight animation around super's frame update, then
119+
// restore both atomically within the same transaction.
120+
CATransform3D savedTransform = self.layer.transform;
121+
CAAnimation *savedTransformAnimation = [[self.layer animationForKey:@"transform"] copy];
122+
BOOL hasPendingTransform = !CATransform3DIsIdentity(savedTransform) || savedTransformAnimation != nil;
123+
124+
if (hasPendingTransform) {
125+
[CATransaction begin];
126+
[CATransaction setDisableActions:YES];
127+
[self.layer removeAnimationForKey:@"transform"];
128+
self.layer.transform = CATransform3DIdentity;
129+
}
130+
114131
[super updateLayoutMetrics:wrapperMetrics oldLayoutMetrics:oldWrapperMetrics];
132+
133+
if (hasPendingTransform) {
134+
self.layer.transform = savedTransform;
135+
if (savedTransformAnimation) {
136+
[self.layer addAnimation:savedTransformAnimation forKey:@"transform"];
137+
}
138+
[CATransaction commit];
139+
}
140+
115141
[_buttonView updateLayoutMetrics:buttonMetrics oldLayoutMetrics:oldbuttonMetrics];
116142
}
117143

@@ -134,8 +160,6 @@ - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
134160
right:borderMetrics.borderWidths.right
135161
bottom:borderMetrics.borderWidths.bottom
136162
left:borderMetrics.borderWidths.left];
137-
138-
[_buttonView applyStartAnimationState];
139163
}
140164

141165
#pragma mark - RCTComponentViewProtocol
@@ -243,6 +267,17 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
243267
{
244268
const auto &newProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(props);
245269

270+
// Re-apply the idle visual state only on first mount or when one of the default
271+
// values actually changed. Doing it on every commit interrupts in-flight press
272+
// animations whenever React re-renders the children mid-press.
273+
BOOL shouldApplyStartAnimationState = !oldProps;
274+
if (oldProps) {
275+
const auto &oldButtonProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(oldProps);
276+
shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity ||
277+
oldButtonProps.defaultScale != newProps.defaultScale ||
278+
oldButtonProps.defaultUnderlayOpacity != newProps.defaultUnderlayOpacity;
279+
}
280+
246281
_buttonView.userEnabled = newProps.enabled;
247282
_buttonView.pressAndHoldAnimationDuration = newProps.pressAndHoldAnimationDuration;
248283
_buttonView.tapAnimationDuration = newProps.tapAnimationDuration > 0 ? newProps.tapAnimationDuration : 0;
@@ -279,7 +314,9 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
279314
}
280315

281316
[super updateProps:props oldProps:oldProps];
282-
[_buttonView applyStartAnimationState];
317+
if (shouldApplyStartAnimationState) {
318+
[_buttonView applyStartAnimationState];
319+
}
283320
}
284321

285322
#if !TARGET_OS_OSX

0 commit comments

Comments
 (0)