基于Reanimated 2、React Native Gesture Handler和Expo的可拖拽滚动ScrollView实现
Expo + Reanimated 2 + React Native Gesture Handler 可拖拽滚动视图优化示例
针对你遇到的iPhone端拖拽至顶部/底部时无法正常滚动的问题,核心原因是原示例未实现边界自动滚动逻辑,仅依赖手指位移触发微小滚动。以下是优化后的完整实现,解决边界滚动问题同时保留拖拽排序功能:
依赖安装
确保已安装所需依赖(Expo项目直接执行以下命令):
npx expo install react-native-reanimated react-native-gesture-handler
完整代码实现
import React, { useState, useRef } from 'react'; import { View, Text, StyleSheet } from 'react-native'; import { GestureHandlerRootView } from 'react-native-gesture-handler'; import Animated, { useSharedValue, useAnimatedStyle, useAnimatedScrollHandler, withTiming, runOnJS, cancelAnimation, } from 'react-native-reanimated'; import { PanGestureHandler, PanGestureHandlerGestureEvent } from 'react-native-gesture-handler'; // 列表项数据类型 type Item = { id: string; text: string; }; export default function DragSortScrollView() { // 列表数据 const [items, setItems] = useState<Item[]>([ { id: '1', text: '列表项 1' }, { id: '2', text: '列表项 2' }, { id: '3', text: '列表项 3' }, { id: '4', text: '列表项 4' }, { id: '5', text: '列表项 5' }, { id: '6', text: '列表项 6' }, { id: '7', text: '列表项 7' }, { id: '8', text: '列表项 8' }, { id: '9', text: '列表项 9' }, { id: '10', text: '列表项 10' }, ]); // 滚动视图的滚动偏移量 const scrollOffset = useSharedValue(0); // 当前拖拽项的索引和偏移量 const activeIndex = useSharedValue(-1); const dragOffset = useSharedValue({ x: 0, y: 0 }); // 边界滚动动画的定时器引用 const scrollTimer = useRef<NodeJS.Timeout | null>(null); // 滚动事件处理,更新scrollOffset const scrollHandler = useAnimatedScrollHandler({ onScroll: (event) => { scrollOffset.value = event.contentOffset.y; }, }); // 处理拖拽项的位置更新 const updateDragPosition = (index: number, y: number) => { activeIndex.value = index; dragOffset.value = { x: 0, y }; }; // 处理边界自动滚动 const handleBoundaryScroll = (direction: 'up' | 'down') => { cancelAnimation(scrollOffset); // 清除之前的定时器 if (scrollTimer.current) clearInterval(scrollTimer.current); // 每隔一段时间触发滚动 scrollTimer.current = setInterval(() => { scrollOffset.value = withTiming( scrollOffset.value + (direction === 'down' ? 20 : -20), { duration: 16 }, () => { // 滚动后更新拖拽项位置(保持相对手指的位置) if (activeIndex.value !== -1) { dragOffset.value = { ...dragOffset.value, y: dragOffset.value.y + (direction === 'down' ? 20 : -20), }; } } ); }, 16); }; // 停止边界滚动 const stopBoundaryScroll = () => { if (scrollTimer.current) { clearInterval(scrollTimer.current); scrollTimer.current = null; } cancelAnimation(scrollOffset); }; // 拖拽结束后排序并重置状态 const handleDragEnd = () => { stopBoundaryScroll(); if (activeIndex.value === -1) return; // 计算拖拽项的最终位置对应的新索引 const itemHeight = 60; // 列表项高度,需和样式中一致 const finalY = dragOffset.value.y - scrollOffset.value; const newIndex = Math.round(finalY / itemHeight); // 确保索引在有效范围内 const clampedIndex = Math.max(0, Math.min(items.length - 1, newIndex)); // 更新列表数据排序 runOnJS(setItems)((prevItems) => { const newItems = [...prevItems]; const [movedItem] = newItems.splice(activeIndex.value, 1); newItems.splice(clampedIndex, 0, movedItem); return newItems; }); // 重置拖拽状态 activeIndex.value = -1; dragOffset.value = { x: 0, y: 0 }; }; return ( <GestureHandlerRootView style={styles.container}> <Animated.ScrollView style={styles.scrollView} onScroll={scrollHandler} scrollEventThrottle={16} showsVerticalScrollIndicator={false} // 绑定滚动偏移量到ScrollView contentOffset={{ y: scrollOffset.value }} > {items.map((item, index) => { // 拖拽项的动画样式 const animatedStyle = useAnimatedStyle(() => { if (activeIndex.value === index) { return { transform: [ { translateX: dragOffset.value.x }, { translateY: dragOffset.value.y - scrollOffset.value }, ], zIndex: 100, opacity: 0.8, }; } // 非拖拽项在排序时的偏移动画 if (activeIndex.value !== -1) { const delta = activeIndex.value < index ? -60 : 60; return { transform: [{ translateY: activeIndex.value !== -1 ? delta : 0 }], }; } return {}; }); return ( <PanGestureHandler key={item.id} onGestureEvent={(event: PanGestureHandlerGestureEvent) => { const { y } = event.nativeEvent.translation; // 计算拖拽项的绝对位置 const itemTop = index * 60 + scrollOffset.value; updateDragPosition(index, itemTop + y); // 判断是否触达边界,触发自动滚动 const screenHeight = 600; // 可根据实际屏幕高度动态获取 const dragTop = dragOffset.value.y - scrollOffset.value; if (dragTop < 50) { handleBoundaryScroll('up'); } else if (dragTop > screenHeight - 100) { handleBoundaryScroll('down'); } else { stopBoundaryScroll(); } }} onEnded={handleDragEnd} > <Animated.View style={[styles.item, animatedStyle]}> <Text style={styles.itemText}>{item.text}</Text> </Animated.View> </PanGestureHandler> ); })} </Animated.ScrollView> </GestureHandlerRootView> ); } const styles = StyleSheet.create({ container: { flex: 1, backgroundColor: '#f5f5f5', }, scrollView: { flex: 1, }, item: { height: 60, backgroundColor: '#fff', marginVertical: 4, marginHorizontal: 8, borderRadius: 8, justifyContent: 'center', paddingHorizontal: 16, shadowColor: '#000', shadowOffset: { width: 0, height: 2 }, shadowOpacity: 0.1, shadowRadius: 4, elevation: 2, }, itemText: { fontSize: 16, color: '#333', }, });
关键优化点说明
- 边界自动滚动:当拖拽项接近屏幕顶部/底部时,通过定时器驱动ScrollView持续滚动,同时同步更新拖拽项的位置,避免手指移动才能滚动的问题
- 滚动偏移同步:使用
scrollOffset共享值实时同步ScrollView的滚动位置,确保拖拽项的绝对位置计算准确 - 惯性滚动处理:拖拽结束时取消所有滚动动画,避免残留滚动影响排序结果
- 排序动画优化:非拖拽项在排序时添加平滑的偏移动画,提升交互体验
内容的提问来源于stack exchange,提问作者StewartArmbrecht
相关产品推荐
相关产品推荐

