Skip to content

Instantly share code, notes, and snippets.

@ianmartorell
Last active October 13, 2024 12:00
Show Gist options
  • Save ianmartorell/c03c8127a83e638e29dbf3d58ef40744 to your computer and use it in GitHub Desktop.
Save ianmartorell/c03c8127a83e638e29dbf3d58ef40744 to your computer and use it in GitHub Desktop.
React Native component to render a list that is sortable through long press and drag. Tested with React Native Web.
import React, {
ReactElement,
useRef,
useState,
useCallback,
createRef,
RefObject,
useEffect,
useMemo,
} from 'react';
import {
View,
Animated,
PanResponder,
StyleSheet,
LayoutChangeEvent,
TouchableOpacity,
PanResponderGestureState,
Easing,
Vibration,
} from 'react-native';
const DEFAULT_Z_INDEX = 8;
const ACTIVE_Z_INDEX = 99;
// We use useNativeDriver: false, so we can rely on _value
type AnimatedValue = Animated.Value & { _value: number };
type AnimatedValueXY = Animated.ValueXY & {
x: { _value: number };
y: { _value: number };
};
interface DataSourceItem {
data: any;
index: number;
position: AnimatedValueXY;
scale: AnimatedValue;
height: AnimatedValue;
}
type DataSource = DataSourceItem[];
interface ActiveItem {
ref: RefObject<View>;
index: number;
moveToIndex: number;
}
export enum ScaleType {
Both = 'scale',
X = 'scaleX',
Y = 'scaleY',
}
export interface SortableListProps<T> {
data: T[];
renderItem: (item: T, index: number) => ReactElement;
keyExtractor: (item: T, index: number) => string;
onClickItem?: (item: T, index: number, data: T[]) => void;
onDragStart?: (startIndex: number) => void;
onDragging?: (
moveToIndex: number,
dx: number,
dy: number,
gestureState: PanResponderGestureState
) => void;
onDragEnd?: (startIndex: number, endIndex: number) => void;
onDataChange?: (data: T[]) => void;
enableScroll?: () => void;
disableScroll?: () => void;
enabled?: boolean;
dragFreely?: boolean;
fixedItems?: number[];
delayLongPress?: number;
scaleType?: ScaleType;
maxScale?: number;
scaleDuration?: number;
minOpacity?: number;
slideDuration?: number;
}
type TSortableList<T = any> = React.FC<SortableListProps<T>>;
export const SortableList: TSortableList = ({
data,
renderItem,
keyExtractor,
onClickItem = () => {},
onDragStart = () => {},
onDragging = () => {},
onDragEnd = () => {},
onDataChange = () => {},
enableScroll = () => {},
disableScroll = () => {},
enabled = true,
dragFreely = true,
fixedItems = [],
delayLongPress = 200,
scaleType = ScaleType.Both,
maxScale = 1.1,
scaleDuration = 100,
minOpacity = 0.8,
slideDuration = 300,
}) => {
/**
* "Static" variables
*/
const itemRefs = useRef(new Map<string, RefObject<View>>());
const activeItem = useRef<ActiveItem | null>(null);
const panResponderActive = useRef(false);
const hasMoved = useRef(false);
const releaseTimeoutHandler = useRef<number | null>(null);
/**
* Computes dataSource from props data
*/
const processData: (data: any[], dataSource?: DataSource) => DataSource = useCallback(
(data, dataSource) => {
return data.map((item, index) => {
const oldItem = dataSource?.find(
el => keyExtractor(el.data, index) === keyExtractor(item, index)
);
return {
data: item,
index,
position:
oldItem?.position ??
(new Animated.ValueXY({ x: 0, y: 0 }) as AnimatedValueXY),
scale: oldItem?.scale ?? (new Animated.Value(1) as AnimatedValue),
height: oldItem?.height ?? (new Animated.Value(0) as AnimatedValue),
};
});
},
[keyExtractor]
);
/**
* Component state
*/
const [prevData, setPrevData] = useState(data);
const [dataSource, setDataSource] = useState(processData(data));
if (data !== prevData) {
setDataSource(processData(data, dataSource));
setPrevData(data);
}
/**
* Returns data in the original format, for use in notifying the
* parent component of changes in the data.
*/
const getOriginalData = useCallback((dataSource: DataSource) => {
const data = dataSource.map(item => item.data);
return data;
}, []);
/**
* Uses keyExtractor to get the key of an item from its index.
*/
const getKey = useCallback(
(index: number) => {
const item = dataSource[index];
return keyExtractor(item.data, index);
},
[dataSource, keyExtractor]
);
/**
* Computes the y position of the top edge of an item given its index.
*/
const getYFromIndex = useCallback(
index => {
let y = 0;
dataSource.every((item, idx) => {
if (idx < index) {
y += item.height._value;
return true;
}
return false;
});
return y;
},
[dataSource]
);
/**
* Move an item from one position to another, with the corresponding
* shift of the other items in the data source.
*/
const changePosition = useCallback(
(startIndex, endIndex) => {
// If the item hasn't changed index, move it back to its position
if (startIndex === endIndex) {
const item = dataSource[startIndex];
if (item !== null) {
item.position.setValue({ x: 0, y: 0 });
// Animated.spring(item.position, {
// toValue: {
// x: 0,
// y: 0,
// },
// stiffness: 200,
// damping: 20,
// }).start();
}
return;
}
// Create new data source with displaced items
let changeDirection = true;
if (startIndex > endIndex) {
changeDirection = false;
const tempIndex = startIndex;
startIndex = endIndex;
endIndex = tempIndex;
}
const newDataSource = dataSource.map((item, index, array) => {
let newIndex = null;
if (changeDirection) {
if (endIndex > index && index >= startIndex) {
newIndex = index + 1;
} else if (endIndex === index) {
newIndex = startIndex;
}
} else {
if (endIndex >= index && index > startIndex) {
newIndex = index - 1;
} else if (startIndex === index) {
newIndex = endIndex;
}
}
if (newIndex !== null) {
const newItem = { ...array[newIndex] };
newItem.position.setValue({ x: 0, y: 0 });
item = newItem;
}
return item;
});
setDataSource(newDataSource);
onDataChange(getOriginalData(newDataSource));
},
[dataSource, getOriginalData, onDataChange]
);
/**
* Handle every tick of the PanResponder
*/
const moveTouch = useCallback(
(_, gestureState) => {
hasMoved.current = true;
if (!activeItem.current) return;
let { dx, dy } = gestureState;
// Restrict to parent bounds
if (!dragFreely) {
const currentY = getYFromIndex(activeItem.current.index);
const height = dataSource[activeItem.current.index].height._value;
const minY = -currentY;
const maxY =
dataSource.reduce((acc, cur) => acc + cur.height._value, 0) - currentY - height;
if (dy < minY) dy = minY;
if (dy > maxY) dy = maxY;
dx = 0;
}
// Ensure dragged item is displayed above all others
activeItem.current.ref.current?.setNativeProps({
style: {
zIndex: ACTIVE_Z_INDEX,
},
});
// Move the dragged item
dataSource[activeItem.current.index].position.setValue({
x: dx,
y: dy,
});
// Find the index the active item should move to
const topEdge = getYFromIndex(activeItem.current.index);
const botEdge = topEdge + dataSource[activeItem.current.index].height._value;
const edge = dy > 0 ? botEdge : topEdge;
const y = edge + dy;
let moveToIndex: number = -1;
dataSource.reduce((acc, cur, idx) => {
if (
(dy < 0 && y < acc + cur.height._value / 2 && moveToIndex === -1) ||
(dy > 0 && y > acc + cur.height._value / 2)
) {
moveToIndex = idx;
}
return acc + cur.height._value;
}, 0);
if (moveToIndex === -1) {
moveToIndex = activeItem.current.index;
}
// Notify parent component
onDragging(moveToIndex, dx, dy, gestureState);
// Slide the other items every time moveToIndex changes
if (activeItem.current.moveToIndex !== moveToIndex) {
if (fixedItems.length > 0 && fixedItems.includes(moveToIndex)) return;
activeItem.current.moveToIndex = moveToIndex;
dataSource.forEach((item, index) => {
if (!activeItem.current) return;
let nextItem = null;
if (index >= activeItem.current.index && index <= moveToIndex) {
nextItem = dataSource[index - 1];
} else if (index <= activeItem.current.index && index >= moveToIndex) {
nextItem = dataSource[index + 1];
} else if (index !== activeItem.current.index && item.position.y._value !== 0) {
nextItem = dataSource[index];
} else if (
(activeItem.current.index - moveToIndex > 0 && moveToIndex === index + 1) ||
(activeItem.current.index - moveToIndex < 0 && moveToIndex === index - 1)
) {
nextItem = dataSource[index];
}
if (nextItem) {
const height = dataSource[activeItem.current.index].height._value;
Animated.timing(item.position, {
toValue: {
x: 0,
y: (nextItem.index - item.index) * height,
},
duration: slideDuration,
easing: Easing.out(Easing.quad),
useNativeDriver: false,
}).start();
}
});
}
},
[dataSource, dragFreely, fixedItems, getYFromIndex, onDragging, slideDuration]
);
/**
* Scale up the touched item and start the PanResponder.
*/
const startTouch = useCallback(
index => {
if (fixedItems.length > 0 && fixedItems.includes(index)) {
return;
}
hasMoved.current = false;
if (!enabled) return;
disableScroll();
Vibration.vibrate(200);
const key = getKey(index);
if (itemRefs.current.has(key)) {
onDragStart(index);
Animated.timing(dataSource[index].scale, {
toValue: maxScale,
duration: scaleDuration,
useNativeDriver: false,
}).start(() => {
activeItem.current = {
// @ts-ignore we already checked for the ref
ref: itemRefs.current.get(key),
index,
moveToIndex: index,
};
panResponderActive.current = true;
});
}
},
[
dataSource,
disableScroll,
enabled,
fixedItems,
getKey,
maxScale,
onDragStart,
scaleDuration,
]
);
/**
* Scale the active item back down and change position of items
* in the data source.
*/
const endTouch = useCallback(() => {
if (!activeItem.current) return;
onDragEnd(activeItem.current.index, activeItem.current.moveToIndex);
enableScroll();
Animated.timing(dataSource[activeItem.current.index].scale, {
toValue: 1,
duration: scaleDuration,
useNativeDriver: false,
}).start(() => {
if (!activeItem.current) return;
const key = getKey(activeItem.current.index);
const ref = itemRefs.current.get(key);
ref?.current?.setNativeProps({
style: {
zIndex: DEFAULT_Z_INDEX,
},
});
changePosition(activeItem.current.index, activeItem.current.moveToIndex);
activeItem.current = null;
});
}, [changePosition, dataSource, enableScroll, getKey, onDragEnd, scaleDuration]);
/**
* We need to initialize the PanResponder with useMemo instead of
* useRef, because otherwise it won't update whenever the useCallback
* references update. There's no way to declare dependencies to useRef.
*/
const panResponder = useMemo(
() =>
PanResponder.create({
onStartShouldSetPanResponder: () => true,
onStartShouldSetPanResponderCapture: () => {
panResponderActive.current = false;
return false;
},
onMoveShouldSetPanResponder: () => panResponderActive.current,
onMoveShouldSetPanResponderCapture: () => panResponderActive.current,
onPanResponderGrant: () => {},
onPanResponderMove: moveTouch,
onPanResponderRelease: endTouch,
onPanResponderTerminationRequest: () => false,
onShouldBlockNativeResponder: () => false,
}),
[endTouch, moveTouch]
);
/**
* This is a fix for the PanResponder not releasing correctly if
* the user didn't move the cursor before releasing.
*/
const onPressOut = useCallback(() => {
releaseTimeoutHandler.current = setTimeout(() => {
if (panResponderActive.current && !hasMoved.current) {
endTouch();
}
}, 200);
}, [endTouch]);
useEffect(() => {
return () => {
if (releaseTimeoutHandler.current) clearTimeout(releaseTimeoutHandler.current);
};
}, []);
/**
* We save the item heights in the onLayout function for later use
*/
const onItemLayout = useCallback((event: LayoutChangeEvent, item: DataSourceItem) => {
const { height } = event.nativeEvent.layout;
item.height.setValue(height);
}, []);
const renderItems = useCallback(
() =>
dataSource.map((item, index) => {
const transformObj: any = {};
transformObj[scaleType] = item.scale;
const key = getKey(index);
const ref = createRef<View>();
itemRefs.current.set(key, ref);
return (
<Animated.View
key={key}
ref={ref}
{...panResponder.panHandlers}
onLayout={(event: LayoutChangeEvent) => onItemLayout(event, item)}
style={[
styles.item,
{
left: item.position.x,
top: item.position.y,
opacity: item.scale.interpolate({
inputRange: [1, maxScale],
outputRange: [1, minOpacity],
}),
transform: [transformObj],
},
]}>
<TouchableOpacity
activeOpacity={1}
delayLongPress={delayLongPress}
onPressOut={onPressOut}
onLongPress={() => startTouch(index)}
onPress={() => onClickItem(item.data, index, getOriginalData(dataSource))}>
{renderItem(item.data, index)}
</TouchableOpacity>
</Animated.View>
);
}),
[
dataSource,
delayLongPress,
getKey,
getOriginalData,
maxScale,
minOpacity,
onClickItem,
onItemLayout,
onPressOut,
panResponder,
renderItem,
scaleType,
startTouch,
]
);
return <View style={styles.container}>{renderItems()}</View>;
};
const styles = StyleSheet.create({
container: {
flexDirection: 'column',
alignItems: 'stretch',
},
item: {
zIndex: DEFAULT_Z_INDEX,
},
});
@ianmartorell
Copy link
Author

Usage is something like this:

<SortableList
  data={data ?? []}
  delayLongPress={180}
  onDataChange={data => updateData(data)}
  keyExtractor={item => item.id}
  onClickItem={item => editItem(item)}
  renderItem={item => (
    <View key={item.id}>
      <Text>{item.content}</Text>
    </View>
  )}
/>

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment