Skip to content

Feat/sortable list locked items #2296

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 6 commits into from
Oct 31, 2022
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
44 changes: 31 additions & 13 deletions demo/src/screens/componentScreens/SortableListScreen.tsx
Original file line number Diff line number Diff line change
@@ -1,18 +1,33 @@
import _ from 'lodash';
import React, {useCallback, useState, useRef} from 'react';
import {StyleSheet} from 'react-native';
import {SortableList, View, TouchableOpacity, Text, Icon, Assets, Colors, Button} from 'react-native-ui-lib';
import {
SortableList,
SortableListItemProps,
View,
TouchableOpacity,
Text,
Icon,
Assets,
Colors,
Button
} from 'react-native-ui-lib';
import {renderHeader} from '../ExampleScreenPresenter';

interface Item {
originalIndex: number;
id: string;
interface Item extends SortableListItemProps {
text: string;
}

const data = _.times(30, index => {
const data: Item[] = _.times(30, index => {
let text = `${index}`;
if (index === 3) {
text = 'Locked item';
}

return {
originalIndex: index,
id: `${index}`
text,
id: `${index}`,
locked: index === 3
};
});

Expand Down Expand Up @@ -57,23 +72,26 @@ const SortableListScreen = () => {

const renderItem = useCallback(({item, index: _index}: {item: Item; index: number}) => {
const isSelected = selectedItems.includes(item);
const {locked} = item;
const Container = locked ? View : TouchableOpacity;
return (
<TouchableOpacity
<Container
style={[styles.itemContainer, isSelected && styles.selectedItemContainer]}
onPress={() => toggleItemSelection(item)}
// overriding the BG color to anything other than white will cause Android's elevation to fail
// backgroundColor={Colors.red30}
centerV
centerH={locked}
paddingH-page
>
<View flex row spread centerV>
<Icon source={Assets.icons.demo.drag} tintColor={Colors.$iconDisabled}/>
<Text center $textDefault>
{item.originalIndex}
{!locked && <Icon source={Assets.icons.demo.drag} tintColor={Colors.$iconDisabled}/>}
<Text center $textDefault={!locked} $textNeutralLight={locked}>
{item.text}
</Text>
<Icon source={Assets.icons.demo.chevronRight} tintColor={Colors.$iconDefault}/>
{!locked && <Icon source={Assets.icons.demo.chevronRight} tintColor={Colors.$iconDefault}/>}
</View>
</TouchableOpacity>
</Container>
);
},
[selectedItems, toggleItemSelection]);
Expand Down
6 changes: 4 additions & 2 deletions src/components/sortableList/SortableListContext.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
import {createContext} from 'react';
import {ViewProps} from 'react-native';
import {SharedValue} from 'react-native-reanimated';
import {Data, SortableListItemProps} from './types';

export interface SortableListContextType {
data: any
export interface SortableListContextType<ItemT extends SortableListItemProps> {
data: Data<ItemT>;
itemsOrder: SharedValue<string[]>;
lockedIds: SharedValue<Dictionary<boolean>>;
onChange: () => void;
itemHeight: SharedValue<number>;
onItemLayout: ViewProps['onLayout'];
Expand Down
17 changes: 14 additions & 3 deletions src/components/sortableList/SortableListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,11 +17,11 @@ import {useDidUpdate} from 'hooks';
import SortableListContext from './SortableListContext';
import usePresenter from './usePresenter';
import {HapticService, HapticType} from '../../services';
export interface SortableListItemProps {
export interface InternalSortableListItemProps {
index: number;
}

type Props = PropsWithChildren<SortableListItemProps>;
type Props = PropsWithChildren<InternalSortableListItemProps>;

const animationConfig = {
easing: Easing.inOut(Easing.ease),
Expand All @@ -36,12 +36,14 @@ const SortableListItem = (props: Props) => {
itemHeight,
onItemLayout,
itemsOrder,
lockedIds,
onChange,
enableHaptic,
scale: propsScale = 1
} = useContext(SortableListContext);
const {getTranslationByIndexChange, getItemIndexById, getIndexByPosition, getIdByItemIndex} = usePresenter();
const id: string = data[index].id;
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);
Expand Down Expand Up @@ -76,6 +78,7 @@ const SortableListItem = (props: Props) => {

const dragOnLongPressGesture = Gesture.Pan()
.activateAfterLongPress(250)
.enabled(!locked)
.onStart(() => {
isDragging.value = true;
translateY.value = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemHeight.value);
Expand All @@ -100,8 +103,16 @@ const SortableListItem = (props: Props) => {
newIndex = Math.sign(newIndex - oldIndex) + oldIndex;
}

const itemIdToSwap = getIdByItemIndex(itemsOrder.value, newIndex);
let itemIdToSwap = getIdByItemIndex(itemsOrder.value, newIndex);

// Skip locked item(s)
while (lockedIds.value[itemIdToSwap]) {
const skipDirection = Math.sign(newIndex - oldIndex);
newIndex = skipDirection + newIndex;
itemIdToSwap = getIdByItemIndex(itemsOrder.value, newIndex);
}

// Swap items
if (itemIdToSwap !== undefined) {
const newItemsOrder = [...itemsOrder.value];
newItemsOrder[newIndex] = id;
Expand Down
40 changes: 14 additions & 26 deletions src/components/sortableList/index.tsx
Original file line number Diff line number Diff line change
@@ -1,44 +1,31 @@
/* eslint-disable react-hooks/exhaustive-deps */
import {map, mapKeys} from 'lodash';
import {map, mapKeys, filter, reduce} from 'lodash';
import React, {useMemo, useCallback} from 'react';
import {FlatList, FlatListProps, LayoutChangeEvent} from 'react-native';
import {FlatList, LayoutChangeEvent} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import SortableListContext, {SortableListContextType} from './SortableListContext';
import SortableListContext from './SortableListContext';
import SortableListItem from './SortableListItem';
import {useDidUpdate, useThemeProps} from 'hooks';
import {SortableListProps, SortableListItemProps} from './types';
export {SortableListProps, SortableListItemProps};

interface ItemWithId {
id: string;
}

export interface SortableListProps<ItemT extends ItemWithId>
extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'>,
Pick<SortableListContextType, 'scale'> {
/**
* The data of the list, do not update the data.
*/
data: FlatListProps<ItemT>['data'];
/**
* A callback to get the new order (or swapped items).
*/
onOrderChange: (data: ItemT[] /* TODO: add more data? */) => void;
/**
* Whether to enable the haptic feedback
* (please note that react-native-haptic-feedback does not support the specific haptic type on Android starting on an unknown version, you can use 1.8.2 for it to work properly)
*/
enableHaptic?: boolean;
function generateItemsOrder<ItemT extends SortableListItemProps>(data: SortableListProps<ItemT>['data']) {
return map(data, item => item.id);
}

function generateItemsOrder<ItemT extends ItemWithId>(data: SortableListProps<ItemT>['data']) {
return map(data, item => item.id);
function generateLockedIds<ItemT extends SortableListItemProps>(data: SortableListProps<ItemT>['data']) {
return reduce(filter(data, item => item.locked),
(item, cur) => ({...item, [(cur as ItemT).id]: true}),
{});
}

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

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

useDidUpdate(() => {
Expand Down Expand Up @@ -67,6 +54,7 @@ const SortableList = <ItemT extends ItemWithId>(props: SortableListProps<ItemT>)
return {
data,
itemsOrder,
lockedIds,
onChange,
itemHeight,
onItemLayout,
Expand Down
28 changes: 28 additions & 0 deletions src/components/sortableList/types.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import {FlatListProps} from 'react-native';
import {SortableListContextType} from './SortableListContext';

export interface SortableListItemProps {
id: string;
locked?: boolean;
}

// Internal
export type Data<ItemT extends SortableListItemProps> = FlatListProps<ItemT>['data'];

export interface SortableListProps<ItemT extends SortableListItemProps>
extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'>,
Pick<SortableListContextType<ItemT>, 'scale'> {
/**
* The data of the list, do not update the data.
*/
data: Data<ItemT>;
/**
* A callback to get the new order (or swapped items).
*/
onOrderChange: (data: ItemT[] /* TODO: add more data? */) => void;
/**
* Whether to enable the haptic feedback
* (please note that react-native-haptic-feedback does not support the specific haptic type on Android starting on an unknown version, you can use 1.8.2 for it to work properly)
*/
enableHaptic?: boolean;
}
2 changes: 1 addition & 1 deletion src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ export {default as SharedTransition} from './components/sharedTransition';
export {default as SkeletonView, SkeletonViewProps} from './components/skeletonView';
export {default as Slider, SliderProps} from './components/slider';
export {default as SortableGridList, SortableGridListProps} from './components/sortableGridList';
export {default as SortableList, SortableListProps} from './components/sortableList';
export {default as SortableList, SortableListProps, SortableListItemProps} from './components/sortableList';
export {default as StackAggregator, StackAggregatorProps} from './components/stackAggregator';
export {default as StateScreen, StateScreenProps} from './components/stateScreen';
export {default as Stepper, StepperProps} from './components/stepper';
Expand Down