React Native 列表拖拽排序实现思路解析

424 阅读3分钟

在 React Native 中,构建一个支持拖拽排序的列表是一个常见的需求。当然有很多开源的组件供我们使用,但是在一些特殊情况下,比如要使用拖拽排序时要安装很多其它依赖的组件,当有些依赖和当前主项目版本不兼容时,我们就无能为力了。下面我们看看如何使用最少的依赖去实现这个功能,并且定制化也很强。

一、功能需求

  1. 支持长按拖拽:用户长按列表项后可以上下拖动调整顺序。
  2. 平滑动画过渡:拖拽时,其他项应平滑移动以适应新顺序。
  3. 数据同步:拖拽完成后,数据应按照新的顺序更新,并通知上层组件。

二、实现思路

为了实现拖拽排序时,让元素动起来时有动画的效果,我们可以借助 react native 自带的 Animated.ValueAnimated.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>
)

四、待改进点

  1. 当元素超过一屏显示后怎么处理
  2. 如果一个列表分成多组,并且每个组都单独拖拽不可以互相拖拽怎么处理,还要考虑每组如果超过一屏的情况