diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h index 839877120a..6e4186b627 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.h @@ -49,6 +49,14 @@ */ - (void)applyStartAnimationState; +/** + * Resets all transient press/animation state so the button can be safely reused + * by Fabric view recycling. Cancels pending press-out blocks, removes in-flight + * animations, and unconditionally restores the animation target's transform/alpha + * and the underlay opacity to neutral values. + */ +- (void)prepareForRecycle; + /** * Updates the underlay layer's corner radii with separate horizontal/vertical * components per corner, supporting elliptical inner corners when border widths diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm index 8acc1a5544..cdb82f1a4e 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButton.mm @@ -117,6 +117,28 @@ - (void)cancelPendingPressOutAnimation [_underlayLayer removeAllAnimations]; } +- (void)prepareForRecycle +{ + // Fabric reuses the same wrapper + button instance across mounts. Without + // this reset, residual press-in transform/alpha/underlay-opacity from a + // prior use leaks into the recycled view, and `updateProps:` won't undo it + // when defaults are unchanged between mounts. + [self cancelPendingPressOutAnimation]; + + RNGHUIView *target = self.animationTarget ?: self; + target.layer.transform = CATransform3DIdentity; +#if !TARGET_OS_OSX + target.alpha = 1.0; +#else + target.alphaValue = 1.0; +#endif + _underlayLayer.opacity = 0; + + _isTouchInsideBounds = NO; + _suppressSuperControlActionDispatch = NO; + _pressInTimestamp = 0; +} + #if TARGET_OS_OSX - (void)viewWillMoveToWindow:(RNGHWindow *)newWindow { diff --git a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm index ac73f1c0e8..164163869a 100644 --- a/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm +++ b/packages/react-native-gesture-handler/apple/RNGestureHandlerButtonComponentView.mm @@ -34,6 +34,7 @@ @interface RNGestureHandlerButtonComponentView () *)childComponentView index:(NSInteger)index { [_buttonView mountChildComponentView:childComponentView index:index]; @@ -143,8 +158,33 @@ - (void)updateLayoutMetrics:(const facebook::react::LayoutMetrics &)layoutMetric - (void)finalizeUpdates:(RNComponentViewUpdateMask)updateMask { + // super's invalidateLayer (called via finalizeUpdates) unconditionally sets + // self.layer.opacity to the React style.opacity, overwriting our + // applyStartAnimationState alpha and interrupting in-flight press + // animations. Save/restore around super, but only touch what super actually + // disturbed — re-adding an unchanged animation resets its progress. + float savedOpacity = self.layer.opacity; + CAAnimation *savedOpacityAnimation = [self.layer animationForKey:@"opacity"]; + [super finalizeUpdates:updateMask]; + BOOL opacityChanged = savedOpacity != self.layer.opacity; + BOOL animationChanged = savedOpacityAnimation != [self.layer animationForKey:@"opacity"]; + if (opacityChanged || animationChanged) { + [CATransaction begin]; + [CATransaction setDisableActions:YES]; + if (animationChanged) { + [self.layer removeAnimationForKey:@"opacity"]; + if (savedOpacityAnimation) { + [self.layer addAnimation:savedOpacityAnimation forKey:@"opacity"]; + } + } + if (opacityChanged) { + self.layer.opacity = savedOpacity; + } + [CATransaction commit]; + } + // Resolve per-corner border radii from props and forward to the button // so its underlay CALayer gets the matching shape. const auto borderMetrics = _props->resolveBorderMetrics(_layoutMetrics); @@ -267,11 +307,15 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & { const auto &newProps = *std::static_pointer_cast(props); - // Re-apply the idle visual state only on first mount or when one of the default - // values actually changed. Doing it on every commit interrupts in-flight press - // animations whenever React re-renders the children mid-press. - BOOL shouldApplyStartAnimationState = !oldProps; - if (oldProps) { + // After recycling, treat diffing branches as a fresh mount so values that + // survived recycling on _buttonView (e.g. pointerEvents) get re-applied. + BOOL treatAsFirstMount = !oldProps || _needsAnimationStateReset; + _needsAnimationStateReset = NO; + + // Avoid re-running applyStartAnimationState on every commit — it would + // interrupt in-flight press animations during mid-press re-renders. + BOOL shouldApplyStartAnimationState = treatAsFirstMount; + if (!treatAsFirstMount) { const auto &oldButtonProps = *std::static_pointer_cast(oldProps); shouldApplyStartAnimationState = oldButtonProps.defaultOpacity != newProps.defaultOpacity || oldButtonProps.defaultScale != newProps.defaultScale || @@ -303,7 +347,7 @@ - (void)updateProps:(const Props::Shared &)props oldProps:(const Props::Shared & // This is necessary because pointerEvents is redefined in the spec, // which shadows the base property with a different, incompatible type. const auto &newViewProps = static_cast(newProps); - if (!oldProps) { + if (treatAsFirstMount) { _buttonView.pointerEvents = RCTPointerEventsToEnum(newViewProps.pointerEvents); } else { const auto &oldButtonProps = *std::static_pointer_cast(oldProps);