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
101 changes: 82 additions & 19 deletions demo/src/screens/componentScreens/SortableGridListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,46 +9,109 @@ 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} = 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}
/>
</View>
</View>
);
}
Expand Down
59 changes: 34 additions & 25 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,34 @@ function SortableItem(props: PropsWithChildren<SortableItemProps>) {
const tempTranslateX = useSharedValue(0);
const tempTranslateY = useSharedValue(0);

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

useDidUpdate(() => {
dataHasChanged.value = true;
}, [data]);

useAnimatedReaction(() => itemsOrder.value.indexOf(index), // Note: It doesn't work with the getItemOrderById util
useAnimatedReaction(() => getItemOrderById(itemsOrder.value, id),
(newOrder, prevOrder) => {
if (prevOrder !== null && newOrder !== prevOrder) {
if (dataHasChanged.value) {
dataHasChanged.value = false;
translateX.value = 0;
translateY.value = 0;
} else 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) {
} else if (newOrder === initialIndex.value) {
translateX.value = withTiming(0, animationConfig);
translateY.value = withTiming(0, animationConfig);
}
});

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

const longPressGesture = Gesture.LongPress()
.onStart(() => {
isFloating.value = true;
Expand Down Expand Up @@ -90,23 +98,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 +141,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 Down
41 changes: 25 additions & 16 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}
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
2 changes: 1 addition & 1 deletion src/components/sortableGridList/sortableGridList.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
"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",
Expand Down
22 changes: 22 additions & 0 deletions src/components/sortableGridList/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
import {FlatListProps, ScrollViewProps, StyleProp, ViewStyle} from 'react-native';
import Animated from 'react-native-reanimated';
import {GridListBaseProps} from '../gridList';

export type ItemLayout = {width: number; height: number} | undefined;
export type ItemsOrder = string[];

export type ItemProps<T> = T & {id: string};

export interface SortableGridListProps<T = any> extends GridListBaseProps, ScrollViewProps {
data: FlatListProps<ItemProps<T>>['data'];
renderItem: FlatListProps<ItemProps<T>>['renderItem'];
onOrderChange?: (newData: ItemProps<T>[], newOrder: ItemsOrder) => void;
}

export interface SortableItemProps {
id: string;
data: any;
itemsOrder: Animated.SharedValue<ItemsOrder>;
onChange: () => void;
style: StyleProp<ViewStyle>;
}
Loading