Last active
October 13, 2024 12:00
-
-
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.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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, | |
}, | |
}); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage is something like this: