在 React Native 中,构建一个支持拖拽排序的列表是一个常见的需求。当然有很多开源的组件供我们使用,但是在一些特殊情况下,比如要使用拖拽排序时要安装很多其它依赖的组件,当有些依赖和当前主项目版本不兼容时,我们就无能为力了。下面我们看看如何使用最少的依赖去实现这个功能,并且定制化也很强。
一、功能需求
- 支持长按拖拽:用户长按列表项后可以上下拖动调整顺序。
- 平滑动画过渡:拖拽时,其他项应平滑移动以适应新顺序。
- 数据同步:拖拽完成后,数据应按照新的顺序更新,并通知上层组件。
二、实现思路
为了实现拖拽排序时,让元素动起来时有动画的效果,我们可以借助 react native 自带的 Animated.Value 和 Animated.View 去做动画,因为列表每一项是按照顺序往下排列的,所以方便控制每个元素的位置,我们需要将列表每项固定高度以及使用绝对定位去排列元素位置。这就需要我们在外部数据传入时,先构造一个带位置属性的数据结构。
//dataSource 是我们传入的数据数组
useEffect(()=>{
let arr = [];
for(let i = 0; i < dataSource?.length; i++){
let value = dataSource[i];
value.pAnimatedY = new Animated.Value(i*(ITEM_HEIGHT)); // 元素 y 坐标
value.pIndex = i; //元素当前的索引,在拖拽交换两个元素位置时有用
arr.push(value);
}
setData(arr);
}, [dataSource])
三、初始实现
我们首先使用react-native-gesture-handler 来实现拖拽功能,核心代码如下:
//当前正在拖拽的元素
const currentIndex = useRef(null);
const spaceRef = useRef(null);
const getItemByY = (y) => {
//判断当前 y 移动到了那个 item 上面
for(let i = 0; i < data?.length; i++){
const item = data[i];
const top = item.pIndex * ITEM_HEIGHT;
if( y < top+ITEM_HEIGHT && y > top ){
return {
item, i
};
}
}
return {};
}
//长按手势
const longPress = Gesture.LongPress().onStart((e)=>{
onStart(e);
}).minDuration(500)
//拖拽和停止拖拽
const pan = Gesture.Pan().onUpdate((e) => {
onMove(e);
}).onFinalize((e)=>{
onEnd();
}).activateAfterLongPress(500);
//手势合并
const composed = Gesture.Simultaneous(longPress, pan);
const onStart = (e) => {
const {item} = getItemByY(e.y);
currentIndex.current = item?.pIndex;
//这里处理是因为长按时,以及拖拽时让元素不要晃动,因为元素位置的坐标在左上角,但是按的时候可能按在了元素的其他位置
spaceRef.current = e.y - currentIndex.current*ITEM_HEIGHT;
}
const onMove = (e) => {
//判断是否可以滑动
if(currentIndex.current !== null){
let {y} = e;
if(y < 0){
y = 0;
}
if(y > data?.length*ITEM_HEIGHT){
y = data?.length*ITEM_HEIGHT;
}
//这里不需要 setData, 因为页面更新是通过动画更新
data[currentIndex.current].pAnimatedY.setValue(y-spaceRef.current);
onMoveOtherItem(y);
}
}
const onEnd = () => {
if(currentIndex.current !== null){
//拖拽停止,将被拖拽元素归位,拖拽时交换过索引的
Animated.timing(data[currentIndex.current].pAnimatedY, {
toValue: data[currentIndex.current].pIndex*ITEM_HEIGHT,
duration: 100,
useNativeDriver: false,
}).start((finished)=>{
if(finished){
currentIndex.current = null;
setActiveIndex(null);
//按照排序重置data 数据
data.sort((a, b) => a.pIndex - b.pIndex);
setData(data);
onDragEnd?.(data);
}
});
}
}
const onMoveOtherItem = (y) => {
const {item, i} = getItemByY(y);
if(item){
const pIndex = item.pIndex;
//判断当前拖拽元素移动到了某个元素上面,那被移动到的某个元素要和当前拖拽元素交换位置,
// 通过动画将被拖拽元素移动位置
Animated.timing(data[i].pAnimatedY, {
toValue: data[currentIndex.current].pIndex*ITEM_HEIGHT,
duration: 200,
useNativeDriver: false,
}).start();
//交互索引
data[i].pIndex = data[currentIndex.current].pIndex;
//更新当前拖拽元素的索引
data[currentIndex.current].pIndex = pIndex;
data[currentIndex.current].pAnimatedY.setValue(y-spaceRef.current);
}
}
//渲染列表项
const renderItem = (item, index) => {
return(
<Animated.View
style={[styles.item, {top: item?.pAnimatedY}}
key={index}
/>
)
}
//渲染列表
const renderList = () => {
return(
<>
{
data?.map((item, index)=>{
return renderItem(item, index);
})
}
</>
)
}
return(
<GestureHandlerRootView>
<GestureDetector gesture={composed}>
<View style={[styles.container, {height: totalH}]}>
{renderList()}
</View>
</GestureDetector>
</GestureHandlerRootView>
)
四、待改进点
- 当元素超过一屏显示后怎么处理
- 如果一个列表分成多组,并且每个组都单独拖拽不可以互相拖拽怎么处理,还要考虑每组如果超过一屏的情况