Skip to content

Commit dcdf76a

Browse files
committed
feat: add support for layout animations
Closes #23.
1 parent d2ef8f1 commit dcdf76a

File tree

14 files changed

+159
-50
lines changed

14 files changed

+159
-50
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,7 @@ This component uses a [FlatList](https://reactnative.dev/docs/flatlist) and it e
6666
| autoscrollActivationDelta | `number` | No | `5` | Allows configuring the delta for autoscroll activation when dragging an item in the same direction as the autoscroll. This is particularly useful when an item is dragged within the autoscroll area to account for minor unintentional movements. |
6767
| animationDuration | `number` | No | `200` | Duration of the animations in milliseconds. Users won't be able to drag a new item until the dragged item is released and its animation to its new position ends. |
6868
| cellAnimations | `ReorderableListCellAnimations` | No | N/A | Allows passing an object with values and/or shared values that can animate a cell, for example by using the `onDragStart` and `onDragEnd` events. Supports view style properties. Override opacity and/or transform to disable the default animation, e.g. `{opacity: 1, transform: []}`. Check the [examples](https://github.com/omahili/react-native-reorderable-list/tree/master/example) for more details. |
69+
| itemLayoutAnimation | `ComponentProps<typeof Animated.View>['layout']>` | No | N/A | Layout animation when the item is added to and/or removed from the view hierarchy. To skip entering or exiting animations use the LayoutAnimationConfig component from [Reanimated](https://docs.swmansion.com/react-native-reanimated). |
6970
| dragEnabled | `boolean` | No | `true` | Whether dragging items is enabled. |
7071
| shouldUpdateActiveItem | boolean | No | `false` | Whether the active item should be updated. Enables usage of `useIsActive` hook. |
7172
| panGesture | `PanGesture` | No | N/A | Custom instance of pan gesture. See [GestureHandler docs](https://docs.swmansion.com/react-native-gesture-handler) for further info. |

example/src/SwipeableList/SwipeableListItem.tsx renamed to example/src/LayoutAnimations/LayoutAnimationsItem.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,12 +5,12 @@ import Swipeable from 'react-native-gesture-handler/Swipeable';
55

66
import {ListItem, ListItemProps} from '../common';
77

8-
interface SwipeableListItemProps extends ListItemProps {
8+
interface LayoutAnimationsItemProps extends ListItemProps {
99
id: string;
1010
onDeletePress: (id: string) => void;
1111
}
1212

13-
export const SwipeableListItem: React.FC<SwipeableListItemProps> = memo(
13+
export const LayoutAnimationsItem: React.FC<LayoutAnimationsItemProps> = memo(
1414
({id, onDeletePress, ...rest}) => {
1515
const renderRightActions = () => (
1616
<Pressable

example/src/SwipeableList/SwipeableListScreen.tsx renamed to example/src/LayoutAnimations/LayoutAnimationsScreen.tsx

Lines changed: 21 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import React, {useCallback, useState} from 'react';
22
import {Button, ListRenderItemInfo, StyleSheet, View} from 'react-native';
33

4+
import {LinearTransition} from 'react-native-reanimated';
45
import ReorderableList, {
56
ReorderableListReorderEvent,
67
reorderItems,
@@ -9,30 +10,39 @@ import ReorderableList, {
910
import {
1011
ItemSeparator,
1112
SeedDataItem,
13+
createDataItem,
1214
usePanGesture,
1315
useSeedData,
1416
} from '../common';
15-
import {SwipeableListItem} from './SwipeableListItem';
17+
import {LayoutAnimationsItem} from './LayoutAnimationsItem';
1618

17-
export const SwipeableListScreen = () => {
19+
export const LayoutAnimationsScreen = () => {
1820
const seedData = useSeedData(5);
1921
const [data, setData] = useState(seedData);
2022
const panGesture = usePanGesture();
2123

22-
const handleReorder = ({from, to}: ReorderableListReorderEvent) => {
23-
setData(value => reorderItems(value, from, to));
24-
};
24+
const handleReorder = useCallback(
25+
({from, to}: ReorderableListReorderEvent) => {
26+
setData(value => reorderItems(value, from, to));
27+
},
28+
[],
29+
);
30+
31+
const handleAddPress = useCallback(() => {
32+
setData(value => [createDataItem(), ...value]);
33+
}, []);
2534

2635
const handleDeletePress = useCallback((id: string) => {
2736
setData(value => value.filter(x => x.id !== id));
2837
}, []);
2938

30-
const renderItem = ({item}: ListRenderItemInfo<SeedDataItem>) => (
31-
<SwipeableListItem {...item} onDeletePress={handleDeletePress} />
39+
const renderItem = useCallback(
40+
({item}: ListRenderItemInfo<SeedDataItem>) => (
41+
<LayoutAnimationsItem {...item} onDeletePress={handleDeletePress} />
42+
),
43+
[handleDeletePress],
3244
);
3345

34-
const handleResetPress = () => setData(seedData);
35-
3646
return (
3747
<View style={styles.container}>
3848
<ReorderableList
@@ -43,12 +53,13 @@ export const SwipeableListScreen = () => {
4353
ItemSeparatorComponent={ItemSeparator}
4454
contentContainerStyle={styles.listContentContainer}
4555
panGesture={panGesture}
56+
itemLayoutAnimation={LinearTransition}
4657
cellAnimations={{
4758
// Disable opacity animation to avoid seeing the delete action underneath.
4859
opacity: 1,
4960
}}
5061
/>
51-
<Button title="Reset" onPress={handleResetPress} />
62+
<Button title="Add" onPress={handleAddPress} />
5263
</View>
5364
);
5465
};
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export {LayoutAnimationsScreen} from './LayoutAnimationsScreen';

example/src/SwipeableList/index.ts

Lines changed: 0 additions & 1 deletion
This file was deleted.

example/src/common/useSeedData.ts

Lines changed: 16 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -9,25 +9,24 @@ export interface SeedDataItem {
99
description: string;
1010
}
1111

12+
export const createDataItem = () => ({
13+
id: faker.string.uuid(),
14+
image: faker.image.urlPicsumPhotos({
15+
width: 50,
16+
height: 50,
17+
}),
18+
title: faker.lorem.sentence(5).slice(0, -1),
19+
description: faker.lorem.paragraph({
20+
min: 1,
21+
max: 3,
22+
}),
23+
});
24+
1225
export const useSeedData = (count = 20) =>
1326
useMemo(
1427
() =>
15-
faker.helpers.multiple(
16-
() => ({
17-
id: faker.string.uuid(),
18-
image: faker.image.urlPicsumPhotos({
19-
width: 50,
20-
height: 50,
21-
}),
22-
title: faker.lorem.sentence(5).slice(0, -1),
23-
description: faker.lorem.paragraph({
24-
min: 1,
25-
max: 3,
26-
}),
27-
}),
28-
{
29-
count,
30-
},
31-
),
28+
faker.helpers.multiple(createDataItem, {
29+
count,
30+
}),
3231
[count],
3332
);

example/src/screens.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,13 @@ import {DynamicHeightsScreen} from './DynamicHeights';
88
import {FloatingHeaderFooterScreen} from './FloatingHeaderFooter';
99
import {HeaderFooterScreen} from './HeaderFooter';
1010
import {IndexChangeScreen} from './IndexChange';
11+
import {LayoutAnimationsScreen} from './LayoutAnimations';
1112
import {MultipleListsScreen} from './MultipleLists';
1213
import {NestedListsScreen} from './NestedLists';
1314
import {NestedScrollableListsScreen} from './NestedScrollableLists';
1415
import {PlaylistScreen} from './Playlist';
1516
import {ReadmeExampleScreen} from './ReadmeExample';
1617
import {RefreshControlScreen} from './RefreshControl';
17-
import {SwipeableListScreen} from './SwipeableList';
1818

1919
const screens = [
2020
{
@@ -79,8 +79,8 @@ const screens = [
7979
},
8080
{
8181
id: '12',
82-
name: 'Swipeable List',
83-
component: SwipeableListScreen,
82+
name: 'Layout Animations',
83+
component: LayoutAnimationsScreen,
8484
},
8585
{
8686
id: '13',

src/components/ReorderableListCell.tsx

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,14 @@ export const ReorderableListCell = memo(
3838
draggedIndex,
3939
animationDuration,
4040
}: ReorderableListCellProps<T>) => {
41-
const {currentIndex, draggedHeight, activeIndex, cellAnimations} =
42-
useContext(ReorderableListContext);
41+
const {
42+
currentIndex,
43+
draggedHeight,
44+
activeIndex,
45+
cellAnimations,
46+
itemLayoutAnimation,
47+
} = useContext(ReorderableListContext);
48+
4349
const dragHandler = useCallback(
4450
() => runOnUI(startDrag)(index),
4551
[startDrag, index],
@@ -141,7 +147,10 @@ export const ReorderableListCell = memo(
141147

142148
return (
143149
<ReorderableCellContext.Provider value={contextValue}>
144-
<Animated.View style={animatedStyle} onLayout={handleLayout}>
150+
<Animated.View
151+
style={animatedStyle}
152+
onLayout={handleLayout}
153+
layout={itemLayoutAnimation.current}>
145154
{children}
146155
</Animated.View>
147156
</ReorderableCellContext.Provider>

src/components/ReorderableListCore.tsx

Lines changed: 71 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
CellRendererProps,
44
FlatList,
55
FlatListProps,
6+
InteractionManager,
67
LayoutChangeEvent,
78
Platform,
89
ScrollView,
@@ -42,7 +43,7 @@ import {
4243
SCALE_ANIMATION_CONFIG_DEFAULT,
4344
} from './constants';
4445
import {ReorderableListCell} from './ReorderableListCell';
45-
import {usePropAsSharedValue} from '../hooks';
46+
import {usePropAsSharedValue, useStableCallback} from '../hooks';
4647

4748
const AnimatedFlatList = Animated.createAnimatedComponent(
4849
FlatList,
@@ -87,17 +88,20 @@ const ReorderableListCore = <T,>(
8788
cellAnimations,
8889
dragEnabled = true,
8990
shouldUpdateActiveItem,
91+
itemLayoutAnimation,
9092
panGesture,
9193
panEnabled = true,
9294
panActivateAfterLongPress,
9395
data,
96+
keyExtractor,
9497
...rest
9598
}: ReorderableListCoreProps<T>,
9699
ref: React.ForwardedRef<FlatList<T>>,
97100
) => {
98101
const scrollEnabled = rest.scrollEnabled ?? true;
99102

100103
const flatListRef = useAnimatedRef<FlatList>();
104+
const markedCellsRef = useRef<Map<string, 1>>();
101105
const [activeIndex, setActiveIndex] = useState(-1);
102106
const prevItemCount = useRef(data.length);
103107

@@ -139,6 +143,12 @@ const ReorderableListCore = <T,>(
139143
const dragDirection = useSharedValue(0);
140144
const lastDragDirectionPivot = useSharedValue<number | null>(null);
141145

146+
const itemLayoutAnimationPropRef = useRef(itemLayoutAnimation);
147+
itemLayoutAnimationPropRef.current = itemLayoutAnimation;
148+
149+
const keyExtractorPropRef = useRef(keyExtractor);
150+
keyExtractorPropRef.current = keyExtractor;
151+
142152
const scrollEnabledProp = usePropAsSharedValue(scrollEnabled);
143153
const animationDurationProp = usePropAsSharedValue(animationDuration);
144154
const autoscrollActivationDeltaProp = usePropAsSharedValue(
@@ -169,13 +179,50 @@ const ReorderableListCore = <T,>(
169179
prevItemCount.current = data.length;
170180
}, [data.length, itemHeight, itemOffset, itemCount]);
171181

182+
useEffect(() => {
183+
if (
184+
!markedCellsRef.current ||
185+
// Clean keys once they surpass by 10% the size of the list itself.
186+
markedCellsRef.current.size <= data.length + Math.ceil(data.length * 0.1)
187+
) {
188+
return;
189+
}
190+
191+
// Can be heavy to loop through all items, defer the task to run after interactions.
192+
const task = InteractionManager.runAfterInteractions(() => {
193+
if (!markedCellsRef.current) {
194+
return;
195+
}
196+
197+
const map = new Map<string, 1>();
198+
for (let i = 0; i < data.length; i++) {
199+
const key = keyExtractorPropRef.current?.(data[i], i) || i.toString();
200+
if (markedCellsRef.current.has(key)) {
201+
map.set(key, markedCellsRef.current.get(key)!);
202+
}
203+
}
204+
205+
markedCellsRef.current = map;
206+
});
207+
208+
return () => {
209+
task.cancel();
210+
};
211+
}, [data]);
212+
213+
const createCellKey = useCallback((cellKey: string) => {
214+
const mark = markedCellsRef.current?.get(cellKey) || 0;
215+
return `${cellKey}#${mark}`;
216+
}, []);
217+
172218
const listContextValue = useMemo(
173219
() => ({
174220
draggedHeight,
175221
currentIndex,
176222
draggedIndex,
177223
dragEndHandlers,
178224
activeIndex,
225+
itemLayoutAnimation: itemLayoutAnimationPropRef,
179226
cellAnimations: {
180227
...cellAnimations,
181228
transform:
@@ -195,6 +242,7 @@ const ReorderableListCore = <T,>(
195242
dragEndHandlers,
196243
activeIndex,
197244
cellAnimations,
245+
itemLayoutAnimationPropRef,
198246
scaleDefault,
199247
opacityDefault,
200248
],
@@ -394,9 +442,28 @@ const ReorderableListCore = <T,>(
394442
setTimeout(runOnUI(resetSharedValues), animationDurationProp.value);
395443
}, [resetSharedValues, animationDurationProp]);
396444

445+
const markCells = (fromIndex: number, toIndex: number) => {
446+
if (!markedCellsRef.current) {
447+
markedCellsRef.current = new Map();
448+
}
449+
450+
const start = Math.min(fromIndex, toIndex);
451+
const end = Math.max(fromIndex, toIndex);
452+
for (let i = start; i <= end; i++) {
453+
const cellKey = keyExtractorPropRef.current?.(data[i], i) || i.toString();
454+
if (!markedCellsRef.current.has(cellKey)) {
455+
markedCellsRef.current.set(cellKey, 1);
456+
} else {
457+
markedCellsRef.current.delete(cellKey);
458+
}
459+
}
460+
};
461+
397462
const reorder = (fromIndex: number, toIndex: number) => {
398463
runOnUI(resetSharedValues)();
399464

465+
markCells(fromIndex, toIndex);
466+
400467
if (fromIndex !== toIndex) {
401468
onReorder({from: fromIndex, to: toIndex});
402469
}
@@ -950,12 +1017,12 @@ const ReorderableListCore = <T,>(
9501017
onScroll || null,
9511018
]);
9521019

953-
const renderAnimatedCell = useCallback(
1020+
const renderAnimatedCell = useStableCallback(
9541021
({cellKey, ...props}: CellRendererProps<T>) => (
9551022
<ReorderableListCell
9561023
{...props}
9571024
// forces remount with key change on reorder
958-
key={`${cellKey}+${props.index}`}
1025+
key={createCellKey(cellKey)}
9591026
itemOffset={itemOffset}
9601027
itemHeight={itemHeight}
9611028
dragY={dragY}
@@ -964,14 +1031,6 @@ const ReorderableListCore = <T,>(
9641031
startDrag={startDrag}
9651032
/>
9661033
),
967-
[
968-
itemOffset,
969-
itemHeight,
970-
dragY,
971-
draggedIndex,
972-
animationDurationProp,
973-
startDrag,
974-
],
9751034
);
9761035

9771036
return (
@@ -981,6 +1040,7 @@ const ReorderableListCore = <T,>(
9811040
{...rest}
9821041
ref={handleRef}
9831042
data={data}
1043+
keyExtractor={keyExtractor}
9841044
CellRendererComponent={renderAnimatedCell}
9851045
onLayout={handleFlatListLayout}
9861046
onScroll={composedScrollHandler}

src/contexts/ReorderableListContext.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import React from 'react';
22

33
import type {SharedValue} from 'react-native-reanimated';
44

5-
import {ReorderableListCellAnimations} from '../types';
5+
import {ItemLayoutAnimation, ReorderableListCellAnimations} from '../types';
66

77
interface ReorderableListContextData {
88
currentIndex: SharedValue<number>;
99
draggedHeight: SharedValue<number>;
1010
dragEndHandlers: SharedValue<((from: number, to: number) => void)[][]>;
1111
activeIndex: number;
12+
itemLayoutAnimation: React.MutableRefObject<ItemLayoutAnimation | undefined>;
1213
cellAnimations: ReorderableListCellAnimations;
1314
}
1415

0 commit comments

Comments
 (0)