Skip to content

Commit 5460083

Browse files
authored
[iOS] Handle view recycling in Touchable (#4131)
## Description Due to the Fabric view recycling not being handled correctly by the native button implementations, the `default*` props were not applied correctly when the button instances were being reused. This presented itself by buttons being styled correctly on the first mount, but not during the following ones. This PR addresses that. ## Test plan |Before|After| |-|-| |<video src="https://github.com/user-attachments/assets/8a7c7e3d-3099-4bbe-a8d8-a6bcdc7750f0" />|<video src="https://github.com/user-attachments/assets/f36134d6-d78f-47a7-aeec-96bec6e6c50a" />|
1 parent dec475f commit 5460083

3 files changed

Lines changed: 80 additions & 6 deletions

File tree

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,14 @@
4949
*/
5050
- (void)applyStartAnimationState;
5151

52+
/**
53+
* Resets all transient press/animation state so the button can be safely reused
54+
* by Fabric view recycling. Cancels pending press-out blocks, removes in-flight
55+
* animations, and unconditionally restores the animation target's transform/alpha
56+
* and the underlay opacity to neutral values.
57+
*/
58+
- (void)prepareForRecycle;
59+
5260
/**
5361
* Updates the underlay layer's corner radii with separate horizontal/vertical
5462
* components per corner, supporting elliptical inner corners when border widths

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

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,28 @@ - (void)cancelPendingPressOutAnimation
117117
[_underlayLayer removeAllAnimations];
118118
}
119119

120+
- (void)prepareForRecycle
121+
{
122+
// Fabric reuses the same wrapper + button instance across mounts. Without
123+
// this reset, residual press-in transform/alpha/underlay-opacity from a
124+
// prior use leaks into the recycled view, and `updateProps:` won't undo it
125+
// when defaults are unchanged between mounts.
126+
[self cancelPendingPressOutAnimation];
127+
128+
RNGHUIView *target = self.animationTarget ?: self;
129+
target.layer.transform = CATransform3DIdentity;
130+
#if !TARGET_OS_OSX
131+
target.alpha = 1.0;
132+
#else
133+
target.alphaValue = 1.0;
134+
#endif
135+
_underlayLayer.opacity = 0;
136+
137+
_isTouchInsideBounds = NO;
138+
_suppressSuperControlActionDispatch = NO;
139+
_pressInTimestamp = 0;
140+
}
141+
120142
#if TARGET_OS_OSX
121143
- (void)viewWillMoveToWindow:(RNGHWindow *)newWindow
122144
{

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

Lines changed: 50 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ @interface RNGestureHandlerButtonComponentView () <RCTRNGestureHandlerButtonView
3434

3535
@implementation RNGestureHandlerButtonComponentView {
3636
RNGestureHandlerButton *_buttonView;
37+
BOOL _needsAnimationStateReset;
3738
}
3839

3940
#if TARGET_OS_OSX
@@ -67,6 +68,20 @@ - (instancetype)initWithFrame:(CGRect)frame
6768
return self;
6869
}
6970

71+
- (void)prepareForRecycle
72+
{
73+
[self.layer removeAnimationForKey:@"transform"];
74+
self.layer.transform = CATransform3DIdentity;
75+
76+
[_buttonView prepareForRecycle];
77+
78+
// Force the next updateProps: to re-run applyStartAnimationState even if
79+
// the new mount's defaults match the previous mount's.
80+
_needsAnimationStateReset = YES;
81+
82+
[super prepareForRecycle];
83+
}
84+
7085
- (void)mountChildComponentView:(RNGHUIView<RCTComponentViewProtocol> *)childComponentView index:(NSInteger)index
7186
{
7287
[_buttonView mountChildComponentView:childComponentView index:index];
@@ -143,8 +158,33 @@ - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetric
143158

144159
- (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask
145160
{
161+
// super's invalidateLayer (called via finalizeUpdates) unconditionally sets
162+
// self.layer.opacity to the React style.opacity, overwriting our
163+
// applyStartAnimationState alpha and interrupting in-flight press
164+
// animations. Save/restore around super, but only touch what super actually
165+
// disturbed — re-adding an unchanged animation resets its progress.
166+
float savedOpacity = self.layer.opacity;
167+
CAAnimation *savedOpacityAnimation = [self.layer animationForKey:@"opacity"];
168+
146169
[super finalizeUpdates:updateMask];
147170

171+
BOOL opacityChanged = savedOpacity != self.layer.opacity;
172+
BOOL animationChanged = savedOpacityAnimation != [self.layer animationForKey:@"opacity"];
173+
if (opacityChanged || animationChanged) {
174+
[CATransaction begin];
175+
[CATransaction setDisableActions:YES];
176+
if (animationChanged) {
177+
[self.layer removeAnimationForKey:@"opacity"];
178+
if (savedOpacityAnimation) {
179+
[self.layer addAnimation:savedOpacityAnimation forKey:@"opacity"];
180+
}
181+
}
182+
if (opacityChanged) {
183+
self.layer.opacity = savedOpacity;
184+
}
185+
[CATransaction commit];
186+
}
187+
148188
// Resolve per-corner border radii from props and forward to the button
149189
// so its underlay CALayer gets the matching shape.
150190
const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics);
@@ -267,11 +307,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
267307
{
268308
const auto &newProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(props);
269309

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) {
310+
// After recycling, treat diffing branches as a fresh mount so values that
311+
// survived recycling on _buttonView (e.g. pointerEvents) get re-applied.
312+
BOOL treatAsFirstMount = !oldProps || _needsAnimationStateReset;
313+
_needsAnimationStateReset = NO;
314+
315+
// Avoid re-running applyStartAnimationState on every commit — it would
316+
// interrupt in-flight press animations during mid-press re-renders.
317+
BOOL shouldApplyStartAnimationState = treatAsFirstMount;
318+
if (!treatAsFirstMount) {
275319
const auto &oldButtonProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(oldProps);
276320
shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity ||
277321
oldButtonProps.defaultScale != newProps.defaultScale ||
@@ -303,7 +347,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared &
303347
// This is necessary because pointerEvents is redefined in the spec,
304348
// which shadows the base property with a different, incompatible type.
305349
const auto &newViewProps = static_cast<const ViewProps &>(newProps);
306-
if (!oldProps) {
350+
if (treatAsFirstMount) {
307351
_buttonView.pointerEvents = RCTPointerEventsToEnum(newViewProps.pointerEvents);
308352
} else {
309353
const auto &oldButtonProps = *std::static_pointer_cast<const RNGestureHandlerButtonProps>(oldProps);

0 commit comments

Comments
 (0)