Skip to content

Commit 3f36c7a

Browse files
ethansharM-i-k-e-llidord-wix
authored
Feat/sortable grid list component (#1918)
* Initial implementation of GridList component based on old GridView * Support orientation change and keepItemSize in GridList * Initial implementation of SortableGridList component * Adjust SortableGridList to work with ScrollView * Update src/components/gridList/gridList.api.json Co-authored-by: Miki Leib <[email protected]> * Move grid list logic to useGridLayout hook and fix code review * Minor fixes * Update src/components/gridList/gridList.api.json Co-authored-by: Lidor Dafna <[email protected]> * Code review fixes * Fix issue with canceling drag before it starts * Code review fixes * Add api json file Co-authored-by: Miki Leib <[email protected]> Co-authored-by: Lidor Dafna <[email protected]>
1 parent c6ac90e commit 3f36c7a

File tree

12 files changed

+428
-2
lines changed

12 files changed

+428
-2
lines changed

demo/src/screens/MenuStructure.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -113,6 +113,7 @@ export const navigationData = {
113113
},
114114
{title: 'Wizard', tags: 'wizard', screen: 'unicorn.components.WizardScreen'},
115115
{title: 'GridList', tags: 'grid list', screen: 'unicorn.components.GridListScreen'},
116+
{title: 'SortableGridList', tags: 'sort grid list drag', screen: 'unicorn.components.SortableGridListScreen'},
116117
{title: 'GridView', tags: 'grid view', screen: 'unicorn.components.GridViewScreen'}
117118
]
118119
},
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import React, {Component} from 'react';
2+
import {StyleSheet} from 'react-native';
3+
import {
4+
View,
5+
Text,
6+
Constants,
7+
SortableGridList,
8+
Card,
9+
Spacings,
10+
BorderRadiuses,
11+
GridListProps,
12+
SortableGridListProps
13+
} from 'react-native-ui-lib';
14+
15+
import products from '../../data/products';
16+
17+
class SortableGridListScreen extends Component {
18+
state = {
19+
orientation: Constants.orientation
20+
};
21+
22+
onOrderChange: SortableGridListProps['onOrderChange'] = (_newOrderedData, newOrder) => {
23+
console.log('newOrder:', newOrder);
24+
};
25+
26+
renderItem: GridListProps<typeof products[0]>['renderItem'] = ({item}) => {
27+
return (
28+
<Card flex onPress={() => console.log('item press')}>
29+
<Card.Section imageSource={{uri: item.mediaUrl}} imageStyle={styles.itemImage}/>
30+
</Card>
31+
);
32+
};
33+
34+
render() {
35+
return (
36+
<View flex>
37+
<Text h1 margin-s5>
38+
SortableGridList
39+
</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+
/>
52+
</View>
53+
);
54+
}
55+
}
56+
57+
const styles = StyleSheet.create({
58+
list: {
59+
padding: Spacings.s5
60+
},
61+
itemImage: {
62+
width: '100%',
63+
// height: 85,
64+
height: 108.7,
65+
borderRadius: BorderRadiuses.br10
66+
}
67+
});
68+
69+
export default SortableGridListScreen;

demo/src/screens/componentScreens/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,7 @@ export function registerScreens(registrar) {
4545
registrar('unicorn.components.SharedTransitionScreen', () => require('./SharedTransitionScreen').default);
4646
registrar('unicorn.components.SkeletonViewScreen', () => require('./SkeletonViewScreen').default);
4747
registrar('unicorn.components.SliderScreen', () => require('./SliderScreen').default);
48+
registrar('unicorn.components.SortableGridListScreen', () => require('./SortableGridListScreen').default);
4849
registrar('unicorn.components.StackAggregatorScreen', () => require('./StackAggregatorScreen').default);
4950
registrar('unicorn.components.StepperScreen', () => require('./StepperScreen').default);
5051
registrar('unicorn.components.SwitchScreen', () => require('./SwitchScreen').default);
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
import { PropsWithChildren } from 'react';
2+
import { StyleProp, ViewStyle } from 'react-native';
3+
import Animated from 'react-native-reanimated';
4+
import usePresenter, { ItemsOrder } from './usePresenter';
5+
interface SortableItemProps extends ReturnType<typeof usePresenter> {
6+
index: number;
7+
itemsOrder: Animated.SharedValue<ItemsOrder>;
8+
onChange: () => void;
9+
style: StyleProp<ViewStyle>;
10+
}
11+
declare function SortableItem(props: PropsWithChildren<SortableItemProps>): JSX.Element;
12+
export default SortableItem;
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
/// <reference types="react" />
2+
import { FlatListProps, ScrollViewProps } from 'react-native';
3+
import { GridListBaseProps } from '../gridList';
4+
import { ItemsOrder } from './usePresenter';
5+
export interface SortableGridListProps<T = any> extends GridListBaseProps, ScrollViewProps {
6+
data: FlatListProps<T>['data'];
7+
renderItem: FlatListProps<T>['renderItem'];
8+
onOrderChange?: (newData: T[], newOrder: ItemsOrder) => void;
9+
}
10+
declare function SortableGridList<T = any>(props: SortableGridListProps<T>): JSX.Element;
11+
export default SortableGridList;
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
export declare const WINDOW_WIDTH: number;
2+
export declare const DEFAULT_NO_OF_COLUMNS = 3;
3+
export declare const getItemSize: (numOfColumns: number, viewWidth: number) => number;
4+
export declare const animationConfig: {
5+
easing: (value: number) => number;
6+
duration: number;
7+
};
8+
export declare type ItemsLayouts = ({
9+
width: number;
10+
height: number;
11+
} | undefined)[];
12+
export declare type ItemsOrder = number[];
13+
declare const usePresenter: (itemsSize: number, numOfColumns: number, itemSpacing: number) => {
14+
updateItemLayout: (index: number, layout: {
15+
width: number;
16+
height: number;
17+
}) => void;
18+
getTranslationByOrderChange: (newOrder: number, oldOrder: number) => {
19+
x: number;
20+
y: number;
21+
};
22+
getOrderByPosition: (positionX: number, positionY: number) => number;
23+
getItemOrderById: (itemsOrder: ItemsOrder, itemId: number) => number;
24+
getIdByItemOrder: (itemsOrder: ItemsOrder, orderIndex: number) => number;
25+
};
26+
export default usePresenter;

src/components/gridList/gridList.api.json

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,7 @@
3434
" maxItemWidth={140$2}",
3535
" numColumns={2$3}",
3636
" itemSpacing={Spacings.s3$4}",
37-
" containerWidth={Constants.screenWidth - (Spacings.s5 * 2)}",
38-
" contentContainerStyle={{padding: Spacings.s5}}",
37+
" listPadding={Spacings.s5}",
3938
"/>"
4039
]
4140
}
Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
import React, {PropsWithChildren, useCallback} from 'react';
2+
import {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
3+
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
4+
import Animated, {
5+
runOnJS,
6+
useAnimatedReaction,
7+
useAnimatedStyle,
8+
useSharedValue,
9+
withSpring,
10+
withTiming
11+
} from 'react-native-reanimated';
12+
import usePresenter, {ItemsOrder, animationConfig} from './usePresenter';
13+
import View from '../view';
14+
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>) {
23+
const {
24+
index,
25+
itemsOrder,
26+
onChange,
27+
style,
28+
getItemOrderById,
29+
getOrderByPosition,
30+
getIdByItemOrder,
31+
getTranslationByOrderChange,
32+
updateItemLayout
33+
} = props;
34+
const translateX = useSharedValue(0);
35+
const translateY = useSharedValue(0);
36+
37+
const isFloating = useSharedValue(false);
38+
const isDragging = useSharedValue(false);
39+
const tempItemsOrder = useSharedValue(itemsOrder.value);
40+
const tempTranslateX = useSharedValue(0);
41+
const tempTranslateY = useSharedValue(0);
42+
43+
const onLayout = useCallback((event: LayoutChangeEvent) => {
44+
'worklet';
45+
const {width, height} = event.nativeEvent.layout;
46+
updateItemLayout(index, {width, height});
47+
}, []);
48+
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+
61+
const longPressGesture = Gesture.LongPress()
62+
.onStart(() => {
63+
isFloating.value = true;
64+
})
65+
.onTouchesCancelled(() => {
66+
if (!isDragging.value) {
67+
isFloating.value = false;
68+
}
69+
})
70+
.minDuration(250);
71+
72+
const dragGesture = Gesture.Pan()
73+
.manualActivation(true)
74+
.onTouchesMove((_e, state) => {
75+
if (isFloating.value) {
76+
isDragging.value = true;
77+
state.activate();
78+
} else {
79+
isDragging.value = false;
80+
state.fail();
81+
}
82+
})
83+
.onStart(() => {
84+
tempTranslateX.value = translateX.value;
85+
tempTranslateY.value = translateY.value;
86+
tempItemsOrder.value = itemsOrder.value;
87+
})
88+
.onUpdate(event => {
89+
translateX.value = tempTranslateX.value + event.translationX;
90+
translateY.value = tempTranslateY.value + event.translationY;
91+
92+
// Swapping items
93+
const oldOrder = getItemOrderById(itemsOrder.value, index);
94+
const newOrder = getOrderByPosition(translateX.value, translateY.value) + index;
95+
96+
if (oldOrder !== newOrder) {
97+
const itemIdToSwap = getIdByItemOrder(itemsOrder.value, newOrder);
98+
99+
if (itemIdToSwap !== undefined) {
100+
const newItemsOrder = [...itemsOrder.value];
101+
newItemsOrder[newOrder] = index;
102+
newItemsOrder[oldOrder] = itemIdToSwap;
103+
itemsOrder.value = newItemsOrder;
104+
}
105+
}
106+
})
107+
.onEnd(() => {
108+
const translation = getTranslationByOrderChange(getItemOrderById(itemsOrder.value, index),
109+
getItemOrderById(tempItemsOrder.value, index));
110+
111+
translateX.value = withTiming(tempTranslateX.value + translation.x, animationConfig);
112+
translateY.value = withTiming(tempTranslateY.value + translation.y, animationConfig);
113+
})
114+
.onFinalize(() => {
115+
if (isDragging.value) {
116+
isDragging.value = false;
117+
isFloating.value = false;
118+
if (tempItemsOrder.value.toString() !== itemsOrder.value.toString()) {
119+
runOnJS(onChange)();
120+
}
121+
}
122+
})
123+
.simultaneousWithExternalGesture(longPressGesture);
124+
125+
const gesture = Gesture.Race(dragGesture, longPressGesture);
126+
127+
const animatedStyle = useAnimatedStyle(() => {
128+
const scale = withSpring(isFloating.value ? 1.1 : 1);
129+
const zIndex = isFloating.value ? 100 : withTiming(0, animationConfig);
130+
131+
return {
132+
zIndex,
133+
transform: [{translateX: translateX.value}, {translateY: translateY.value}, {scale}]
134+
};
135+
});
136+
return (
137+
<View reanimated style={[style, animatedStyle]} onLayout={onLayout}>
138+
<GestureDetector gesture={gesture}>
139+
<View>{props.children}</View>
140+
</GestureDetector>
141+
</View>
142+
);
143+
}
144+
145+
export default SortableItem;
Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import React, {useCallback} from 'react';
2+
import {StyleSheet, FlatListProps, ScrollView, ScrollViewProps} from 'react-native';
3+
import {GestureHandlerRootView} from 'react-native-gesture-handler';
4+
import {useSharedValue} from 'react-native-reanimated';
5+
import _ from 'lodash';
6+
import {GridListBaseProps} from '../gridList';
7+
import SortableItem from './SortableItem';
8+
import usePresenter, {ItemsOrder} from './usePresenter';
9+
10+
import useGridLayout, {DEFAULT_ITEM_SPACINGS, DEFAULT_NUM_COLUMNS} from '../gridList/useGridLayout';
11+
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;
16+
}
17+
18+
function SortableGridList<T = any>(props: SortableGridListProps<T>) {
19+
const {renderItem, onOrderChange, contentContainerStyle, ...others} = props;
20+
21+
const {itemContainerStyle, numberOfColumns} = useGridLayout(props);
22+
const {numColumns = DEFAULT_NUM_COLUMNS, itemSpacing = DEFAULT_ITEM_SPACINGS, data} = others;
23+
const itemsOrder = useSharedValue<number[]>(_.map(props.data, (_v, i) => i));
24+
25+
// TODO: Get the number of columns from GridList calculation somehow
26+
const presenter = usePresenter(props.data?.length ?? 0, numColumns, itemSpacing);
27+
28+
const onChange = useCallback(() => {
29+
const newData: T[] = [];
30+
if (data?.length) {
31+
itemsOrder.value.forEach(itemIndex => {
32+
newData.push(data[itemIndex]);
33+
});
34+
}
35+
36+
onOrderChange?.(newData, itemsOrder.value);
37+
}, [onOrderChange, data]);
38+
39+
const _renderItem = useCallback(({item, index}) => {
40+
const lastItemInRow = (index + 1) % numberOfColumns === 0;
41+
return (
42+
<SortableItem
43+
key={index}
44+
{...presenter}
45+
style={[itemContainerStyle, lastItemInRow && {marginRight: 0}]}
46+
itemsOrder={itemsOrder}
47+
index={index}
48+
onChange={onChange}
49+
>
50+
{/* @ts-expect-error */}
51+
{renderItem({item, index})}
52+
</SortableItem>
53+
);
54+
}, []);
55+
56+
return (
57+
<GestureHandlerRootView>
58+
<ScrollView contentContainerStyle={[styles.listContent, contentContainerStyle]}>
59+
{data?.map((item, index) => _renderItem({item, index}))}
60+
</ScrollView>
61+
</GestureHandlerRootView>
62+
);
63+
}
64+
65+
export default SortableGridList;
66+
67+
const styles = StyleSheet.create({
68+
listContent: {
69+
flexWrap: 'wrap',
70+
flexDirection: 'row'
71+
}
72+
});
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
{
2+
"name": "SortableGridList",
3+
"category": "layoutsAndTemplates",
4+
"description": "An sortable grid list (based on GridList component)",
5+
"notes": "This component supports square items only",
6+
"extends": ["GridList"],
7+
"example": "https://github.com/wix/react-native-ui-lib/blob/master/demo/src/screens/componentScreens/SortableGridListScreen.tsx",
8+
"props": [
9+
{"name": "data", "type": "any[]", "description": "Data of items"},
10+
{"name": "renderItem", "type": "FlatListProps['renderItem']", "description": "Custom render item callback"},
11+
{
12+
"name": "onOrderChange",
13+
"type": "(newData: T[], newOrder: ItemsOrder) => void",
14+
"description": "Order change callback"
15+
}
16+
],
17+
"snippet": [
18+
"<SortableGridList>",
19+
" data={items$1}",
20+
" maxItemWidth={140$2}",
21+
" itemSpacing={Spacings.s3$3}",
22+
" listPadding={Spacings.s5}",
23+
" onOrderChange={$4}",
24+
"/>"
25+
]
26+
}

0 commit comments

Comments
 (0)