Skip to content

Commit 44e8d61

Browse files
authored
[iOS] Re-sync layer opacity and transform from retained props when recycling buttons (#4360)
## Description On Fabric, a v3 `Pressable` (or any `RNGestureHandlerButton`) with a style opacity rendered correctly on first mount, but lost the opacity once the component was unmounted and mounted again - the remount reuses a recycled native view and the button renders fully opaque. The root cause is a desync between the wrapper's layer and its retained props, introduced by the recycle reset from #4131: - `RNGestureHandlerButton.prepareForRecycle` resets `target.alpha = 1.0` to clear residual press-animation state, but target is the `animationTarget` - the wrapper `RNGestureHandlerButtonComponentView`, whose `layer.opacity` carries React's style opacity. - Fabric retains `_props` across recycling, and `RCTViewComponentView.updateProps`: re-applies opacity only when it differs from the retained props. - Even when another prop change triggers `invalidateLayer` (which would re-apply the correct opacity unconditionally), our `finalizeUpdates:` save/restore detects that as an unwanted change and restores the stale value. The fix re-syncs the layer from the retained props right after the button reset in `prepareForRecycle`, instead of leaving neutral values: `opacity` from `viewProps.opacity`, `transform` via `viewProps.resolveTransform(_layoutMetrics)`. > [!NOTE] > The transform gets the same treatment: the identity reset leaves the layer desynced from the retained props in the same way, it just isn't visible today because `updateLayoutMetrics` happens to recompute transforms after recycling. Re-syncing removes the reliance on that implementation detail. Fixes #4353 ## Test plan <details> <summary>Tested on the following code:</summary> ```tsx import React, { useState } from 'react'; import { Button, StyleSheet, Text, View } from 'react-native'; import { Pressable } from 'react-native-gesture-handler'; // Repro for #4353 // iOS + Fabric: style opacity (and transform) on a v3 Pressable is applied on // the first mount, but lost after the component is unmounted and mounted again // (the remount reuses a recycled native view). The plain Views on the right // show the expected rendering and should stay identical to the Pressables // after every toggle. export default function EmptyExample() { const [mounted, setMounted] = useState(true); const [generation, setGeneration] = useState(0); return ( <View style={styles.container}> <Button title={mounted ? 'Unmount' : 'Mount again'} onPress={() => { setMounted(!mounted); if (!mounted) { setGeneration(generation + 1); } }} /> <Text style={styles.caption}> Remounts: {generation} — left column: Pressable, right column: expected (plain View) </Text> <View style={styles.row}> {mounted ? ( <Pressable style={[styles.box, styles.opacityStyle]} onPress={() => console.log('press (opacity)')}> <Text style={styles.label}>opacity: 0.3</Text> </Pressable> ) : ( <View style={styles.boxPlaceholder} /> )} <View style={[styles.box, styles.opacityStyle]}> <Text style={styles.label}>opacity: 0.3</Text> </View> </View> <View style={styles.row}> {mounted ? ( <Pressable style={[styles.box, styles.transformStyle]} onPress={() => console.log('press (transform)')}> <Text style={styles.label}>rotate: 25deg</Text> </Pressable> ) : ( <View style={styles.boxPlaceholder} /> )} <View style={[styles.box, styles.transformStyle]}> <Text style={styles.label}>rotate: 25deg</Text> </View> </View> </View> ); } const styles = StyleSheet.create({ container: { flex: 1, justifyContent: 'center', alignItems: 'center', gap: 24, }, row: { flexDirection: 'row', gap: 48, }, box: { width: 120, height: 120, backgroundColor: 'mediumpurple', justifyContent: 'center', alignItems: 'center', }, boxPlaceholder: { width: 120, height: 120, }, opacityStyle: { opacity: 0.3, }, transformStyle: { transform: [{ rotate: '25deg' }], }, caption: { marginHorizontal: 24, textAlign: 'center', opacity: 0.5, }, label: { color: 'white', }, }); ``` </details>
1 parent caf03a3 commit 44e8d61

1 file changed

Lines changed: 11 additions & 0 deletions

File tree

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -131,6 +131,17 @@ - (void)prepareForRecycle
131131

132132
[_buttonView prepareForRecycle];
133133

134+
// The reset above forces this wrapper's alpha and transform back to neutral
135+
// values, but Fabric retains `_props` across recycling and `updateProps:`
136+
// re-applies opacity/transform only when they differ from the retained
137+
// props. Re-sync the layer from the retained props so the next mount's diff
138+
// stays valid (the same approach RN takes for Animated-managed props in its
139+
// `prepareForRecycle`); otherwise a remount with an unchanged style opacity
140+
// keeps the neutral 1.0 instead (https://github.com/software-mansion/react-native-gesture-handler/issues/4353).
141+
const auto &viewProps = static_cast<const ViewProps &>(*_props);
142+
self.layer.opacity = (float)viewProps.opacity;
143+
self.layer.transform = RCTCATransform3DFromTransformMatrix(viewProps.resolveTransform(_layoutMetrics));
144+
134145
// Force the next updateProps: to re-run applyStartAnimationState even if
135146
// the new mount's defaults match the previous mount's.
136147
_needsAnimationStateReset = YES;

0 commit comments

Comments
 (0)