Skip to content

Fix/sortable list item fixes #2217

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 8 commits into from
Aug 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
22 changes: 18 additions & 4 deletions demo/src/screens/componentScreens/SortableListScreen.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ const SortableListScreen = () => {
} else {
setSelectedItems(selectedItems.concat(item));
}
}, [selectedItems, setSelectedItems]);
},
[selectedItems, setSelectedItems]);

const addItem = useCallback(() => {
if (removedItems.length > 0) {
Expand Down Expand Up @@ -74,17 +75,30 @@ const SortableListScreen = () => {
</View>
</TouchableOpacity>
);
}, [selectedItems, toggleItemSelection]);
},
[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}/>
<Button
label="Remove Items"
size={Button.sizes.xSmall}
disabled={selectedItems.length === 0}
marginL-s3
onPress={removeSelectedItems}
/>
</View>
<View flex useSafeArea>
<SortableList data={items} renderItem={renderItem} keyExtractor={keyExtractor} onOrderChange={onOrderChange}/>
<SortableList
data={items}
renderItem={renderItem}
keyExtractor={keyExtractor}
onOrderChange={onOrderChange}
scale={1.02}
/>
</View>
</View>
);
Expand Down
6 changes: 6 additions & 0 deletions src/components/sortableList/SortableList.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@
"name": "enableHaptic",
"type": "boolean",
"description": "Whether to enable the haptic feedback.\n(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)"
},
{
"name": "scale",
"type": "number",
"default": "1",
"description": "Scale the item once dragged."
}
],
"snippet": [
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 @@ -2,13 +2,14 @@ import {createContext} from 'react';
import {ViewProps} from 'react-native';
import {SharedValue} from 'react-native-reanimated';

interface SortableListContextType {
export interface SortableListContextType {
data: any
itemsOrder: SharedValue<string[]>;
onChange: () => void;
itemHeight: SharedValue<number>;
onItemLayout: ViewProps['onLayout'];
enableHaptic?: boolean;
scale?: number;
}

// @ts-ignore
Expand Down
70 changes: 37 additions & 33 deletions src/components/sortableList/SortableListItem.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,55 +31,54 @@ const animationConfig = {
const SortableListItem = (props: Props) => {
const {children, index} = props;

const {data, itemHeight, onItemLayout, itemsOrder, onChange, enableHaptic} = useContext(SortableListContext);
const {
data,
itemHeight,
onItemLayout,
itemsOrder,
onChange,
enableHaptic,
scale: propsScale = 1
} = useContext(SortableListContext);
const {getTranslationByIndexChange, getItemIndexById, getIndexByPosition, getIdByItemIndex} = usePresenter();
const id: string = data[index].id;
const initialIndex = useSharedValue<number>(map(data, 'id').indexOf(id));
const currIndex = useSharedValue(initialIndex.value);
const translateY = useSharedValue<number>(0);

const isDragging = useSharedValue(false);
const tempTranslateY = useSharedValue<number>(0);
const tempItemsOrder = useSharedValue<string[]>(itemsOrder.value);
const dataManuallyChanged = useSharedValue<boolean>(false);

useDidUpdate(() => {
dataManuallyChanged.value = true;
initialIndex.value = map(data, 'id').indexOf(id);
const newItemIndex = map(data, 'id').indexOf(id);

initialIndex.value = newItemIndex;
currIndex.value = newItemIndex;

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

useAnimatedReaction(() => itemsOrder.value,
(currItemsOrder, prevItemsOrder) => {
// Note: Unfortunately itemsOrder sharedValue is being initialized on each render
// Therefore I added this extra check here that compares current and previous values
// See open issue: https://github.com/software-mansion/react-native-reanimated/issues/3224
if (prevItemsOrder === null || currItemsOrder.join(',') === prevItemsOrder.join(',')) {
useAnimatedReaction(() => getItemIndexById(itemsOrder.value, id),
(newIndex, prevIndex) => {
if (prevIndex === null || newIndex === prevIndex) {
return;
} else {
const newIndex = getItemIndexById(currItemsOrder, id);
const oldIndex = getItemIndexById(prevItemsOrder, id);

/* In case the order of the item has returned back to its initial index we reset its position */
if (newIndex === initialIndex.value) {
/* Reset without an animation when the change is due to manual data change */
if (dataManuallyChanged.value) {
translateY.value = 0;
dataManuallyChanged.value = false;
/* Reset with an animation when the change id due to user reordering */
} else {
translateY.value = withTiming(0, animationConfig);
}
/* Handle an order change, animate item to its new position */
} else if (newIndex !== oldIndex) {
const translation = getTranslationByIndexChange(newIndex, oldIndex, itemHeight.value);
translateY.value = withTiming(translateY.value + translation, animationConfig);
}
}
});

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

translateY.value = withTiming(translation, animationConfig);
}
},
[]);

const dragOnLongPressGesture = Gesture.Pan()
.activateAfterLongPress(250)
.onStart(() => {
isDragging.value = true;
translateY.value = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemHeight.value);
tempTranslateY.value = translateY.value;
tempItemsOrder.value = itemsOrder.value;
})
Expand All @@ -92,10 +91,15 @@ const SortableListItem = (props: Props) => {
translateY.value = tempTranslateY.value + event.translationY;

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

if (newIndex !== oldIndex) {
// Sometimes getIndexByPosition will give an index that is off by one because of rounding error (floor\ceil does not help)
if (Math.abs(newIndex - oldIndex) > 1) {
newIndex = Math.sign(newIndex - oldIndex) + oldIndex;
}

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

if (itemIdToSwap !== undefined) {
Expand Down Expand Up @@ -124,7 +128,7 @@ const SortableListItem = (props: Props) => {
});

const draggedAnimatedStyle = useAnimatedStyle(() => {
const scaleY = withSpring(isDragging.value ? 1.1 : 1);
const scale = withSpring(isDragging.value ? propsScale : 1);
const zIndex = isDragging.value ? 100 : withTiming(0, animationConfig);
const opacity = isDragging.value ? 0.95 : 1;
const shadow = isDragging.value
Expand All @@ -140,7 +144,7 @@ const SortableListItem = (props: Props) => {
return {
backgroundColor: Colors.$backgroundDefault, // required for elevation to work in Android
zIndex,
transform: [{translateY: translateY.value}, {scaleY}],
transform: [{translateY: translateY.value}, {scale}],
opacity,
...shadow
};
Expand Down
14 changes: 9 additions & 5 deletions src/components/sortableList/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,17 @@ import React, {useMemo, useCallback} from 'react';
import {FlatList, FlatListProps, LayoutChangeEvent} from 'react-native';
import {useSharedValue} from 'react-native-reanimated';
import {GestureHandlerRootView} from 'react-native-gesture-handler';
import SortableListContext from './SortableListContext';
import SortableListContext, {SortableListContextType} from './SortableListContext';
import SortableListItem from './SortableListItem';
import {useDidUpdate} from 'hooks';
import {useDidUpdate, useThemeProps} from 'hooks';

interface ItemWithId {
id: string;
}

export interface SortableListProps<ItemT extends ItemWithId> extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'> {
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.
*/
Expand All @@ -33,7 +35,8 @@ function generateItemsOrder<ItemT extends ItemWithId>(data: SortableListProps<It
}

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

const itemsOrder = useSharedValue<string[]>(generateItemsOrder(data));
const itemHeight = useSharedValue<number>(52);
Expand Down Expand Up @@ -67,7 +70,8 @@ const SortableList = <ItemT extends ItemWithId>(props: SortableListProps<ItemT>)
onChange,
itemHeight,
onItemLayout,
enableHaptic
enableHaptic,
scale
};
}, [data]);

Expand Down
16 changes: 14 additions & 2 deletions src/incubator/TextField/Input.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import React, {useContext} from 'react';
import {TextInput, StyleSheet, Platform} from 'react-native';
import React, {useContext, useMemo} from 'react';
import {TextInput as RNTextInput, StyleSheet, Platform} from 'react-native';
import {Constants, ForwardRefInjectedProps} from '../../commons/new';
import {InputProps, ColorType} from './types';
import {getColorByState} from './Presenter';
Expand All @@ -18,6 +18,7 @@ const Input = ({
color = DEFAULT_INPUT_COLOR,
forwardedRef,
formatter,
useGestureHandlerInput,
...props
}: InputProps & ForwardRefInjectedProps) => {
const inputRef = useImperativeInputHandle(forwardedRef, {onChangeText: props.onChangeText});
Expand All @@ -27,6 +28,17 @@ const Input = ({
const placeholderTextColor = getColorByState(props.placeholderTextColor, context);
const value = formatter && !context.isFocused ? formatter(props.value) : props.value;

const TextInput = useMemo(() => {
if (useGestureHandlerInput) {
const {
TextInput: GestureTextInput
}: typeof import('react-native-gesture-handler') = require('react-native-gesture-handler');
return GestureTextInput;
} else {
return RNTextInput;
}
}, [useGestureHandlerInput]);

return (
<TextInput
style={[styles.input, !!inputColor && {color: inputColor}, style]}
Expand Down
5 changes: 5 additions & 0 deletions src/incubator/TextField/textField.api.json
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,11 @@
"name": "formatter",
"type": "(value) => string | undefined",
"description": "Custom formatter for the input value (used only when input if not focused)"
},
{
"name": "useGestureHandlerInput",
"type": "boolean",
"description": "Use react-native-gesture-handler instead of react-native for the base TextInput"
}
],
"snippet": [
Expand Down
4 changes: 4 additions & 0 deletions src/incubator/TextField/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,10 @@ export interface InputProps
* Custom formatter for the input value (used only when input if not focused)
*/
formatter?: (value?: string) => string | undefined;
/**
* Use react-native-gesture-handler instead of react-native for the base TextInput
*/
useGestureHandlerInput?: boolean;
}

export type TextFieldProps = MarginModifiers &
Expand Down