Skip to content

Commit

Permalink
Fix/sortable list item fixes (#2217)
Browse files Browse the repository at this point in the history
* 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
  • Loading branch information
M-i-k-e-l authored Aug 31, 2022
1 parent 46a9fe7 commit 684a39f
Show file tree
Hide file tree
Showing 8 changed files with 95 additions and 45 deletions.
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

0 comments on commit 684a39f

Please sign in to comment.