diff --git a/apps/common-app/src/examples/LayoutAnimations/ListItemLayoutAnimation.tsx b/apps/common-app/src/examples/LayoutAnimations/ListItemLayoutAnimation.tsx new file mode 100644 index 00000000000..03ec60ac994 --- /dev/null +++ b/apps/common-app/src/examples/LayoutAnimations/ListItemLayoutAnimation.tsx @@ -0,0 +1,202 @@ +import React, { memo, useCallback, useState } from 'react'; +import type { ListRenderItem } from 'react-native'; +import { + Dimensions, + Pressable, + SafeAreaView, + StyleSheet, + Text, + TouchableOpacity, + View, +} from 'react-native'; +import Animated, { + CurvedTransition, + EntryExitTransition, + FadeOut, + FadeIn, + FadingTransition, + JumpingTransition, + LayoutAnimationConfig, + LinearTransition, + SequencedTransition, +} from 'react-native-reanimated'; + +const ITEMS = ['Item 1', 'Item 2', 'Item 3', 'Item 4', 'Item 5']; +const LAYOUT_TRANSITIONS = [ + LinearTransition, + FadingTransition, + SequencedTransition, + JumpingTransition, + CurvedTransition, + EntryExitTransition, +] as const; + +type ListItemProps = { + id: string; + text: string; + onPress: (id: string) => void; +}; + +const ListItem = memo(function ({ id, text, onPress }: ListItemProps) { + return ( + onPress(id)} style={styles.listItem}> + {text} + + ); +}); + +export default function ListItemLayoutAnimation() { + const [layoutTransitionEnabled, setLayoutTransitionEnabled] = useState(true); + const [currentTransitionIndex, setCurrentTransitionIndex] = useState(0); + const [items, setItems] = useState(ITEMS); + + const removeItem = useCallback((id: string) => { + setItems((prevItems) => prevItems.filter((item) => item !== id)); + }, []); + + const renderItem = useCallback>( + ({ item }) => , + [removeItem] + ); + + const getNewItemName = useCallback(() => { + let i = 1; + while (items.includes(`Item ${i}`)) { + i++; + } + return `Item ${i}`; + }, [items]); + + const reorderItems = useCallback(() => { + setItems((prevItems) => { + const newItems = [...prevItems]; + newItems.sort(() => Math.random() - 0.5); + return newItems; + }); + }, []); + + const resetOrder = useCallback(() => { + setItems((prevItems) => { + const newItems = [...prevItems]; + newItems.sort((left, right) => { + const aNum = parseInt(left.match(/\d+$/)![0], 10); + const bNum = parseInt(right.match(/\d+$/)![0], 10); + return aNum - bNum; + }); + return newItems; + }); + }, []); + + const transition = layoutTransitionEnabled + ? LAYOUT_TRANSITIONS[currentTransitionIndex] + : undefined; + + return ( + + + + + Layout animation: + { + setLayoutTransitionEnabled((prev) => !prev); + }}> + + {layoutTransitionEnabled ? 'Enabled' : 'Disabled'} + + + + {transition && ( + + + Current: {transition?.presetName} + + { + setCurrentTransitionIndex( + (prev) => (prev + 1) % LAYOUT_TRANSITIONS.length + ); + }}> + Change + + + )} + + + item} + contentContainerStyle={styles.contentContainer} + itemLayoutAnimation={layoutTransitionEnabled ? transition : undefined} + layout={transition} + /> + + + Press an item to remove it + setItems([...items, getNewItemName()])}> + Add item + + + + Reorder + + + Reset order + + + + + + ); +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + padding: 16, + gap: 16, + }, + row: { + flexDirection: 'row', + gap: 16, + alignItems: 'center', + }, + list: { + flexGrow: 0, + maxHeight: Dimensions.get('window').height - 300, + }, + listItem: { + padding: 20, + backgroundColor: '#ad8ee9', + shadowColor: '#000', + shadowOpacity: 0.05, + }, + itemText: { + color: 'white', + fontSize: 22, + }, + menu: { + padding: 16, + alignItems: 'center', + justifyContent: 'center', + paddingTop: 16, + gap: 8, + }, + infoText: { + color: '#222534', + fontSize: 18, + }, + buttonText: { + fontSize: 18, + fontWeight: 'bold', + color: '#b59aeb', + }, +}); diff --git a/apps/common-app/src/examples/index.ts b/apps/common-app/src/examples/index.ts index 309bda12a12..53d510fb606 100644 --- a/apps/common-app/src/examples/index.ts +++ b/apps/common-app/src/examples/index.ts @@ -131,6 +131,7 @@ import BorderRadiiExample from './SharedElementTransitions/BorderRadii'; import FreezingShareablesExample from './ShareableFreezingExample'; import TabNavigatorExample from './SharedElementTransitions/TabNavigatorExample'; import StrictDOMExample from './StrictDOMExample'; +import ListItemLayoutAnimation from './LayoutAnimations/ListItemLayoutAnimation'; interface Example { icon?: string; @@ -669,6 +670,10 @@ export const EXAMPLES: Record = { title: '[LA] Reactions counter', screen: ReactionsCounterExample, }, + ListItemLayoutAnimation: { + title: '[LA] List item layout animation', + screen: ListItemLayoutAnimation, + }, SwipeableList: { title: '[LA] Swipeable list', screen: SwipeableList, diff --git a/packages/docs-reanimated/docs/layout-animations/list-layout-animations.mdx b/packages/docs-reanimated/docs/layout-animations/list-layout-animations.mdx new file mode 100644 index 00000000000..83f9a232542 --- /dev/null +++ b/packages/docs-reanimated/docs/layout-animations/list-layout-animations.mdx @@ -0,0 +1,86 @@ +--- +sidebar_position: 6 +title: List Layout Animations +sidebar_label: List Layout Animations +--- + +`itemLayoutAnimation` lets you define a [layout transition](/docs/layout-animations/layout-transitions) that's applied when list items layout changes. You can use one of the [predefined transitions](/docs/layout-animations/layout-transitions#predefined-transitions) like `LinearTransition` or create [your own transition](/docs/layout-animations/custom-animations#custom-layout-transition). + +## Example + + + + + +
+ +```jsx +import Animated, { LinearTransition } from 'react-native-reanimated'; + +function App() { + return ( + + ); +} +``` + +
+ +
+ +## Remarks + +- `itemLayoutAnimation` works only with a single-column `Animated.FlatList`, `numColumns` property cannot be grater than 1. +- You can change the `itemLayoutAnimation` on the fly or disable it by setting it to `undefined`. + + + +```javascript +function App() { + const [transition, setTransition] = useState(LinearTransition); + + const changeTransition = () => { + // highlight-start + setTransition( + transition === LinearTransition ? JumpingTransition : LinearTransition + ); + // highlight-end + }; + + const toggleTransition = () => { + // highlight-next-line + setTransition(transition ? undefined : LinearTransition); + }; + + return ( + + ); +} +``` + + + +## Platform compatibility + +
+ +| Android | iOS | Web | +| ------- | --- | --- | +| ✅ | ✅ | ✅ | + +
diff --git a/packages/docs-reanimated/static/recordings/layout-animations/listitem_dark.mov b/packages/docs-reanimated/static/recordings/layout-animations/listitem_dark.mov new file mode 100644 index 00000000000..56fe9efd1ac Binary files /dev/null and b/packages/docs-reanimated/static/recordings/layout-animations/listitem_dark.mov differ diff --git a/packages/docs-reanimated/static/recordings/layout-animations/listitem_light.mov b/packages/docs-reanimated/static/recordings/layout-animations/listitem_light.mov new file mode 100644 index 00000000000..dc60cec082e Binary files /dev/null and b/packages/docs-reanimated/static/recordings/layout-animations/listitem_light.mov differ diff --git a/packages/react-native-reanimated/src/component/FlatList.tsx b/packages/react-native-reanimated/src/component/FlatList.tsx index bea52d63a8d..25032977bf0 100644 --- a/packages/react-native-reanimated/src/component/FlatList.tsx +++ b/packages/react-native-reanimated/src/component/FlatList.tsx @@ -45,6 +45,7 @@ interface ReanimatedFlatListPropsWithLayout extends AnimatedProps> { /** * Lets you pass layout animation directly to the FlatList item. + * Works only with a single-column `Animated.FlatList`, `numColumns` property cannot be greater than 1. */ itemLayoutAnimation?: ILayoutAnimationBuilder; /**