Skip to content

SortableList - support horizontal #2800

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 1 commit into from
Dec 5, 2023
Merged
Show file tree
Hide file tree
Changes from all 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
3 changes: 3 additions & 0 deletions demo/src/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,9 @@ module.exports = {
get HintsScreen() {
return require('./screens/componentScreens/HintsScreen').default;
},
get HorizontalSortableListScreen() {
return require('./screens/componentScreens/HorizontalSortableListScreen').default;
},
get IconScreen() {
return require('./screens/componentScreens/IconScreen').default;
},
Expand Down
1 change: 1 addition & 0 deletions demo/src/screens/MenuStructure.js
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,7 @@ export const navigationData = {
{title: 'Conversation List', tags: 'list conversation', screen: 'unicorn.lists.ConversationListScreen'},
{title: 'Drawer', tags: 'drawer', screen: 'unicorn.components.DrawerScreen'},
{title: 'SortableList', tags: 'sortable list drag', screen: 'unicorn.components.SortableListScreen'},
{title: 'HorizontalSortableList', tags: 'sortable horizontal list drag', screen: 'unicorn.components.HorizontalSortableListScreen'},
{title: 'GridList', tags: 'grid list', screen: 'unicorn.components.GridListScreen'},
{title: 'SortableGridList', tags: 'sort grid list drag', screen: 'unicorn.components.SortableGridListScreen'}
]
Expand Down
120 changes: 120 additions & 0 deletions demo/src/screens/componentScreens/HorizontalSortableListScreen.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import React, {useCallback, useState, useRef} from 'react';
import {StyleSheet} from 'react-native';
import {SortableList, View, Text, Colors, Button, Card, BorderRadiuses} from 'react-native-ui-lib';
import {renderHeader} from '../ExampleScreenPresenter';
import products from '../../data/products';

const data = products.map((product, index) => ({...product, locked: index === 3}));
type Item = (typeof data)[0];

const HorizontalSortableListScreen = () => {
const [items, setItems] = useState<Item[]>(data);
const [selectedItems, setSelectedItems] = useState<Item[]>([]);
const [removedItems, setRemovedItems] = useState<Item[]>([]);
const orderedItems = useRef<Item[]>(data);

const toggleItemSelection = useCallback((item: Item) => {
if (selectedItems.includes(item)) {
setSelectedItems(selectedItems.filter(selectedItem => ![item.id].includes(selectedItem.id)));
} else {
setSelectedItems(selectedItems.concat(item));
}
},
[selectedItems, setSelectedItems]);

const addItem = useCallback(() => {
if (removedItems.length > 0) {
orderedItems.current = orderedItems.current.concat(removedItems[0]);
setItems(orderedItems.current);
setRemovedItems(removedItems.slice(1));
}
}, [removedItems, setItems, setRemovedItems]);

const removeSelectedItems = useCallback(() => {
setRemovedItems(removedItems.concat(selectedItems));
setSelectedItems([]);
orderedItems.current = orderedItems.current.filter(item => !selectedItems.includes(item));
setItems(orderedItems.current);
}, [setRemovedItems, removedItems, selectedItems, setItems, setSelectedItems]);

const keyExtractor = useCallback((item: Item) => {
return `${item.id}`;
}, []);

const onOrderChange = useCallback((newData: Item[]) => {
console.log('New order:', newData);
orderedItems.current = newData;
}, []);

const renderItem = useCallback(({item, index: _index}: {item: Item; index: number}) => {
const selected = selectedItems.includes(item);
const {locked} = item;
const Container = locked ? View : Card;
return (
<Container
style={styles.itemContainer}
onPress={() => toggleItemSelection(item)}
customValue={item.id}
selected={selected}
margin-s1
>
<Card.Section
imageSource={{uri: item.mediaUrl}}
imageStyle={styles.itemImage}
imageProps={{
customOverlayContent: (
<Text margin-s1={!locked} h1={!locked} h4={locked} center={locked} orange30>
{locked ? 'Locked' : item.id}
</Text>
)
}}
/>
</Container>
);
},
[selectedItems, toggleItemSelection]);

return (
<View flex bg-$backgroundDefault>
{renderHeader('Sortable List', {'margin-10': true})}
<View row center marginB-s2>
<Button label="Add Item" size={Button.sizes.xSmall} disabled={removedItems.length === 0} onPress={addItem}/>
<Button
label="Remove Items"
size={Button.sizes.xSmall}
disabled={selectedItems.length === 0}
marginL-s3
onPress={removeSelectedItems}
/>
</View>
<View flex useSafeArea>
<SortableList
horizontal
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
onOrderChange={onOrderChange}
scale={1.02}
/>
</View>
</View>
);
};

export default HorizontalSortableListScreen;
const styles = StyleSheet.create({
itemContainer: {
overflow: 'hidden',
borderColor: Colors.$outlineDefault,
borderBottomWidth: 1
},
selectedItemContainer: {
borderLeftColor: Colors.$outlinePrimary,
borderLeftWidth: 5
},
itemImage: {
width: 80,
height: 80,
borderRadius: BorderRadiuses.br10
}
});
1 change: 1 addition & 0 deletions demo/src/screens/componentScreens/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ export function registerScreens(registrar) {
registrar('unicorn.components.SliderScreen', () => require('./SliderScreen').default);
registrar('unicorn.components.SortableGridListScreen', () => require('./SortableGridListScreen').default);
registrar('unicorn.components.SortableListScreen', () => require('./SortableListScreen').default);
registrar('unicorn.components.HorizontalSortableListScreen', () => require('./HorizontalSortableListScreen').default);
registrar('unicorn.components.StackAggregatorScreen', () => require('./StackAggregatorScreen').default);
registrar('unicorn.components.StepperScreen', () => require('./StepperScreen').default);
registrar('unicorn.components.SwitchScreen', () => require('./SwitchScreen').default);
Expand Down
3 changes: 2 additions & 1 deletion src/components/sortableList/SortableListContext.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@ export interface SortableListContextType<ItemT extends SortableListItemProps> {
itemsOrder: SharedValue<string[]>;
lockedIds: SharedValue<Dictionary<boolean>>;
onChange: () => void;
itemHeight: SharedValue<number>;
itemSize: SharedValue<number>;
horizontal?: boolean;
onItemLayout: ViewProps['onLayout'];
enableHaptic?: boolean;
scale?: number;
Expand Down
30 changes: 26 additions & 4 deletions src/components/sortableList/SortableListItem.driver.new.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import _ from 'lodash';
import {useComponentDriver, ComponentProps} from '../../testkit/new/Component.driver';
import {useDraggableDriver} from '../../testkit/new/useDraggable.driver';
import {SortableListItemProps} from './types';
import {DEFAULT_LIST_ITEM_HEIGHT} from './SortableListItem';
import {DEFAULT_LIST_ITEM_SIZE} from './SortableListItem';

export const SortableListItemDriver = (props: ComponentProps) => {
const driver = useDraggableDriver<SortableListItemProps>(useComponentDriver(props));
Expand All @@ -11,7 +11,7 @@ export const SortableListItemDriver = (props: ComponentProps) => {
validateIndices(indices);
const data = _.times(indices, index => {
return {
translationY: -DEFAULT_LIST_ITEM_HEIGHT * (index + 1)
translationY: -DEFAULT_LIST_ITEM_SIZE * (index + 1)
};
});

Expand All @@ -22,7 +22,29 @@ export const SortableListItemDriver = (props: ComponentProps) => {
validateIndices(indices);
const data = _.times(indices, index => {
return {
translationY: DEFAULT_LIST_ITEM_HEIGHT * (index + 1)
translationY: DEFAULT_LIST_ITEM_SIZE * (index + 1)
};
});

driver.drag(data);
};

const dragLeft = async (indices: number) => {
validateIndices(indices);
const data = _.times(indices, index => {
return {
translationX: -DEFAULT_LIST_ITEM_SIZE * (index + 1)
};
});

driver.drag(data);
};

const dragRight = async (indices: number) => {
validateIndices(indices);
const data = _.times(indices, index => {
return {
translationX: DEFAULT_LIST_ITEM_SIZE * (index + 1)
};
});

Expand All @@ -35,5 +57,5 @@ export const SortableListItemDriver = (props: ComponentProps) => {
}
};

return {...driver, dragUp, dragDown};
return {...driver, dragUp, dragDown, dragLeft, dragRight};
};
33 changes: 18 additions & 15 deletions src/components/sortableList/SortableListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ export interface InternalSortableListItemProps {

type Props = PropsWithChildren<InternalSortableListItemProps>;

export const DEFAULT_LIST_ITEM_HEIGHT = 52;
export const DEFAULT_LIST_ITEM_SIZE = 52;

const animationConfig = {
easing: Easing.inOut(Easing.ease),
Expand All @@ -40,7 +40,8 @@ const SortableListItem = (props: Props) => {

const {
data,
itemHeight,
itemSize,
horizontal,
itemProps,
onItemLayout,
itemsOrder,
Expand All @@ -54,7 +55,7 @@ const SortableListItem = (props: Props) => {
const locked: boolean = data[index].locked;
const initialIndex = useSharedValue<number>(map(data, 'id').indexOf(id));
const currIndex = useSharedValue(initialIndex.value);
const translateY = useSharedValue<number>(0);
const translation = useSharedValue<number>(0);

const isDragging = useSharedValue(false);

Expand All @@ -68,7 +69,7 @@ const SortableListItem = (props: Props) => {
elevation: 0
}));

const tempTranslateY = useSharedValue<number>(0);
const tempTranslation = useSharedValue<number>(0);
const tempItemsOrder = useSharedValue<string[]>(itemsOrder.value);

useDidUpdate(() => {
Expand All @@ -77,7 +78,7 @@ const SortableListItem = (props: Props) => {
initialIndex.value = newItemIndex;
currIndex.value = newItemIndex;

translateY.value = 0;
translation.value = 0;
}, [data]);

useAnimatedReaction(() => getItemIndexById(itemsOrder.value, id),
Expand All @@ -88,9 +89,9 @@ const SortableListItem = (props: Props) => {

currIndex.value = newIndex;
if (!isDragging.value) {
const translation = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemHeight.value);
const _translation = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemSize.value);

translateY.value = withTiming(translation, animationConfig);
translation.value = withTiming(_translation, animationConfig);
}
},
[]);
Expand All @@ -100,8 +101,8 @@ const SortableListItem = (props: Props) => {
.enabled(!locked)
.onStart(() => {
isDragging.value = true;
translateY.value = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemHeight.value);
tempTranslateY.value = translateY.value;
translation.value = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemSize.value);
tempTranslation.value = translation.value;
tempItemsOrder.value = itemsOrder.value;
})
.onTouchesMove(() => {
Expand All @@ -110,10 +111,12 @@ const SortableListItem = (props: Props) => {
}
})
.onUpdate(event => {
translateY.value = tempTranslateY.value + event.translationY;
const {translationX, translationY} = event;
const _translation = horizontal ? translationX : translationY;
translation.value = tempTranslation.value + _translation;

// Swapping items
let newIndex = getIndexByPosition(translateY.value, itemHeight.value) + initialIndex.value;
let newIndex = getIndexByPosition(translation.value, itemSize.value) + initialIndex.value;
const oldIndex = getItemIndexById(itemsOrder.value, id);

if (newIndex !== oldIndex) {
Expand Down Expand Up @@ -141,11 +144,11 @@ const SortableListItem = (props: Props) => {
}
})
.onEnd(() => {
const translation = getTranslationByIndexChange(getItemIndexById(itemsOrder.value, id),
const _translation = getTranslationByIndexChange(getItemIndexById(itemsOrder.value, id),
getItemIndexById(tempItemsOrder.value, id),
itemHeight.value);
itemSize.value);

translateY.value = withTiming(tempTranslateY.value + translation, animationConfig, () => {
translation.value = withTiming(tempTranslation.value + _translation, animationConfig, () => {
if (tempItemsOrder.value.toString() !== itemsOrder.value.toString()) {
runOnJS(onChange)();
}
Expand All @@ -168,7 +171,7 @@ const SortableListItem = (props: Props) => {
return {
backgroundColor: LIST_ITEM_BACKGROUND, // required for elevation to work in Android
zIndex,
transform: [{translateY: translateY.value}, {scale}],
transform: [horizontal ? {translateX: translation.value} : {translateY: translation.value}, {scale}],
opacity,
...itemProps?.margins,
...shadow
Expand Down
22 changes: 14 additions & 8 deletions src/components/sortableList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import {FlatList, LayoutChangeEvent} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import SortableListContext from './SortableListContext';
import SortableListItem, {DEFAULT_LIST_ITEM_HEIGHT} from './SortableListItem';
import SortableListItem, {DEFAULT_LIST_ITEM_SIZE} from './SortableListItem';
import {useDidUpdate, useThemeProps} from 'hooks';
import {SortableListProps, SortableListItemProps} from './types';
import type {Dictionary} from '../../typings/common';
Expand All @@ -24,11 +24,11 @@ function generateLockedIds<ItemT extends SortableListItemProps>(data: SortableLi

const SortableList = <ItemT extends SortableListItemProps>(props: SortableListProps<ItemT>) => {
const themeProps = useThemeProps(props, 'SortableList');
const {data, onOrderChange, enableHaptic, scale, itemProps, ...others} = themeProps;
const {data, onOrderChange, enableHaptic, scale, itemProps, horizontal, ...others} = themeProps;

const itemsOrder = useSharedValue<string[]>(generateItemsOrder(data));
const lockedIds = useSharedValue<Dictionary<boolean>>(generateLockedIds(data));
const itemHeight = useSharedValue<number>(DEFAULT_LIST_ITEM_HEIGHT);
const itemSize = useSharedValue<number>(DEFAULT_LIST_ITEM_SIZE);

useDidUpdate(() => {
itemsOrder.value = generateItemsOrder(data);
Expand All @@ -47,11 +47,15 @@ const SortableList = <ItemT extends SortableListItemProps>(props: SortableListPr
}, [onOrderChange, data]);

const onItemLayout = useCallback((event: LayoutChangeEvent) => {
// Round height for Android
const newHeight = Math.round(event.nativeEvent.layout.height);
const {height, width} = event.nativeEvent.layout;
// Round for Android
const newSize = Math.round(horizontal ? width : height);
// Check validity for tests
if (newHeight) {
itemHeight.value = newHeight + (itemProps?.margins?.marginTop ?? 0) + (itemProps?.margins?.marginBottom ?? 0);
if (newSize) {
const margins = horizontal
? (itemProps?.margins?.marginLeft ?? 0) + (itemProps?.margins?.marginRight ?? 0)
: (itemProps?.margins?.marginTop ?? 0) + (itemProps?.margins?.marginBottom ?? 0);
itemSize.value = newSize + margins;
}
}, []);

Expand All @@ -61,7 +65,8 @@ const SortableList = <ItemT extends SortableListItemProps>(props: SortableListPr
itemsOrder,
lockedIds,
onChange,
itemHeight,
itemSize,
horizontal,
itemProps,
onItemLayout,
enableHaptic,
Expand All @@ -74,6 +79,7 @@ const SortableList = <ItemT extends SortableListItemProps>(props: SortableListPr
<SortableListContext.Provider value={context}>
<FlatList
{...others}
horizontal={horizontal}
data={data}
CellRendererComponent={SortableListItem}
removeClippedSubviews={false} // Workaround for crashing on Android (ArrayIndexOutOfBoundsException in ViewGroupDrawingOrderHelper.getChildDrawingOrder)
Expand Down
Loading