Skip to content

Commit a2cdebd

Browse files
[Fabric LA] Fix maybeDropAncestors condition. (#6663)
## Summary In the `maybeDropAncestors` function we can remove the view if it has no remaining animating views. Let's say we have nested exiting animations: ```mermaid flowchart TD A((A: EXITING)) B((B: EXITING)) C((C: WAITING)) D((D: WAITING)) E((E: WAITING)) A --> B A --> C A --> D A --> E ``` In the current implementation in this case if the animation in `B` ended before `A`, we would visit `A` in `maybeDropAncestors` and decided to remove `A`, even though it still has some waiting children. Then `A` would be added to the view recycling pool while still having children. This would cause us to see some zombie views when the view is reused. I changed the `maybeDropAncestors` condition to check the size of the `children` list. I also removed `node->animatedChildren` as I think it is no longer necessary. Fixes #6644 ## Test plan Chceck `[LA] View recycling` example, if there are no zombie views in the `WheelPicker` component.
1 parent 6586c5f commit a2cdebd

File tree

4 files changed

+367
-10
lines changed

4 files changed

+367
-10
lines changed
Lines changed: 360 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,360 @@
1+
import {
2+
SafeAreaView,
3+
StyleSheet,
4+
View,
5+
Text,
6+
TouchableOpacity,
7+
Pressable,
8+
} from 'react-native';
9+
import type {
10+
FlatList,
11+
LayoutChangeEvent,
12+
NativeScrollEvent,
13+
NativeSyntheticEvent,
14+
ViewStyle,
15+
} from 'react-native';
16+
import { useCallback, useMemo, useRef, useState } from 'react';
17+
import Animated, {
18+
FadeInRight,
19+
FadeOutLeft,
20+
interpolate,
21+
runOnJS,
22+
useAnimatedScrollHandler,
23+
useAnimatedStyle,
24+
useSharedValue,
25+
ZoomIn,
26+
ZoomOut,
27+
} from 'react-native-reanimated';
28+
import type { SharedValue } from 'react-native-reanimated';
29+
30+
type Option = {
31+
key: string;
32+
emoji?: string;
33+
label: string;
34+
};
35+
36+
type OptionProps = {
37+
onChange: (option: Option) => void;
38+
};
39+
40+
const data: Option[] = [
41+
{ key: '1', emoji: '😭', label: '1' },
42+
{ key: '2', emoji: '💀', label: '2' },
43+
{ key: '3', emoji: '🎩', label: '3' },
44+
{ key: '4', emoji: '🥹', label: '4' },
45+
];
46+
47+
const OptionInput = ({ onChange }: OptionProps) => {
48+
const [activeOption, setActiveOption] = useState<Option>(data[0]);
49+
50+
const handleSelect = (option: Option) => {
51+
setActiveOption(option);
52+
onChange && onChange(option);
53+
};
54+
55+
return (
56+
<View style={styles.optionContainer}>
57+
{data.map((option) => (
58+
<Box
59+
key={option.key}
60+
option={option}
61+
isActive={option.key === activeOption?.key}
62+
onSelect={handleSelect}
63+
/>
64+
))}
65+
</View>
66+
);
67+
};
68+
69+
type BoxProps = {
70+
option: Option;
71+
isActive: boolean;
72+
onSelect: (option: Option) => void;
73+
};
74+
75+
const Box = ({ option, isActive, onSelect }: BoxProps) => {
76+
return (
77+
<TouchableOpacity
78+
style={[styles.boxContainer, isActive && { borderColor: '#AAFFAA' }]}
79+
activeOpacity={0.6}
80+
onPress={() => onSelect(option)}>
81+
<View style={styles.boxContent}>
82+
<View style={styles.optionContent}>
83+
{option.emoji && <Text style={styles.emoji}>{option.emoji}</Text>}
84+
<Text>{option.label}</Text>
85+
</View>
86+
<View
87+
style={[
88+
styles.circle,
89+
isActive && {
90+
borderColor: 'black',
91+
backgroundColor: '#AAFFAA',
92+
},
93+
]}>
94+
{isActive && (
95+
<Animated.View
96+
key={`option)-${option.label}`}
97+
entering={ZoomIn}
98+
exiting={ZoomOut}>
99+
<Text>✔️</Text>
100+
</Animated.View>
101+
)}
102+
</View>
103+
</View>
104+
</TouchableOpacity>
105+
);
106+
};
107+
108+
type Props = {
109+
item: string | number;
110+
index: number;
111+
contentOffsetY: SharedValue<number>;
112+
itemHeight: number;
113+
};
114+
115+
const Item = ({ item, index, contentOffsetY, itemHeight }: Props) => {
116+
const animatedStyle = useAnimatedStyle(() => {
117+
const inputRange = [
118+
itemHeight * (index - 3),
119+
itemHeight * (index - 2),
120+
itemHeight * (index - 1),
121+
itemHeight * index,
122+
itemHeight * (index + 1),
123+
itemHeight * (index + 2),
124+
itemHeight * (index + 3),
125+
];
126+
127+
const opacity = interpolate(
128+
contentOffsetY.value,
129+
inputRange,
130+
[0.1, 0.2, 0.35, 1, 0.35, 0.2, 0.1],
131+
'clamp'
132+
);
133+
134+
return {
135+
opacity,
136+
};
137+
});
138+
139+
return (
140+
<Animated.View
141+
style={[styles.itemContainer, { height: itemHeight }, animatedStyle]}>
142+
<Text style={styles.itemText}>{item.toString()}</Text>
143+
</Animated.View>
144+
);
145+
};
146+
147+
interface WheelPickerProps {
148+
minValue: number;
149+
maxValue: number;
150+
initialValue?: number;
151+
itemHeight?: number;
152+
highlightStyle?: ViewStyle;
153+
}
154+
155+
const WheelPicker: React.FC<WheelPickerProps> = ({
156+
minValue,
157+
maxValue,
158+
initialValue,
159+
itemHeight = 60,
160+
highlightStyle,
161+
}) => {
162+
const [containerHeight, setContainerHeight] = useState(0);
163+
const flatListRef = useRef<FlatList>(null);
164+
const contentOffsetY = useSharedValue(0);
165+
const lastSelectedIndex = useRef(-1);
166+
167+
const data = useMemo(
168+
() =>
169+
Array.from({ length: maxValue - minValue + 1 }, (_, i) => i + minValue),
170+
[minValue, maxValue]
171+
);
172+
173+
const calculateSelectedValue = useCallback(
174+
(offsetY: number) => {
175+
const index = Math.round(offsetY / itemHeight);
176+
if (index !== lastSelectedIndex.current) {
177+
lastSelectedIndex.current = index;
178+
}
179+
},
180+
[itemHeight]
181+
);
182+
183+
const onScroll = useAnimatedScrollHandler({
184+
onScroll: (event) => {
185+
contentOffsetY.value = event.contentOffset.y;
186+
runOnJS(calculateSelectedValue)(event.contentOffset.y);
187+
},
188+
});
189+
190+
const onScrollEnd = (event: NativeSyntheticEvent<NativeScrollEvent>) => {
191+
calculateSelectedValue(event.nativeEvent.contentOffset.y);
192+
};
193+
194+
const onLayout = (event: LayoutChangeEvent) => {
195+
setContainerHeight(event.nativeEvent.layout.height);
196+
};
197+
198+
return (
199+
<View onLayout={onLayout}>
200+
<Animated.FlatList
201+
ref={flatListRef}
202+
data={data}
203+
keyExtractor={(item) => item.toString()}
204+
showsVerticalScrollIndicator={false}
205+
scrollEventThrottle={16}
206+
snapToInterval={itemHeight}
207+
decelerationRate="fast"
208+
bounces={false}
209+
onScroll={onScroll}
210+
onMomentumScrollEnd={onScrollEnd}
211+
onScrollEndDrag={onScrollEnd}
212+
renderItem={({ item, index }) => (
213+
<Item
214+
index={index}
215+
item={item}
216+
itemHeight={itemHeight}
217+
contentOffsetY={contentOffsetY}
218+
/>
219+
)}
220+
contentContainerStyle={{
221+
paddingTop: containerHeight / 2 - itemHeight / 2,
222+
paddingBottom: containerHeight / 2 - itemHeight / 2,
223+
}}
224+
initialScrollIndex={initialValue ? data.indexOf(initialValue) : 0}
225+
getItemLayout={(_, index) => ({
226+
length: itemHeight,
227+
offset: itemHeight * index,
228+
index,
229+
})}
230+
/>
231+
<View
232+
style={[
233+
styles.activeLine,
234+
highlightStyle,
235+
{
236+
borderWidth: 1,
237+
width: 300,
238+
backgroundColor: '#DDDDDD10',
239+
top: containerHeight / 2 - itemHeight / 2,
240+
height: itemHeight,
241+
},
242+
]}
243+
/>
244+
</View>
245+
);
246+
};
247+
248+
export default function Example() {
249+
const [value, setValue] = useState<boolean>(true);
250+
251+
return (
252+
<SafeAreaView style={styles.container}>
253+
<Pressable
254+
style={styles.buttonContainer}
255+
onPress={() => setValue((x) => !x)}>
256+
<Animated.View style={styles.button}>
257+
<Animated.Text style={styles.buttonText}>Continue</Animated.Text>
258+
</Animated.View>
259+
</Pressable>
260+
{value && (
261+
<Animated.View
262+
key="OptionInput"
263+
entering={FadeInRight}
264+
exiting={FadeOutLeft}
265+
style={styles.content}>
266+
<OptionInput onChange={() => {}} />
267+
</Animated.View>
268+
)}
269+
270+
{!value && (
271+
<Animated.View
272+
key="WheelPicker"
273+
entering={FadeInRight}
274+
exiting={FadeOutLeft}
275+
style={styles.content}>
276+
<WheelPicker minValue={16} maxValue={99} initialValue={30} />
277+
</Animated.View>
278+
)}
279+
</SafeAreaView>
280+
);
281+
}
282+
283+
const styles = StyleSheet.create({
284+
optionContainer: {
285+
justifyContent: 'center',
286+
flexDirection: 'row',
287+
flexWrap: 'wrap',
288+
gap: 10,
289+
},
290+
boxContainer: {
291+
width: 150,
292+
borderRadius: 10,
293+
borderWidth: 2,
294+
backgroundColor: '#5555FF77',
295+
},
296+
boxContent: {
297+
justifyContent: 'center',
298+
alignItems: 'center',
299+
paddingHorizontal: 22,
300+
paddingTop: 20,
301+
paddingBottom: 26,
302+
gap: 16,
303+
},
304+
optionContent: {
305+
gap: 8,
306+
justifyContent: 'center',
307+
alignItems: 'center',
308+
},
309+
emoji: {
310+
fontSize: 48,
311+
},
312+
circle: {
313+
width: 30,
314+
height: 30,
315+
borderRadius: 15,
316+
borderWidth: 1,
317+
borderColor: 'transparent',
318+
alignItems: 'center',
319+
justifyContent: 'center',
320+
},
321+
itemContainer: {
322+
justifyContent: 'center',
323+
alignItems: 'center',
324+
width: 300,
325+
},
326+
container: {
327+
flex: 1,
328+
justifyContent: 'center',
329+
backgroundColor: '#ecf0f1',
330+
padding: 8,
331+
},
332+
content: {
333+
flex: 1,
334+
alignItems: 'center',
335+
},
336+
activeLine: {
337+
position: 'absolute',
338+
borderRadius: 99,
339+
zIndex: -5,
340+
},
341+
buttonContainer: {
342+
justifyContent: 'flex-end',
343+
alignItems: 'center',
344+
marginVertical: 16,
345+
},
346+
button: {
347+
backgroundColor: 'black',
348+
padding: 20,
349+
borderRadius: 8,
350+
alignItems: 'center',
351+
justifyContent: 'center',
352+
width: '80%',
353+
},
354+
buttonText: {
355+
fontSize: 25,
356+
fontWeight: 'bold',
357+
color: 'white',
358+
},
359+
itemText: { fontWeight: 'bold', fontSize: 30 },
360+
});

apps/common-app/src/examples/index.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,7 @@ import StrictDOMExample from './StrictDOMExample';
134134
import BottomTabsExample from './LayoutAnimations/BottomTabs';
135135
import ListItemLayoutAnimation from './LayoutAnimations/ListItemLayoutAnimation';
136136
import ViewFlatteningExample from './LayoutAnimations/ViewFlattening';
137+
import ViewRecyclingExample from './LayoutAnimations/ViewRecyclingExample';
137138
import InvalidValueAccessExample from './InvalidValueAccessExample';
138139

139140
interface Example {
@@ -722,6 +723,10 @@ export const EXAMPLES: Record<string, Example> = {
722723
title: '[LA] View Flattening',
723724
screen: ViewFlatteningExample,
724725
},
726+
ViewRecycling: {
727+
title: '[LA] View Recycling',
728+
screen: ViewRecyclingExample,
729+
},
725730

726731
// Shared Element Transitions
727732

0 commit comments

Comments
 (0)