Skip to content

Feat/sortable grid list component #1918

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 18 commits into from
Apr 6, 2022
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions demo/src/screens/MenuStructure.js
Original file line number Diff line number Diff line change
Expand Up @@ -113,6 +113,7 @@ export const navigationData = {
},
{title: 'Wizard', tags: 'wizard', screen: 'unicorn.components.WizardScreen'},
{title: 'GridList', tags: 'grid list', screen: 'unicorn.components.GridListScreen'},
{title: 'SortableGridList', tags: 'sort grid list drag', screen: 'unicorn.components.SortableGridListScreen'},
{title: 'GridView', tags: 'grid view', screen: 'unicorn.components.GridViewScreen'}
]
},
Expand Down
69 changes: 69 additions & 0 deletions demo/src/screens/componentScreens/SortableGridListScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import React, {Component} from 'react';
import {StyleSheet} from 'react-native';
import {
View,
Text,
Constants,
SortableGridList,
Card,
Spacings,
BorderRadiuses,
GridListProps,
SortableGridListProps
} from 'react-native-ui-lib';

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

class GridListScreen extends Component {
state = {
orientation: Constants.orientation
};

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

renderItem: GridListProps<typeof products[0]>['renderItem'] = ({item}) => {
return (
<Card flex onPress={() => console.log('item press')}>
<Card.Section imageSource={{uri: item.mediaUrl}} imageStyle={styles.itemImage}/>
</Card>
);
};

render() {
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>
);
}
}

const styles = StyleSheet.create({
list: {
padding: Spacings.s5
},
itemImage: {
width: '100%',
// height: 85,
height: 108.7,
borderRadius: BorderRadiuses.br10
}
});

export default GridListScreen;
1 change: 1 addition & 0 deletions demo/src/screens/componentScreens/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ export function registerScreens(registrar) {
registrar('unicorn.components.SharedTransitionScreen', () => require('./SharedTransitionScreen').default);
registrar('unicorn.components.SkeletonViewScreen', () => require('./SkeletonViewScreen').default);
registrar('unicorn.components.SliderScreen', () => require('./SliderScreen').default);
registrar('unicorn.components.SortableGridListScreen', () => require('./SortableGridListScreen').default);
registrar('unicorn.components.StackAggregatorScreen', () => require('./StackAggregatorScreen').default);
registrar('unicorn.components.StepperScreen', () => require('./StepperScreen').default);
registrar('unicorn.components.SwitchScreen', () => require('./SwitchScreen').default);
Expand Down
12 changes: 12 additions & 0 deletions generatedTypes/src/components/sortableGridList/SortableItem.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import { PropsWithChildren } from 'react';
import { StyleProp, ViewStyle } from 'react-native';
import Animated from 'react-native-reanimated';
import usePresenter, { ItemsOrder } from './usePresenter';
interface SortableItemProps extends ReturnType<typeof usePresenter> {
index: number;
itemsOrder: Animated.SharedValue<ItemsOrder>;
onChange: () => void;
style: StyleProp<ViewStyle>;
}
declare function SortableItem(props: PropsWithChildren<SortableItemProps>): JSX.Element;
export default SortableItem;
11 changes: 11 additions & 0 deletions generatedTypes/src/components/sortableGridList/index.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/// <reference types="react" />
import { FlatListProps, ScrollViewProps } from 'react-native';
import { GridListBaseProps } from '../gridList';
import { ItemsOrder } from './usePresenter';
export interface SortableGridListProps<T = any> extends GridListBaseProps, ScrollViewProps {
data: FlatListProps<T>['data'];
renderItem: FlatListProps<T>['renderItem'];
onOrderChange?: (newData: T[], newOrder: ItemsOrder) => void;
}
declare function SortableGridList<T = any>(props: SortableGridListProps<T>): JSX.Element;
export default SortableGridList;
26 changes: 26 additions & 0 deletions generatedTypes/src/components/sortableGridList/usePresenter.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
export declare const WINDOW_WIDTH: number;
export declare const DEFAULT_NO_OF_COLUMNS = 3;
export declare const getItemSize: (numOfColumns: number, viewWidth: number) => number;
export declare const animationConfig: {
easing: (value: number) => number;
duration: number;
};
export declare type ItemsLayouts = ({
width: number;
height: number;
} | undefined)[];
export declare type ItemsOrder = number[];
declare const usePresenter: (itemsSize: number, numOfColumns: number, itemSpacing: number) => {
updateItemLayout: (index: number, layout: {
width: number;
height: number;
}) => void;
getTranslationByOrderChange: (newOrder: number, oldOrder: number) => {
x: number;
y: number;
};
getOrderByPosition: (positionX: number, positionY: number) => number;
getItemOrderById: (itemsOrder: ItemsOrder, itemId: number) => number;
getIdByItemOrder: (itemsOrder: ItemsOrder, orderIndex: number) => number;
};
export default usePresenter;
145 changes: 145 additions & 0 deletions src/components/sortableGridList/SortableItem.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
import React, {PropsWithChildren, useCallback} from 'react';
import {LayoutChangeEvent, StyleProp, ViewStyle} from 'react-native';
import {Gesture, GestureDetector} from 'react-native-gesture-handler';
import Animated, {
runOnJS,
useAnimatedReaction,
useAnimatedStyle,
useSharedValue,
withSpring,
withTiming
} from 'react-native-reanimated';
import usePresenter, {ItemsOrder, animationConfig} from './usePresenter';
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>) {
const {
index,
itemsOrder,
onChange,
style,
getItemOrderById,
getOrderByPosition,
getIdByItemOrder,
getTranslationByOrderChange,
updateItemLayout
} = props;
const translateX = useSharedValue(0);
const translateY = useSharedValue(0);

const isFloating = useSharedValue(false);
const isDragging = useSharedValue(false);
const tempItemsOrder = useSharedValue(itemsOrder.value);
const tempTranslateX = useSharedValue(0);
const tempTranslateY = useSharedValue(0);

const onLayout = useCallback((event: LayoutChangeEvent) => {
'worklet';
const {width, height} = event.nativeEvent.layout;
updateItemLayout(index, {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;
})
.onTouchesCancelled(() => {
if (!isDragging.value) {
isFloating.value = false;
}
})
.minDuration(250);

const dragGesture = Gesture.Pan()
.manualActivation(true)
.onTouchesMove((_e, state) => {
if (isFloating.value) {
isDragging.value = true;
state.activate();
} else {
isDragging.value = false;
state.fail();
}
})
.onStart(() => {
tempTranslateX.value = translateX.value;
tempTranslateY.value = translateY.value;
tempItemsOrder.value = itemsOrder.value;
})
.onUpdate(event => {
translateX.value = tempTranslateX.value + event.translationX;
translateY.value = tempTranslateY.value + event.translationY;

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

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

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

translateX.value = withTiming(tempTranslateX.value + translation.x, animationConfig);
translateY.value = withTiming(tempTranslateY.value + translation.y, animationConfig);
})
.onFinalize(() => {
if (isDragging.value) {
isDragging.value = false;
isFloating.value = false;
if (tempItemsOrder.value.toString() !== itemsOrder.value.toString()) {
runOnJS(onChange)();
}
}
})
.simultaneousWithExternalGesture(longPressGesture);

const gesture = Gesture.Race(dragGesture, longPressGesture);

const animatedStyle = useAnimatedStyle(() => {
const scale = withSpring(isFloating.value ? 1.1 : 1);
const zIndex = isFloating.value ? 100 : withTiming(0, animationConfig);

return {
zIndex,
transform: [{translateX: translateX.value}, {translateY: translateY.value}, {scale}]
};
});
return (
<View reanimated style={[style, animatedStyle]} onLayout={onLayout}>
<GestureDetector gesture={gesture}>
<View>{props.children}</View>
</GestureDetector>
</View>
);
}

export default SortableItem;
72 changes: 72 additions & 0 deletions src/components/sortableGridList/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import React, {useCallback} from 'react';
import {StyleSheet, FlatListProps, ScrollView, ScrollViewProps} from 'react-native';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import {useSharedValue} from 'react-native-reanimated';
import _ from 'lodash';
import {GridListBaseProps} from '../gridList';
import SortableItem from './SortableItem';
import usePresenter, {ItemsOrder} from './usePresenter';

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 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));

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

const onChange = useCallback(() => {
const newData: T[] = [];
if (data?.length) {
itemsOrder.value.forEach(itemIndex => {
newData.push(data[itemIndex]);
});
}

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

const _renderItem = useCallback(({item, index}) => {
const lastItemInRow = (index + 1) % numberOfColumns === 0;
return (
<SortableItem
key={index}
{...presenter}
style={[itemContainerStyle, lastItemInRow && {marginRight: 0}]}
itemsOrder={itemsOrder}
index={index}
onChange={onChange}
>
{/* @ts-expect-error */}
{renderItem({item, index})}
</SortableItem>
);
}, []);

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

export default SortableGridList;

const styles = StyleSheet.create({
listContent: {
flexWrap: 'wrap',
flexDirection: 'row'
}
});
Loading