Skip to content

Handle data change and reset sortable grid list order #2020

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 10 commits into from
May 9, 2022
102 changes: 83 additions & 19 deletions demo/src/screens/componentScreens/SortableGridListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,110 @@ import {
Spacings,
BorderRadiuses,
GridListProps,
SortableGridListProps
SortableGridListProps,
Button
} from 'react-native-ui-lib';

import _ from 'lodash';
import products from '../../data/products';

const productsWithIds = products.map((product, index) => ({...product, id: index.toString()}));
type Item = typeof productsWithIds[0];

class SortableGridListScreen extends Component {
state = {
orientation: Constants.orientation
orientation: Constants.orientation,
selectedItemId: undefined,
items: productsWithIds,
removedItems: [] as Item[]
};
itemsOrdered = this.state.items;

onOrderChange: SortableGridListProps['onOrderChange'] = (_newOrderedData, newOrder) => {
onOrderChange: SortableGridListProps['onOrderChange'] = (newOrderedData, newOrder) => {
this.itemsOrdered = newOrderedData;
console.log('newOrder:', newOrder);
};

renderItem: GridListProps<typeof products[0]>['renderItem'] = ({item}) => {
selectItem = ({customValue: id}: {customValue: number}) => {
const {selectedItemId} = this.state;
if (id === selectedItemId) {
this.setState({selectedItemId: undefined});
} else {
this.setState({selectedItemId: id});
}
};

removeSelectedItem = () => {
const {selectedItemId, removedItems} = this.state;
if (!_.isUndefined(selectedItemId)) {
const newItems = [...this.itemsOrdered];
const removed = _.remove(newItems, item => item.id === selectedItemId);
removedItems.push(removed[0]);
this.setState({items: newItems, selectedItemId: undefined, removedItems});
this.itemsOrdered = newItems;
}
};

addItem = () => {
const {removedItems} = this.state;
const itemToAdd = removedItems.pop();
if (itemToAdd) {
this.itemsOrdered.push(itemToAdd);
const newItems = [...this.itemsOrdered];

this.setState({items: newItems, selectedItemId: undefined, removedItems});
}
};

renderItem: GridListProps<Item>['renderItem'] = ({item}) => {
const {selectedItemId} = this.state;
return (
<Card flex onPress={() => console.log('item press')}>
<Card.Section imageSource={{uri: item.mediaUrl}} imageStyle={styles.itemImage}/>
<Card flex onPress={this.selectItem} customValue={item.id} selected={item.id === selectedItemId}>
<Card.Section
imageSource={{uri: item.mediaUrl}}
imageStyle={styles.itemImage}
imageProps={{
customOverlayContent: (
<Text margin-s1 h1 orange30>
{item.id}
</Text>
)
}}
/>
</Card>
);
};

render() {
const {items, removedItems, selectedItemId} = this.state;
return (
<View flex>
<Text h1 margin-s5>
SortableGridList
</Text>
<SortableGridList
data={products}
renderItem={this.renderItem}
// numColumns={2}
maxItemWidth={140}
itemSpacing={Spacings.s3}
// itemSpacing={0}
listPadding={Spacings.s5}
// keepItemSize
contentContainerStyle={styles.list}
onOrderChange={this.onOrderChange}
/>
<View row center marginB-s2>
<Button
label="Add Item"
size={Button.sizes.xSmall}
disabled={removedItems.length === 0}
onPress={this.addItem}
/>
<Button label="Remove Item" size={Button.sizes.xSmall} marginL-s3 onPress={this.removeSelectedItem}/>
</View>
<View flex>
<SortableGridList
data={items}
renderItem={this.renderItem}
// numColumns={2}
maxItemWidth={140}
itemSpacing={Spacings.s3}
// itemSpacing={0}
listPadding={Spacings.s5}
// keepItemSize
contentContainerStyle={styles.list}
onOrderChange={this.onOrderChange}
extraData={selectedItemId}
/>
</View>
</View>
);
}
Expand Down
88 changes: 57 additions & 31 deletions src/components/sortableGridList/SortableItem.tsx
Original file line number Diff line number Diff line change
@@ -1,27 +1,24 @@
import React, {PropsWithChildren, useCallback} from 'react';
import {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
import {LayoutChangeEvent} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {
import {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming
} from 'react-native-reanimated';
import usePresenter, {ItemsOrder, animationConfig} from './usePresenter';
import _ from 'lodash';
import {useDidUpdate} from 'hooks';
import usePresenter, {animationConfig} from './usePresenter';
import {SortableItemProps} from './types';
import View from '../view';

interface SortableItemProps extends ReturnType<typeof usePresenter> {
index: number;
itemsOrder: Animated.SharedValue<ItemsOrder>;
onChange: () => void;
style: StyleProp<ViewStyle>;
}

function SortableItem(props: PropsWithChildren<SortableItemProps>) {
function SortableItem(props: PropsWithChildren<SortableItemProps & ReturnType<typeof usePresenter>>) {
const {
index,
data,
id,
itemsOrder,
onChange,
style,
Expand All @@ -31,6 +28,7 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
getTranslationByOrderChange,
updateItemLayout
} = props;
const initialIndex = useSharedValue(_.map(data, 'id').indexOf(id));
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);

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

const dataManuallyChanged = useSharedValue(false);

useDidUpdate(() => {
dataManuallyChanged.value = true;
initialIndex.value = _.map(data, 'id').indexOf(id);
}, [data]);

useAnimatedReaction(() => itemsOrder.value,
(currItemsOrder, prevItemsOrder) => {
// Note: Unfortunately itemsOrder sharedValue is being initialized on each render
// Therefore I added this extra check here that compares current and previous values
// See open issue: https://github.com/software-mansion/react-native-reanimated/issues/3224
if (prevItemsOrder === null || currItemsOrder.join(',') === prevItemsOrder.join(',')) {
return;
} else {
const newOrder = getItemOrderById(currItemsOrder, id);
const prevOrder = getItemOrderById(prevItemsOrder, id);

/* In case the order of the item has returned back to its initial index we reset its position */
if (newOrder === initialIndex.value) {
/* Reset without an animation when the change is due to manual data change */
if (dataManuallyChanged.value) {
translateX.value = 0;
translateY.value = 0;
/* Reset with an animation when the change id due to user reordering */
} else {
translateX.value = withTiming(0, animationConfig);
translateY.value = withTiming(0, animationConfig);
}
dataManuallyChanged.value = false;
/* Handle an order change, animate item to its new position */
} else if (newOrder !== prevOrder) {
const translation = getTranslationByOrderChange(newOrder, prevOrder);
translateX.value = withTiming(translateX.value + translation.x, animationConfig);
translateY.value = withTiming(translateY.value + translation.y, animationConfig);
}
}
});

const onLayout = useCallback((event: LayoutChangeEvent) => {
'worklet';
const {width, height} = event.nativeEvent.layout;
updateItemLayout(index, {width, height});
updateItemLayout({width, height});
}, []);

useAnimatedReaction(() => itemsOrder.value.indexOf(index), // Note: It doesn't work with the getItemOrderById util
(newOrder, prevOrder) => {
if (prevOrder !== null && newOrder !== prevOrder) {
const translation = getTranslationByOrderChange(newOrder, prevOrder);
translateX.value = withTiming(translateX.value + translation.x, animationConfig);
translateY.value = withTiming(translateY.value + translation.y, animationConfig);
} else if (newOrder === index) {
translateX.value = withTiming(0, animationConfig);
translateY.value = withTiming(0, animationConfig);
}
});

const longPressGesture = Gesture.LongPress()
.onStart(() => {
isFloating.value = true;
Expand Down Expand Up @@ -90,23 +115,23 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
translateY.value = tempTranslateY.value + event.translationY;

// Swapping items
const oldOrder = getItemOrderById(itemsOrder.value, index);
const newOrder = getOrderByPosition(translateX.value, translateY.value) + index;
const oldOrder = getItemOrderById(itemsOrder.value, id);
const newOrder = getOrderByPosition(translateX.value, translateY.value) + initialIndex.value;

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

if (itemIdToSwap !== undefined) {
const newItemsOrder = [...itemsOrder.value];
newItemsOrder[newOrder] = index;
newItemsOrder[newOrder] = id;
newItemsOrder[oldOrder] = itemIdToSwap;
itemsOrder.value = newItemsOrder;
}
}
})
.onEnd(() => {
const translation = getTranslationByOrderChange(getItemOrderById(itemsOrder.value, index),
getItemOrderById(tempItemsOrder.value, index));
const translation = getTranslationByOrderChange(getItemOrderById(itemsOrder.value, id),
getItemOrderById(tempItemsOrder.value, id));

translateX.value = withTiming(tempTranslateX.value + translation.x, animationConfig);
translateY.value = withTiming(tempTranslateY.value + translation.y, animationConfig);
Expand All @@ -133,6 +158,7 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
transform: [{translateX: translateX.value}, {translateY: translateY.value}, {scale}]
};
});

return (
<View reanimated style={[style, animatedStyle]} onLayout={onLayout}>
{/* @ts-expect-error related to children type issue that started on react 18 */}
Expand All @@ -143,4 +169,4 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
);
}

export default SortableItem;
export default React.memo(SortableItem);
43 changes: 26 additions & 17 deletions src/components/sortableGridList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,67 +1,76 @@
import React, {useCallback} from 'react';
import {StyleSheet, FlatListProps, ScrollView, ScrollViewProps, ListRenderItemInfo} from 'react-native';
import {StyleSheet, ScrollView, ListRenderItemInfo} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {useSharedValue} from 'react-native-reanimated';
import _ from 'lodash';
import {GridListBaseProps} from '../gridList';
import {useDidUpdate} from 'hooks';

import SortableItem from './SortableItem';
import usePresenter, {ItemsOrder} from './usePresenter';
import usePresenter from './usePresenter';
import {ItemsOrder, SortableGridListProps, ItemProps} from './types';

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

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

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

const {itemContainerStyle, numberOfColumns} = useGridLayout(props);
const {numColumns = DEFAULT_NUM_COLUMNS, itemSpacing = DEFAULT_ITEM_SPACINGS, data} = others;
const itemsOrder = useSharedValue<number[]>(_.map(props.data, (_v, i) => i));
const itemsOrder = useSharedValue<ItemsOrder>(generateItemsOrder(data));

useDidUpdate(() => {
itemsOrder.value = generateItemsOrder(data);
}, [data]);

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

const onChange = useCallback(() => {
const newData: T[] = [];
const newData: ItemProps<T>[] = [];
const dataByIds = _.mapKeys(data, 'id');
if (data?.length) {
itemsOrder.value.forEach(itemIndex => {
newData.push(data[itemIndex]);
itemsOrder.value.forEach(itemId => {
newData.push(dataByIds[itemId]);
});
}

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

const _renderItem = useCallback(({item, index}: ListRenderItemInfo<T>) => {
const _renderItem = useCallback(({item, index}: ListRenderItemInfo<ItemProps<T>>) => {
const lastItemInRow = (index + 1) % numberOfColumns === 0;

return (
<SortableItem
key={index}
key={item.id}
data={data}
{...presenter}
style={[itemContainerStyle, lastItemInRow && {marginRight: 0}]}
itemsOrder={itemsOrder}
index={index}
id={item.id}
onChange={onChange}
>
{/* @ts-expect-error */}
{renderItem({item, index})}
</SortableItem>
);
}, []);
},
[data]);

return (
<GestureHandlerRootView>
<ScrollView contentContainerStyle={[styles.listContent, contentContainerStyle]}>
{data?.map((item, index) => _renderItem({item, index} as ListRenderItemInfo<T>))}
{data?.map((item, index) => _renderItem({item, index} as ListRenderItemInfo<ItemProps<T>>))}
</ScrollView>
</GestureHandlerRootView>
);
}

export {SortableGridListProps};
export default SortableGridList;

const styles = StyleSheet.create({
Expand Down
7 changes: 6 additions & 1 deletion src/components/sortableGridList/sortableGridList.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,12 +6,17 @@
"extends": ["layoutsAndTemplates/GridList"],
"example": "https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/SortableGridListScreen.tsx",
"props": [
{"name": "data", "type": "any[]", "description": "Data of items"},
{"name": "data", "type": "any[] & {id: string}", "description": "Data of items with an id prop as unique identifier"},
{"name": "renderItem", "type": "FlatListProps['renderItem']", "description": "Custom render item callback"},
{
"name": "onOrderChange",
"type": "(newData: T[], newOrder: ItemsOrder) => void",
"description": "Order change callback"
},
{
"name": "extraData",
"type": "any",
"description": "Pass any extra data that should trigger a re-render"
}
],
"snippet": [
Expand Down
Loading