Skip to content

Commit 684a39f

Browse files
authored
Fix/sortable list item fixes (#2217)
* Fix flaky sorting when dragging an item fast * Prettify * Scale both axes and add to props * Add theme * Support TextInput from react-native-gesture-handler * Fix docs * Rename t ouseGestureHandlerInput
1 parent 46a9fe7 commit 684a39f

File tree

8 files changed

+95
-45
lines changed

8 files changed

+95
-45
lines changed

demo/src/screens/componentScreens/SortableListScreen.tsx

+18-4
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ const SortableListScreen = () => {
2828
} else {
2929
setSelectedItems(selectedItems.concat(item));
3030
}
31-
}, [selectedItems, setSelectedItems]);
31+
},
32+
[selectedItems, setSelectedItems]);
3233

3334
const addItem = useCallback(() => {
3435
if (removedItems.length > 0) {
@@ -74,17 +75,30 @@ const SortableListScreen = () => {
7475
</View>
7576
</TouchableOpacity>
7677
);
77-
}, [selectedItems, toggleItemSelection]);
78+
},
79+
[selectedItems, toggleItemSelection]);
7880

7981
return (
8082
<View flex bg-$backgroundDefault>
8183
{renderHeader('Sortable List', {'margin-10': true})}
8284
<View row center marginB-s2>
8385
<Button label="Add Item" size={Button.sizes.xSmall} disabled={removedItems.length === 0} onPress={addItem}/>
84-
<Button label="Remove Items" size={Button.sizes.xSmall} disabled={selectedItems.length === 0} marginL-s3 onPress={removeSelectedItems}/>
86+
<Button
87+
label="Remove Items"
88+
size={Button.sizes.xSmall}
89+
disabled={selectedItems.length === 0}
90+
marginL-s3
91+
onPress={removeSelectedItems}
92+
/>
8593
</View>
8694
<View flex useSafeArea>
87-
<SortableList data={items} renderItem={renderItem} keyExtractor={keyExtractor} onOrderChange={onOrderChange}/>
95+
<SortableList
96+
data={items}
97+
renderItem={renderItem}
98+
keyExtractor={keyExtractor}
99+
onOrderChange={onOrderChange}
100+
scale={1.02}
101+
/>
88102
</View>
89103
</View>
90104
);

src/components/sortableList/SortableList.api.json

+6
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@
2222
"name": "enableHaptic",
2323
"type": "boolean",
2424
"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)"
25+
},
26+
{
27+
"name": "scale",
28+
"type": "number",
29+
"default": "1",
30+
"description": "Scale the item once dragged."
2531
}
2632
],
2733
"snippet": [

src/components/sortableList/SortableListContext.ts

+2-1
Original file line numberDiff line numberDiff line change
@@ -2,13 +2,14 @@ import {createContext} from 'react';
22
import {ViewProps} from 'react-native';
33
import {SharedValue} from 'react-native-reanimated';
44

5-
interface SortableListContextType {
5+
export interface SortableListContextType {
66
data: any
77
itemsOrder: SharedValue<string[]>;
88
onChange: () => void;
99
itemHeight: SharedValue<number>;
1010
onItemLayout: ViewProps['onLayout'];
1111
enableHaptic?: boolean;
12+
scale?: number;
1213
}
1314

1415
// @ts-ignore

src/components/sortableList/SortableListItem.tsx

+37-33
Original file line numberDiff line numberDiff line change
@@ -31,55 +31,54 @@ const animationConfig = {
3131
const SortableListItem = (props: Props) => {
3232
const {children, index} = props;
3333

34-
const {data, itemHeight, onItemLayout, itemsOrder, onChange, enableHaptic} = useContext(SortableListContext);
34+
const {
35+
data,
36+
itemHeight,
37+
onItemLayout,
38+
itemsOrder,
39+
onChange,
40+
enableHaptic,
41+
scale: propsScale = 1
42+
} = useContext(SortableListContext);
3543
const {getTranslationByIndexChange, getItemIndexById, getIndexByPosition, getIdByItemIndex} = usePresenter();
3644
const id: string = data[index].id;
3745
const initialIndex = useSharedValue<number>(map(data, 'id').indexOf(id));
46+
const currIndex = useSharedValue(initialIndex.value);
3847
const translateY = useSharedValue<number>(0);
3948

4049
const isDragging = useSharedValue(false);
4150
const tempTranslateY = useSharedValue<number>(0);
4251
const tempItemsOrder = useSharedValue<string[]>(itemsOrder.value);
43-
const dataManuallyChanged = useSharedValue<boolean>(false);
4452

4553
useDidUpdate(() => {
46-
dataManuallyChanged.value = true;
47-
initialIndex.value = map(data, 'id').indexOf(id);
54+
const newItemIndex = map(data, 'id').indexOf(id);
55+
56+
initialIndex.value = newItemIndex;
57+
currIndex.value = newItemIndex;
58+
59+
translateY.value = 0;
4860
}, [data]);
4961

50-
useAnimatedReaction(() => itemsOrder.value,
51-
(currItemsOrder, prevItemsOrder) => {
52-
// Note: Unfortunately itemsOrder sharedValue is being initialized on each render
53-
// Therefore I added this extra check here that compares current and previous values
54-
// See open issue: https://github.com/software-mansion/react-native-reanimated/issues/3224
55-
if (prevItemsOrder === null || currItemsOrder.join(',') === prevItemsOrder.join(',')) {
62+
useAnimatedReaction(() => getItemIndexById(itemsOrder.value, id),
63+
(newIndex, prevIndex) => {
64+
if (prevIndex === null || newIndex === prevIndex) {
5665
return;
57-
} else {
58-
const newIndex = getItemIndexById(currItemsOrder, id);
59-
const oldIndex = getItemIndexById(prevItemsOrder, id);
60-
61-
/* In case the order of the item has returned back to its initial index we reset its position */
62-
if (newIndex === initialIndex.value) {
63-
/* Reset without an animation when the change is due to manual data change */
64-
if (dataManuallyChanged.value) {
65-
translateY.value = 0;
66-
dataManuallyChanged.value = false;
67-
/* Reset with an animation when the change id due to user reordering */
68-
} else {
69-
translateY.value = withTiming(0, animationConfig);
70-
}
71-
/* Handle an order change, animate item to its new position */
72-
} else if (newIndex !== oldIndex) {
73-
const translation = getTranslationByIndexChange(newIndex, oldIndex, itemHeight.value);
74-
translateY.value = withTiming(translateY.value + translation, animationConfig);
75-
}
7666
}
77-
});
67+
68+
currIndex.value = newIndex;
69+
if (!isDragging.value) {
70+
const translation = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemHeight.value);
71+
72+
translateY.value = withTiming(translation, animationConfig);
73+
}
74+
},
75+
[]);
7876

7977
const dragOnLongPressGesture = Gesture.Pan()
8078
.activateAfterLongPress(250)
8179
.onStart(() => {
8280
isDragging.value = true;
81+
translateY.value = getTranslationByIndexChange(currIndex.value, initialIndex.value, itemHeight.value);
8382
tempTranslateY.value = translateY.value;
8483
tempItemsOrder.value = itemsOrder.value;
8584
})
@@ -92,10 +91,15 @@ const SortableListItem = (props: Props) => {
9291
translateY.value = tempTranslateY.value + event.translationY;
9392

9493
// Swapping items
95-
const newIndex = getIndexByPosition(translateY.value, itemHeight.value) + initialIndex.value;
94+
let newIndex = getIndexByPosition(translateY.value, itemHeight.value) + initialIndex.value;
9695
const oldIndex = getItemIndexById(itemsOrder.value, id);
9796

9897
if (newIndex !== oldIndex) {
98+
// Sometimes getIndexByPosition will give an index that is off by one because of rounding error (floor\ceil does not help)
99+
if (Math.abs(newIndex - oldIndex) > 1) {
100+
newIndex = Math.sign(newIndex - oldIndex) + oldIndex;
101+
}
102+
99103
const itemIdToSwap = getIdByItemIndex(itemsOrder.value, newIndex);
100104

101105
if (itemIdToSwap !== undefined) {
@@ -124,7 +128,7 @@ const SortableListItem = (props: Props) => {
124128
});
125129

126130
const draggedAnimatedStyle = useAnimatedStyle(() => {
127-
const scaleY = withSpring(isDragging.value ? 1.1 : 1);
131+
const scale = withSpring(isDragging.value ? propsScale : 1);
128132
const zIndex = isDragging.value ? 100 : withTiming(0, animationConfig);
129133
const opacity = isDragging.value ? 0.95 : 1;
130134
const shadow = isDragging.value
@@ -140,7 +144,7 @@ const SortableListItem = (props: Props) => {
140144
return {
141145
backgroundColor: Colors.$backgroundDefault, // required for elevation to work in Android
142146
zIndex,
143-
transform: [{translateY: translateY.value}, {scaleY}],
147+
transform: [{translateY: translateY.value}, {scale}],
144148
opacity,
145149
...shadow
146150
};

src/components/sortableList/index.tsx

+9-5
Original file line numberDiff line numberDiff line change
@@ -4,15 +4,17 @@ import React, {useMemo, useCallback} from 'react';
44
import {FlatList, FlatListProps, LayoutChangeEvent} from 'react-native';
55
import {useSharedValue} from 'react-native-reanimated';
66
import {GestureHandlerRootView} from 'react-native-gesture-handler';
7-
import SortableListContext from './SortableListContext';
7+
import SortableListContext, {SortableListContextType} from './SortableListContext';
88
import SortableListItem from './SortableListItem';
9-
import {useDidUpdate} from 'hooks';
9+
import {useDidUpdate, useThemeProps} from 'hooks';
1010

1111
interface ItemWithId {
1212
id: string;
1313
}
1414

15-
export interface SortableListProps<ItemT extends ItemWithId> extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'> {
15+
export interface SortableListProps<ItemT extends ItemWithId>
16+
extends Omit<FlatListProps<ItemT>, 'extraData' | 'data'>,
17+
Pick<SortableListContextType, 'scale'> {
1618
/**
1719
* The data of the list, do not update the data.
1820
*/
@@ -33,7 +35,8 @@ function generateItemsOrder<ItemT extends ItemWithId>(data: SortableListProps<It
3335
}
3436

3537
const SortableList = <ItemT extends ItemWithId>(props: SortableListProps<ItemT>) => {
36-
const {data, onOrderChange, enableHaptic, ...others} = props;
38+
const themeProps = useThemeProps(props, 'SortableList');
39+
const {data, onOrderChange, enableHaptic, scale, ...others} = themeProps;
3740

3841
const itemsOrder = useSharedValue<string[]>(generateItemsOrder(data));
3942
const itemHeight = useSharedValue<number>(52);
@@ -67,7 +70,8 @@ const SortableList = <ItemT extends ItemWithId>(props: SortableListProps<ItemT>)
6770
onChange,
6871
itemHeight,
6972
onItemLayout,
70-
enableHaptic
73+
enableHaptic,
74+
scale
7175
};
7276
}, [data]);
7377

src/incubator/TextField/Input.tsx

+14-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import React, {useContext} from 'react';
2-
import {TextInput, StyleSheet, Platform} from 'react-native';
1+
import React, {useContext, useMemo} from 'react';
2+
import {TextInput as RNTextInput, StyleSheet, Platform} from 'react-native';
33
import {Constants, ForwardRefInjectedProps} from '../../commons/new';
44
import {InputProps, ColorType} from './types';
55
import {getColorByState} from './Presenter';
@@ -18,6 +18,7 @@ const Input = ({
1818
color = DEFAULT_INPUT_COLOR,
1919
forwardedRef,
2020
formatter,
21+
useGestureHandlerInput,
2122
...props
2223
}: InputProps & ForwardRefInjectedProps) => {
2324
const inputRef = useImperativeInputHandle(forwardedRef, {onChangeText: props.onChangeText});
@@ -27,6 +28,17 @@ const Input = ({
2728
const placeholderTextColor = getColorByState(props.placeholderTextColor, context);
2829
const value = formatter && !context.isFocused ? formatter(props.value) : props.value;
2930

31+
const TextInput = useMemo(() => {
32+
if (useGestureHandlerInput) {
33+
const {
34+
TextInput: GestureTextInput
35+
}: typeof import('react-native-gesture-handler') = require('react-native-gesture-handler');
36+
return GestureTextInput;
37+
} else {
38+
return RNTextInput;
39+
}
40+
}, [useGestureHandlerInput]);
41+
3042
return (
3143
<TextInput
3244
style={[styles.input, !!inputColor && {color: inputColor}, style]}

src/incubator/TextField/textField.api.json

+5
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,11 @@
8787
"name": "formatter",
8888
"type": "(value) => string | undefined",
8989
"description": "Custom formatter for the input value (used only when input if not focused)"
90+
},
91+
{
92+
"name": "useGestureHandlerInput",
93+
"type": "boolean",
94+
"description": "Use react-native-gesture-handler instead of react-native for the base TextInput"
9095
}
9196
],
9297
"snippet": [

src/incubator/TextField/types.ts

+4
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,10 @@ export interface InputProps
139139
* Custom formatter for the input value (used only when input if not focused)
140140
*/
141141
formatter?: (value?: string) => string | undefined;
142+
/**
143+
* Use react-native-gesture-handler instead of react-native for the base TextInput
144+
*/
145+
useGestureHandlerInput?: boolean;
142146
}
143147

144148
export type TextFieldProps = MarginModifiers &

0 commit comments

Comments
 (0)