Skip to content

Commit cf29b80

Browse files
authored
SortableList - support dynamic data (#2057)
* SortableList - support items change * disableHaptic to enableHaptic * ItemId to ItemWithId * Add note about Android's haptic feedback * Send data via context
1 parent f468e42 commit cf29b80

File tree

7 files changed

+148
-62
lines changed

7 files changed

+148
-62
lines changed
Lines changed: 49 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,37 +1,69 @@
11
import _ from 'lodash';
2-
import React, {useCallback} from 'react';
2+
import React, {useCallback, useState, useRef} from 'react';
33
import {StyleSheet} from 'react-native';
4-
import {SortableList, View, TouchableOpacity, Text, Icon, Assets, Colors} from 'react-native-ui-lib';
4+
import {SortableList, View, TouchableOpacity, Text, Icon, Assets, Colors, Button} from 'react-native-ui-lib';
55
import {renderHeader} from '../ExampleScreenPresenter';
66

77
interface Item {
88
originalIndex: number;
9+
id: string;
910
}
1011

1112
const data = _.times(30, index => {
1213
return {
13-
originalIndex: index
14+
originalIndex: index,
15+
id: `${index}`
1416
};
1517
});
1618

1719
const SortableListScreen = () => {
20+
const [items, setItems] = useState<Item[]>(data);
21+
const [selectedItems, setSelectedItems] = useState<Item[]>([]);
22+
const [removedItems, setRemovedItems] = useState<Item[]>([]);
23+
const orderedItems = useRef<Item[]>(data);
24+
25+
const toggleItemSelection = useCallback((item: Item) => {
26+
if (selectedItems.includes(item)) {
27+
setSelectedItems(selectedItems.filter(selectedItem => ![item.id].includes(selectedItem.id)));
28+
} else {
29+
setSelectedItems(selectedItems.concat(item));
30+
}
31+
}, [selectedItems, setSelectedItems]);
32+
33+
const addItem = useCallback(() => {
34+
if (removedItems.length > 0) {
35+
orderedItems.current = orderedItems.current.concat(removedItems[0]);
36+
setItems(orderedItems.current);
37+
setRemovedItems(removedItems.slice(1));
38+
}
39+
}, [removedItems, setItems, setRemovedItems]);
40+
41+
const removeSelectedItems = useCallback(() => {
42+
setRemovedItems(removedItems.concat(selectedItems));
43+
setSelectedItems([]);
44+
orderedItems.current = orderedItems.current.filter(item => !selectedItems.includes(item));
45+
setItems(orderedItems.current);
46+
}, [setRemovedItems, removedItems, selectedItems, setItems, setSelectedItems]);
47+
1848
const keyExtractor = useCallback((item: Item) => {
19-
return `${item.originalIndex}`;
49+
return `${item.id}`;
2050
}, []);
2151

2252
const onOrderChange = useCallback((newData: Item[]) => {
2353
console.log('New order:', newData);
54+
orderedItems.current = newData;
2455
}, []);
2556

2657
const renderItem = useCallback(({item, index: _index}: {item: Item; index: number}) => {
58+
const isSelected = selectedItems.includes(item);
2759
return (
2860
<TouchableOpacity
29-
style={styles.itemContainer}
30-
onPress={() => console.log('Original index is', item.originalIndex)}
61+
style={[styles.itemContainer, isSelected && styles.selectedItemContainer]}
62+
onPress={() => toggleItemSelection(item)}
3163
// overriding the BG color to anything other than white will cause Android's elevation to fail
3264
// backgroundColor={Colors.red30}
3365
centerV
34-
marginH-page
66+
paddingH-page
3567
>
3668
<View flex row spread centerV>
3769
<Icon source={Assets.icons.demo.drag} tintColor={Colors.$iconDisabled}/>
@@ -42,13 +74,17 @@ const SortableListScreen = () => {
4274
</View>
4375
</TouchableOpacity>
4476
);
45-
}, []);
77+
}, [selectedItems, toggleItemSelection]);
4678

4779
return (
4880
<View flex bg-$backgroundDefault>
4981
{renderHeader('Sortable List', {'margin-10': true})}
82+
<View row center marginB-s2>
83+
<Button label="Add Item" size={Button.sizes.xSmall} disabled={removedItems.length === 0} onPress={addItem}/>
84+
<Button label="Remove Items" size={Button.sizes.xSmall} disabled={selectedItems.length === 0} marginL-s3 onPress={removeSelectedItems}/>
85+
</View>
5086
<View flex useSafeArea>
51-
<SortableList data={data} renderItem={renderItem} keyExtractor={keyExtractor} onOrderChange={onOrderChange}/>
87+
<SortableList data={items} renderItem={renderItem} keyExtractor={keyExtractor} onOrderChange={onOrderChange}/>
5288
</View>
5389
</View>
5490
);
@@ -60,5 +96,9 @@ const styles = StyleSheet.create({
6096
height: 52,
6197
borderColor: Colors.$outlineDefault,
6298
borderBottomWidth: 1
99+
},
100+
selectedItemContainer: {
101+
borderLeftColor: Colors.$outlinePrimary,
102+
borderLeftWidth: 5
63103
}
64104
});

src/components/sortableList/SortableList.api.json

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@
88
"props": [
99
{
1010
"name": "data",
11-
"type": "ItemT[]",
12-
"description": "The initial data of the list, do not update the data.",
11+
"type": "ItemT[] (ItemT extends {id: string})",
12+
"description": "The data of the list, with an id prop as unique identifier.",
1313
"required": true
1414
},
1515
{
@@ -19,9 +19,9 @@
1919
"required": true
2020
},
2121
{
22-
"name": "disableHaptic",
22+
"name": "enableHaptic",
2323
"type": "boolean",
24-
"description": "Whether to disable the haptic feedback."
24+
"description": "Whether to enable the haptic feedback.\n(please note that react-native-haptic-feedback does not support the specific haptic type on Android starting on an unknown version, you can use 1.8.2 for it to work properly)"
2525
}
2626
],
2727
"snippet": [

src/components/sortableList/SortableListContext.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,11 +3,12 @@ import {ViewProps} from 'react-native';
33
import {SharedValue} from 'react-native-reanimated';
44

55
interface SortableListContextType {
6-
itemsOrder: SharedValue<number[]>;
6+
data: any
7+
itemsOrder: SharedValue<string[]>;
78
onChange: () => void;
89
itemHeight: SharedValue<number>;
910
onItemLayout: ViewProps['onLayout'];
10-
disableHaptic?: boolean;
11+
enableHaptic?: boolean;
1112
}
1213

1314
// @ts-ignore

src/components/sortableList/SortableListItem.tsx

Lines changed: 58 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
/* eslint-disable react-hooks/exhaustive-deps */
2+
import {map} from 'lodash';
23
import React, {PropsWithChildren, useCallback, useContext} from 'react';
34
import {
45
useSharedValue,
@@ -12,16 +13,13 @@ import {
1213
import {GestureDetector, GestureUpdateEvent, PanGestureHandlerEventPayload} from 'react-native-gesture-handler';
1314
import View from '../view';
1415
import {Shadows, Colors} from '../../style';
16+
import {useDidUpdate} from 'hooks';
1517
import SortableListContext from './SortableListContext';
1618
import usePresenter from './usePresenter';
1719
import useDragAfterLongPressGesture from './useDragAfterLongPressGesture';
1820

1921
export interface SortableListItemProps {
2022
index: number;
21-
/**
22-
* Whether to disable the haptic feedback
23-
*/
24-
disableHaptic?: boolean;
2523
}
2624

2725
type Props = PropsWithChildren<SortableListItemProps>;
@@ -32,21 +30,49 @@ const animationConfig = {
3230
};
3331

3432
const SortableListItem = (props: Props) => {
35-
const {children, index, disableHaptic} = props;
33+
const {children, index} = props;
3634

37-
const {itemHeight, onItemLayout, itemsOrder, onChange} = useContext(SortableListContext);
35+
const {data, itemHeight, onItemLayout, itemsOrder, onChange, enableHaptic} =
36+
useContext(SortableListContext);
3837
const {getTranslationByIndexChange, getItemIndexById, getIndexByPosition, getIdByItemIndex} = usePresenter();
39-
const translateY = useSharedValue(0);
40-
const tempTranslateY = useSharedValue(0);
41-
const tempItemsOrder = useSharedValue(itemsOrder.value);
42-
43-
useAnimatedReaction(() => itemsOrder.value.indexOf(index),
44-
(newIndex, oldIndex) => {
45-
if (oldIndex !== null && oldIndex !== undefined && newIndex !== undefined && newIndex !== oldIndex) {
46-
const translation = getTranslationByIndexChange(newIndex, oldIndex, itemHeight.value);
47-
translateY.value = withTiming(translateY.value + translation, animationConfig);
48-
} else if (newIndex === index) {
49-
translateY.value = withTiming(0, animationConfig);
38+
const id: string = data[index].id;
39+
const initialIndex = useSharedValue<number>(map(data, 'id').indexOf(id));
40+
const translateY = useSharedValue<number>(0);
41+
const tempTranslateY = useSharedValue<number>(0);
42+
const tempItemsOrder = useSharedValue<string[]>(itemsOrder.value);
43+
const dataManuallyChanged = useSharedValue<boolean>(false);
44+
45+
useDidUpdate(() => {
46+
dataManuallyChanged.value = true;
47+
initialIndex.value = map(data, 'id').indexOf(id);
48+
}, [data]);
49+
50+
useAnimatedReaction(() => itemsOrder.value,
51+
(currItemsOrder, prevItemsOrder) => {
52+
// Note: Unfortunately itemsOrder sharedValue is being initialized on each render
53+
// Therefore I added this extra check here that compares current and previous values
54+
// See open issue: https://github.com/software-mansion/react-native-reanimated/issues/3224
55+
if (prevItemsOrder === null || currItemsOrder.join(',') === prevItemsOrder.join(',')) {
56+
return;
57+
} else {
58+
const newIndex = getItemIndexById(currItemsOrder, id);
59+
const oldIndex = getItemIndexById(prevItemsOrder, id);
60+
61+
/* In case the order of the item has returned back to its initial index we reset its position */
62+
if (newIndex === initialIndex.value) {
63+
/* Reset without an animation when the change is due to manual data change */
64+
if (dataManuallyChanged.value) {
65+
translateY.value = 0;
66+
dataManuallyChanged.value = false;
67+
/* Reset with an animation when the change id due to user reordering */
68+
} else {
69+
translateY.value = withTiming(0, animationConfig);
70+
}
71+
/* Handle an order change, animate item to its new position */
72+
} else if (newIndex !== oldIndex) {
73+
const translation = getTranslationByIndexChange(newIndex, oldIndex, itemHeight.value);
74+
translateY.value = withTiming(translateY.value + translation, animationConfig);
75+
}
5076
}
5177
});
5278

@@ -61,39 +87,39 @@ const SortableListItem = (props: Props) => {
6187
translateY.value = tempTranslateY.value + event.translationY;
6288

6389
// Swapping items
64-
const oldOrder = getItemIndexById(itemsOrder.value, index);
65-
const newOrder = getIndexByPosition(translateY.value, itemHeight.value) + index;
90+
const newIndex = getIndexByPosition(translateY.value, itemHeight.value) + initialIndex.value;
91+
const oldIndex = getItemIndexById(itemsOrder.value, id);
6692

67-
if (oldOrder !== newOrder) {
68-
const itemIdToSwap = getIdByItemIndex(itemsOrder.value, newOrder);
93+
if (newIndex !== oldIndex) {
94+
const itemIdToSwap = getIdByItemIndex(itemsOrder.value, newIndex);
6995

7096
if (itemIdToSwap !== undefined) {
7197
const newItemsOrder = [...itemsOrder.value];
72-
newItemsOrder[newOrder] = index;
73-
newItemsOrder[oldOrder] = itemIdToSwap;
98+
newItemsOrder[newIndex] = id;
99+
newItemsOrder[oldIndex] = itemIdToSwap;
74100
itemsOrder.value = newItemsOrder;
75101
}
76102
}
77103
}, []);
78104

79105
const onDragEnd = useCallback(() => {
80106
'worklet';
81-
const translation = getTranslationByIndexChange(getItemIndexById(itemsOrder.value, index),
82-
getItemIndexById(tempItemsOrder.value, index),
107+
const translation = getTranslationByIndexChange(getItemIndexById(itemsOrder.value, id),
108+
getItemIndexById(tempItemsOrder.value, id),
83109
itemHeight.value);
84110

85-
translateY.value = withTiming(tempTranslateY.value + translation, animationConfig);
86-
87-
if (tempItemsOrder.value.toString() !== itemsOrder.value.toString()) {
88-
runOnJS(onChange)();
89-
}
111+
translateY.value = withTiming(tempTranslateY.value + translation, animationConfig, () => {
112+
if (tempItemsOrder.value.toString() !== itemsOrder.value.toString()) {
113+
runOnJS(onChange)();
114+
}
115+
});
90116
}, []);
91117

92118
const {dragAfterLongPressGesture, isFloating} = useDragAfterLongPressGesture({
93119
onDragStart,
94120
onDragUpdate,
95121
onDragEnd,
96-
hapticComponentName: disableHaptic ? null : 'SortableList'
122+
hapticComponentName: enableHaptic ? 'SortableList' : null
97123
});
98124

99125
const draggedAnimatedStyle = useAnimatedStyle(() => {
@@ -129,4 +155,4 @@ const SortableListItem = (props: Props) => {
129155
);
130156
};
131157

132-
export default SortableListItem;
158+
export default React.memo(SortableListItem);

src/components/sortableList/index.tsx

Lines changed: 31 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,18 @@
11
/* eslint-disable react-hooks/exhaustive-deps */
2-
import {map} from 'lodash';
2+
import {map, mapKeys} from 'lodash';
33
import React, {useMemo, useCallback} from 'react';
44
import {FlatList, FlatListProps, LayoutChangeEvent} from 'react-native';
55
import {useSharedValue} from 'react-native-reanimated';
66
import {GestureHandlerRootView} from 'react-native-gesture-handler';
77
import SortableListContext from './SortableListContext';
8-
import SortableListItem, {SortableListItemProps} from './SortableListItem';
8+
import SortableListItem from './SortableListItem';
9+
import {useDidUpdate} from 'hooks';
910

10-
export interface SortableListProps<ItemT>
11-
extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'>,
12-
Pick<SortableListItemProps, 'disableHaptic'> {
11+
interface ItemWithId {
12+
id: string;
13+
}
14+
15+
export interface SortableListProps<ItemT extends ItemWithId> extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'> {
1316
/**
1417
* The data of the list, do not update the data.
1518
*/
@@ -18,19 +21,33 @@ export interface SortableListProps<ItemT>
1821
* A callback to get the new order (or swapped items).
1922
*/
2023
onOrderChange: (data: ItemT[] /* TODO: add more data? */) => void;
24+
/**
25+
* Whether to enable the haptic feedback
26+
* (please note that react-native-haptic-feedback does not support the specific haptic type on Android starting on an unknown version, you can use 1.8.2 for it to work properly)
27+
*/
28+
enableHaptic?: boolean;
2129
}
2230

23-
const SortableList = <ItemT extends unknown>(props: SortableListProps<ItemT>) => {
24-
const {data, onOrderChange, disableHaptic, ...others} = props;
31+
function generateItemsOrder<ItemT extends ItemWithId>(data: SortableListProps<ItemT>['data']) {
32+
return map(data, item => item.id);
33+
}
34+
35+
const SortableList = <ItemT extends ItemWithId>(props: SortableListProps<ItemT>) => {
36+
const {data, onOrderChange, enableHaptic, ...others} = props;
2537

26-
const itemsOrder = useSharedValue<number[]>(map(props.data, (_v, i) => i));
27-
const itemHeight = useSharedValue<number>(1);
38+
const itemsOrder = useSharedValue<string[]>(generateItemsOrder(data));
39+
const itemHeight = useSharedValue<number>(52);
40+
41+
useDidUpdate(() => {
42+
itemsOrder.value = generateItemsOrder(data);
43+
}, [data]);
2844

2945
const onChange = useCallback(() => {
3046
const newData: ItemT[] = [];
47+
const dataByIds = mapKeys(data, 'id');
3148
if (data?.length) {
32-
itemsOrder.value.forEach(itemIndex => {
33-
newData.push(data[itemIndex]);
49+
itemsOrder.value.forEach(itemId => {
50+
newData.push(dataByIds[itemId]);
3451
});
3552
}
3653

@@ -45,13 +62,14 @@ const SortableList = <ItemT extends unknown>(props: SortableListProps<ItemT>) =>
4562

4663
const context = useMemo(() => {
4764
return {
65+
data,
4866
itemsOrder,
4967
onChange,
5068
itemHeight,
5169
onItemLayout,
52-
disableHaptic
70+
enableHaptic
5371
};
54-
}, []);
72+
}, [data]);
5573

5674
return (
5775
<GestureHandlerRootView>

src/components/sortableList/useDragAfterLongPressGesture.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,7 @@ const useDragAfterLongPressGesture = (props: Props) => {
3838
isDragging.value = true;
3939
stateManager.activate();
4040
if (hapticComponentName) {
41+
// TODO: this should be changed IMO since Android does not support this type - consulting UX
4142
runOnJS(HapticService.triggerHaptic)(HapticType.selection, hapticComponentName);
4243
}
4344
} else {

src/components/sortableList/usePresenter.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ export const animationConfig = {
55
duration: 350
66
};
77

8-
export type ItemsOrder = number[];
8+
type ItemsOrder = string[];
99

1010
const usePresenter = () => {
1111
return {
@@ -21,7 +21,7 @@ const usePresenter = () => {
2121
'worklet';
2222
return Math.round(positionY / itemHeight);
2323
},
24-
getItemIndexById: (itemsOrder: ItemsOrder, itemId: number) => {
24+
getItemIndexById: (itemsOrder: ItemsOrder, itemId: string) => {
2525
'worklet';
2626
return itemsOrder.indexOf(itemId);
2727
},

0 commit comments

Comments
 (0)