Skip to content

Commit ae3692a

Browse files
authored
Handle data change and reset sortable grid list order (#2020)
* Handle data change and reset sortable grid list order * Supprort data changes in SortableGridList * Fix use case of adding new items to SortableGridList * Fix flexness issue on Android * Extract items order generation to a function * move all types to a dedicate file * Fix issues with data changes * Add comments on the code than handle items reordering
1 parent 03adf3f commit ae3692a

File tree

6 files changed

+205
-83
lines changed

6 files changed

+205
-83
lines changed

demo/src/screens/componentScreens/SortableGridListScreen.tsx

Lines changed: 83 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -9,46 +9,110 @@ import {
99
Spacings,
1010
BorderRadiuses,
1111
GridListProps,
12-
SortableGridListProps
12+
SortableGridListProps,
13+
Button
1314
} from 'react-native-ui-lib';
14-
15+
import _ from 'lodash';
1516
import products from '../../data/products';
1617

18+
const productsWithIds = products.map((product, index) => ({...product, id: index.toString()}));
19+
type Item = typeof productsWithIds[0];
20+
1721
class SortableGridListScreen extends Component {
1822
state = {
19-
orientation: Constants.orientation
23+
orientation: Constants.orientation,
24+
selectedItemId: undefined,
25+
items: productsWithIds,
26+
removedItems: [] as Item[]
2027
};
28+
itemsOrdered = this.state.items;
2129

22-
onOrderChange: SortableGridListProps['onOrderChange'] = (_newOrderedData, newOrder) => {
30+
onOrderChange: SortableGridListProps['onOrderChange'] = (newOrderedData, newOrder) => {
31+
this.itemsOrdered = newOrderedData;
2332
console.log('newOrder:', newOrder);
2433
};
2534

26-
renderItem: GridListProps<typeof products[0]>['renderItem'] = ({item}) => {
35+
selectItem = ({customValue: id}: {customValue: number}) => {
36+
const {selectedItemId} = this.state;
37+
if (id === selectedItemId) {
38+
this.setState({selectedItemId: undefined});
39+
} else {
40+
this.setState({selectedItemId: id});
41+
}
42+
};
43+
44+
removeSelectedItem = () => {
45+
const {selectedItemId, removedItems} = this.state;
46+
if (!_.isUndefined(selectedItemId)) {
47+
const newItems = [...this.itemsOrdered];
48+
const removed = _.remove(newItems, item => item.id === selectedItemId);
49+
removedItems.push(removed[0]);
50+
this.setState({items: newItems, selectedItemId: undefined, removedItems});
51+
this.itemsOrdered = newItems;
52+
}
53+
};
54+
55+
addItem = () => {
56+
const {removedItems} = this.state;
57+
const itemToAdd = removedItems.pop();
58+
if (itemToAdd) {
59+
this.itemsOrdered.push(itemToAdd);
60+
const newItems = [...this.itemsOrdered];
61+
62+
this.setState({items: newItems, selectedItemId: undefined, removedItems});
63+
}
64+
};
65+
66+
renderItem: GridListProps<Item>['renderItem'] = ({item}) => {
67+
const {selectedItemId} = this.state;
2768
return (
28-
<Card flex onPress={() => console.log('item press')}>
29-
<Card.Section imageSource={{uri: item.mediaUrl}} imageStyle={styles.itemImage}/>
69+
<Card flex onPress={this.selectItem} customValue={item.id} selected={item.id === selectedItemId}>
70+
<Card.Section
71+
imageSource={{uri: item.mediaUrl}}
72+
imageStyle={styles.itemImage}
73+
imageProps={{
74+
customOverlayContent: (
75+
<Text margin-s1 h1 orange30>
76+
{item.id}
77+
</Text>
78+
)
79+
}}
80+
/>
3081
</Card>
3182
);
3283
};
3384

3485
render() {
86+
const {items, removedItems, selectedItemId} = this.state;
3587
return (
3688
<View flex>
3789
<Text h1 margin-s5>
3890
SortableGridList
3991
</Text>
40-
<SortableGridList
41-
data={products}
42-
renderItem={this.renderItem}
43-
// numColumns={2}
44-
maxItemWidth={140}
45-
itemSpacing={Spacings.s3}
46-
// itemSpacing={0}
47-
listPadding={Spacings.s5}
48-
// keepItemSize
49-
contentContainerStyle={styles.list}
50-
onOrderChange={this.onOrderChange}
51-
/>
92+
<View row center marginB-s2>
93+
<Button
94+
label="Add Item"
95+
size={Button.sizes.xSmall}
96+
disabled={removedItems.length === 0}
97+
onPress={this.addItem}
98+
/>
99+
<Button label="Remove Item" size={Button.sizes.xSmall} marginL-s3 onPress={this.removeSelectedItem}/>
100+
</View>
101+
<View flex>
102+
<SortableGridList
103+
data={items}
104+
renderItem={this.renderItem}
105+
// numColumns={2}
106+
maxItemWidth={140}
107+
itemSpacing={Spacings.s3}
108+
// itemSpacing={0}
109+
listPadding={Spacings.s5}
110+
// keepItemSize
111+
contentContainerStyle={styles.list}
112+
onOrderChange={this.onOrderChange}
113+
extraData={selectedItemId}
114+
/>
115+
</View>
52116
</View>
53117
);
54118
}

src/components/sortableGridList/SortableItem.tsx

Lines changed: 57 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1,27 +1,24 @@
11
import React, {PropsWithChildren, useCallback} from 'react';
2-
import {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
2+
import {LayoutChangeEvent} from 'react-native';
33
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
4-
import Animated, {
4+
import {
55
runOnJS,
66
useAnimatedReaction,
77
useAnimatedStyle,
88
useSharedValue,
99
withSpring,
1010
withTiming
1111
} from 'react-native-reanimated';
12-
import usePresenter, {ItemsOrder, animationConfig} from './usePresenter';
12+
import _ from 'lodash';
13+
import {useDidUpdate} from 'hooks';
14+
import usePresenter, {animationConfig} from './usePresenter';
15+
import {SortableItemProps} from './types';
1316
import View from '../view';
1417

15-
interface SortableItemProps extends ReturnType<typeof usePresenter> {
16-
index: number;
17-
itemsOrder: Animated.SharedValue<ItemsOrder>;
18-
onChange: () => void;
19-
style: StyleProp<ViewStyle>;
20-
}
21-
22-
function SortableItem(props: PropsWithChildren<SortableItemProps>) {
18+
function SortableItem(props: PropsWithChildren<SortableItemProps & ReturnType<typeof usePresenter>>) {
2319
const {
24-
index,
20+
data,
21+
id,
2522
itemsOrder,
2623
onChange,
2724
style,
@@ -31,6 +28,7 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
3128
getTranslationByOrderChange,
3229
updateItemLayout
3330
} = props;
31+
const initialIndex = useSharedValue(_.map(data, 'id').indexOf(id));
3432
const translateX = useSharedValue(0);
3533
const translateY = useSharedValue(0);
3634

@@ -40,24 +38,51 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
4038
const tempTranslateX = useSharedValue(0);
4139
const tempTranslateY = useSharedValue(0);
4240

41+
const dataManuallyChanged = useSharedValue(false);
42+
43+
useDidUpdate(() => {
44+
dataManuallyChanged.value = true;
45+
initialIndex.value = _.map(data, 'id').indexOf(id);
46+
}, [data]);
47+
48+
useAnimatedReaction(() => itemsOrder.value,
49+
(currItemsOrder, prevItemsOrder) => {
50+
// Note: Unfortunately itemsOrder sharedValue is being initialized on each render
51+
// Therefore I added this extra check here that compares current and previous values
52+
// See open issue: https://github.com/software-mansion/react-native-reanimated/issues/3224
53+
if (prevItemsOrder === null || currItemsOrder.join(',') === prevItemsOrder.join(',')) {
54+
return;
55+
} else {
56+
const newOrder = getItemOrderById(currItemsOrder, id);
57+
const prevOrder = getItemOrderById(prevItemsOrder, id);
58+
59+
/* In case the order of the item has returned back to its initial index we reset its position */
60+
if (newOrder === initialIndex.value) {
61+
/* Reset without an animation when the change is due to manual data change */
62+
if (dataManuallyChanged.value) {
63+
translateX.value = 0;
64+
translateY.value = 0;
65+
/* Reset with an animation when the change id due to user reordering */
66+
} else {
67+
translateX.value = withTiming(0, animationConfig);
68+
translateY.value = withTiming(0, animationConfig);
69+
}
70+
dataManuallyChanged.value = false;
71+
/* Handle an order change, animate item to its new position */
72+
} else if (newOrder !== prevOrder) {
73+
const translation = getTranslationByOrderChange(newOrder, prevOrder);
74+
translateX.value = withTiming(translateX.value + translation.x, animationConfig);
75+
translateY.value = withTiming(translateY.value + translation.y, animationConfig);
76+
}
77+
}
78+
});
79+
4380
const onLayout = useCallback((event: LayoutChangeEvent) => {
4481
'worklet';
4582
const {width, height} = event.nativeEvent.layout;
46-
updateItemLayout(index, {width, height});
83+
updateItemLayout({width, height});
4784
}, []);
4885

49-
useAnimatedReaction(() => itemsOrder.value.indexOf(index), // Note: It doesn't work with the getItemOrderById util
50-
(newOrder, prevOrder) => {
51-
if (prevOrder !== null && newOrder !== prevOrder) {
52-
const translation = getTranslationByOrderChange(newOrder, prevOrder);
53-
translateX.value = withTiming(translateX.value + translation.x, animationConfig);
54-
translateY.value = withTiming(translateY.value + translation.y, animationConfig);
55-
} else if (newOrder === index) {
56-
translateX.value = withTiming(0, animationConfig);
57-
translateY.value = withTiming(0, animationConfig);
58-
}
59-
});
60-
6186
const longPressGesture = Gesture.LongPress()
6287
.onStart(() => {
6388
isFloating.value = true;
@@ -90,23 +115,23 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
90115
translateY.value = tempTranslateY.value + event.translationY;
91116

92117
// Swapping items
93-
const oldOrder = getItemOrderById(itemsOrder.value, index);
94-
const newOrder = getOrderByPosition(translateX.value, translateY.value) + index;
118+
const oldOrder = getItemOrderById(itemsOrder.value, id);
119+
const newOrder = getOrderByPosition(translateX.value, translateY.value) + initialIndex.value;
95120

96121
if (oldOrder !== newOrder) {
97122
const itemIdToSwap = getIdByItemOrder(itemsOrder.value, newOrder);
98123

99124
if (itemIdToSwap !== undefined) {
100125
const newItemsOrder = [...itemsOrder.value];
101-
newItemsOrder[newOrder] = index;
126+
newItemsOrder[newOrder] = id;
102127
newItemsOrder[oldOrder] = itemIdToSwap;
103128
itemsOrder.value = newItemsOrder;
104129
}
105130
}
106131
})
107132
.onEnd(() => {
108-
const translation = getTranslationByOrderChange(getItemOrderById(itemsOrder.value, index),
109-
getItemOrderById(tempItemsOrder.value, index));
133+
const translation = getTranslationByOrderChange(getItemOrderById(itemsOrder.value, id),
134+
getItemOrderById(tempItemsOrder.value, id));
110135

111136
translateX.value = withTiming(tempTranslateX.value + translation.x, animationConfig);
112137
translateY.value = withTiming(tempTranslateY.value + translation.y, animationConfig);
@@ -133,6 +158,7 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
133158
transform: [{translateX: translateX.value}, {translateY: translateY.value}, {scale}]
134159
};
135160
});
161+
136162
return (
137163
<View reanimated style={[style, animatedStyle]} onLayout={onLayout}>
138164
{/* @ts-expect-error related to children type issue that started on react 18 */}
@@ -143,4 +169,4 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
143169
);
144170
}
145171

146-
export default SortableItem;
172+
export default React.memo(SortableItem);

src/components/sortableGridList/index.tsx

Lines changed: 26 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -1,67 +1,76 @@
11
import React, {useCallback} from 'react';
2-
import {StyleSheet, FlatListProps, ScrollView, ScrollViewProps, ListRenderItemInfo} from 'react-native';
2+
import {StyleSheet, ScrollView, ListRenderItemInfo} from 'react-native';
33
import {GestureHandlerRootView} from 'react-native-gesture-handler';
44
import {useSharedValue} from 'react-native-reanimated';
55
import _ from 'lodash';
6-
import {GridListBaseProps} from '../gridList';
6+
import {useDidUpdate} from 'hooks';
7+
78
import SortableItem from './SortableItem';
8-
import usePresenter, {ItemsOrder} from './usePresenter';
9+
import usePresenter from './usePresenter';
10+
import {ItemsOrder, SortableGridListProps, ItemProps} from './types';
911

1012
import useGridLayout, {DEFAULT_ITEM_SPACINGS, DEFAULT_NUM_COLUMNS} from '../gridList/useGridLayout';
1113

12-
export interface SortableGridListProps<T = any> extends GridListBaseProps, ScrollViewProps {
13-
data: FlatListProps<T>['data'];
14-
renderItem: FlatListProps<T>['renderItem'];
15-
onOrderChange?: (newData: T[], newOrder: ItemsOrder) => void;
14+
function generateItemsOrder(data: SortableGridListProps['data']) {
15+
return _.map(data, item => item.id);
1616
}
1717

1818
function SortableGridList<T = any>(props: SortableGridListProps<T>) {
1919
const {renderItem, onOrderChange, contentContainerStyle, ...others} = props;
2020

2121
const {itemContainerStyle, numberOfColumns} = useGridLayout(props);
2222
const {numColumns = DEFAULT_NUM_COLUMNS, itemSpacing = DEFAULT_ITEM_SPACINGS, data} = others;
23-
const itemsOrder = useSharedValue<number[]>(_.map(props.data, (_v, i) => i));
23+
const itemsOrder = useSharedValue<ItemsOrder>(generateItemsOrder(data));
24+
25+
useDidUpdate(() => {
26+
itemsOrder.value = generateItemsOrder(data);
27+
}, [data]);
2428

2529
// TODO: Get the number of columns from GridList calculation somehow
26-
const presenter = usePresenter(props.data?.length ?? 0, numColumns, itemSpacing);
30+
const presenter = usePresenter(numColumns, itemSpacing);
2731

2832
const onChange = useCallback(() => {
29-
const newData: T[] = [];
33+
const newData: ItemProps<T>[] = [];
34+
const dataByIds = _.mapKeys(data, 'id');
3035
if (data?.length) {
31-
itemsOrder.value.forEach(itemIndex => {
32-
newData.push(data[itemIndex]);
36+
itemsOrder.value.forEach(itemId => {
37+
newData.push(dataByIds[itemId]);
3338
});
3439
}
3540

3641
onOrderChange?.(newData, itemsOrder.value);
3742
}, [onOrderChange, data]);
3843

39-
const _renderItem = useCallback(({item, index}: ListRenderItemInfo<T>) => {
44+
const _renderItem = useCallback(({item, index}: ListRenderItemInfo<ItemProps<T>>) => {
4045
const lastItemInRow = (index + 1) % numberOfColumns === 0;
46+
4147
return (
4248
<SortableItem
43-
key={index}
49+
key={item.id}
50+
data={data}
4451
{...presenter}
4552
style={[itemContainerStyle, lastItemInRow && {marginRight: 0}]}
4653
itemsOrder={itemsOrder}
47-
index={index}
54+
id={item.id}
4855
onChange={onChange}
4956
>
5057
{/* @ts-expect-error */}
5158
{renderItem({item, index})}
5259
</SortableItem>
5360
);
54-
}, []);
61+
},
62+
[data]);
5563

5664
return (
5765
<GestureHandlerRootView>
5866
<ScrollView contentContainerStyle={[styles.listContent, contentContainerStyle]}>
59-
{data?.map((item, index) => _renderItem({item, index} as ListRenderItemInfo<T>))}
67+
{data?.map((item, index) => _renderItem({item, index} as ListRenderItemInfo<ItemProps<T>>))}
6068
</ScrollView>
6169
</GestureHandlerRootView>
6270
);
6371
}
6472

73+
export {SortableGridListProps};
6574
export default SortableGridList;
6675

6776
const styles = StyleSheet.create({

src/components/sortableGridList/sortableGridList.api.json

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,17 @@
66
"extends": ["layoutsAndTemplates/GridList"],
77
"example": "https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/SortableGridListScreen.tsx",
88
"props": [
9-
{"name": "data", "type": "any[]", "description": "Data of items"},
9+
{"name": "data", "type": "any[] & {id: string}", "description": "Data of items with an id prop as unique identifier"},
1010
{"name": "renderItem", "type": "FlatListProps['renderItem']", "description": "Custom render item callback"},
1111
{
1212
"name": "onOrderChange",
1313
"type": "(newData: T[], newOrder: ItemsOrder) => void",
1414
"description": "Order change callback"
15+
},
16+
{
17+
"name": "extraData",
18+
"type": "any",
19+
"description": "Pass any extra data that should trigger a re-render"
1520
}
1621
],
1722
"snippet": [

0 commit comments

Comments
 (0)