/** * 支持长按拖拽排序的flatlist,右边加个固定的按钮,拖拽排序。 * 考虑到方便实现+节省性能,整个app内的拖拽排序都遵守以下实现。 * 点击会出现 */ import globalStyle from '@/constants/globalStyle'; import {iconSizeConst} from '@/constants/uiConst'; import useTextColor from '@/hooks/useTextColor'; import rpx from '@/utils/rpx'; import {FlashList} from '@shopify/flash-list'; import React, { ForwardedRef, forwardRef, memo, useEffect, useMemo, useRef, useState, } from 'react'; import {LayoutRectangle, Pressable, StyleSheet, View} from 'react-native'; import { runOnJS, useDerivedValue, useSharedValue, } from 'react-native-reanimated'; import Icon from '@/components/base/icon.tsx'; const defaultZIndex = 10; interface ISortableFlatListProps { data: T[]; renderItem: (props: {item: T; index: number}) => JSX.Element; // 高度 itemHeight: number; itemJustifyContent?: | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly'; // 滚动list距离顶部的距离, 这里写的不好 marginTop: number; /** 拖拽时的背景色 */ activeBackgroundColor?: string; /** 交换结束 */ onSortEnd?: (newData: T[]) => void; } export default function SortableFlatList( props: ISortableFlatListProps, ) { const { data, renderItem, itemHeight, itemJustifyContent, marginTop, activeBackgroundColor, onSortEnd, } = props; // 不要干扰原始数据 const [_data, _setData] = useState([...(data ?? [])]); // 是否禁止滚动 const [scrollEnabled, setScrollEnabled] = useState(true); // 是否处在激活状态, -1表示无,其他表示当前激活的下标 const activeRef = useRef(-1); const [activeItem, setActiveItem] = useState(null); const layoutRef = useRef(); // listref const listRef = useRef | null>(null); // fakeref const fakeItemRef = useRef(null); // contentoffset const contentOffsetYRef = useRef(-1); const targetOffsetYRef = useRef(0); const direction = useSharedValue(0); useEffect(() => { _setData([...(data ?? [])]); }, [data]); const initDragPageY = useRef(0); const initDragLocationY = useRef(0); const offsetRef = useRef(0); //#region 滚动 const scrollingRef = useRef(false); // 列表整体的高度 const listContentHeight = useMemo( () => itemHeight * data.length, [data, itemHeight], ); function scrollToTarget(forceScroll = false) { // 未选中 if (activeRef.current === -1) { scrollingRef.current = false; return; } // 滚动中就不滚了 / if (scrollingRef.current && !forceScroll) { scrollingRef.current = true; return; } // 方向是0 if (direction.value === 0) { scrollingRef.current = false; return; } const nextTarget = Math.sign(direction.value) * Math.max(Math.abs(direction.value), 0.3) * 300 + contentOffsetYRef.current; // 当前到极限了 if ( (contentOffsetYRef.current <= 2 && nextTarget < contentOffsetYRef.current) || (contentOffsetYRef.current >= listContentHeight - (layoutRef.current?.height ?? 0) - 2 && nextTarget > contentOffsetYRef.current) ) { scrollingRef.current = false; return; } scrollingRef.current = true; // 超出区域 targetOffsetYRef.current = Math.min( Math.max(0, nextTarget), listContentHeight - (layoutRef.current?.height ?? 0), ); listRef.current?.scrollToOffset({ animated: true, offset: targetOffsetYRef.current, }); } useDerivedValue(() => { // 正在滚动 if (scrollingRef.current) { return; } else if (direction.value !== 0) { // 开始滚动 runOnJS(scrollToTarget)(); } }, []); //#endregion return ( {/* 纯展示 */} (fakeItemRef.current = _)} backgroundColor={activeBackgroundColor} renderItem={renderItem} itemHeight={itemHeight} item={activeItem} itemJustifyContent={itemJustifyContent} /> { listRef.current = _; }} onLayout={evt => { layoutRef.current = evt.nativeEvent.layout; }} data={_data} estimatedItemSize={itemHeight} scrollEventThrottle={16} onTouchStart={e => { if (activeRef.current !== -1) { // 相对于整个页面顶部的距离 initDragPageY.current = e.nativeEvent.pageY; initDragLocationY.current = e.nativeEvent.locationY; } }} onTouchMove={e => { if (activeRef.current !== -1) { offsetRef.current = e.nativeEvent.pageY - (marginTop ?? layoutRef.current?.y ?? 0) - itemHeight / 2; if (offsetRef.current < 0) { offsetRef.current = 0; } else if ( offsetRef.current > (layoutRef.current?.height ?? 0) - itemHeight ) { offsetRef.current = (layoutRef.current?.height ?? 0) - itemHeight; } fakeItemRef.current!.setNativeProps({ top: offsetRef.current, opacity: 1, zIndex: 100, }); // 如果超出范围,停止 if (offsetRef.current < itemHeight * 2) { // 上滑 direction.value = offsetRef.current / itemHeight / 2 - 1; } else if ( offsetRef.current > (layoutRef.current?.height ?? 0) - 3 * itemHeight ) { // 下滑 direction.value = (offsetRef.current - (layoutRef.current?.height ?? 0) + 3 * itemHeight) / itemHeight / 2; } else { // 不滑动 direction.value = 0; } } }} onTouchEnd={e => { if (activeRef.current !== -1) { // 计算最终的位置,触发onSortEnd let index = activeRef.current; if (contentOffsetYRef.current !== -1) { index = Math.round( (contentOffsetYRef.current + offsetRef.current) / itemHeight, ); } else { // 拖动的距离 index = activeRef.current + Math.round( (e.nativeEvent.pageY - initDragPageY.current + initDragLocationY.current) / itemHeight, ); } index = Math.min(data.length, Math.max(index, 0)); // from: activeRef.current to: index if (activeRef.current !== index) { let nData = _data .slice(0, activeRef.current) .concat(_data.slice(activeRef.current + 1)); nData.splice(index, 0, activeItem as T); onSortEnd?.(nData); // 测试用,正式时移除掉 // _setData(nData); } } scrollingRef.current = false; activeRef.current = -1; setScrollEnabled(true); setActiveItem(null); fakeItemRef.current!.setNativeProps({ top: 0, opacity: 0, zIndex: -1, }); }} onTouchCancel={() => { // todo: 滑动很快的时候会触发取消,native的flatlist就这样 activeRef.current = -1; scrollingRef.current = false; setScrollEnabled(true); setActiveItem(null); fakeItemRef.current!.setNativeProps({ top: 0, opacity: 0, zIndex: -1, }); contentOffsetYRef.current = -1; }} onScroll={e => { contentOffsetYRef.current = e.nativeEvent.contentOffset.y; if ( activeRef.current !== -1 && Math.abs( contentOffsetYRef.current - targetOffsetYRef.current, ) < 2 ) { scrollToTarget(true); } }} renderItem={({item, index}) => { return ( ); }} /> ); } interface ISortableFlatListItemProps { item: T; index: number; // 高度 itemHeight: number; itemJustifyContent?: | 'flex-start' | 'flex-end' | 'center' | 'space-between' | 'space-around' | 'space-evenly'; setScrollEnabled: (scrollEnabled: boolean) => void; renderItem: (props: {item: T; index: number}) => JSX.Element; setActiveItem: (item: T | null) => void; activeRef: React.MutableRefObject; } function _SortableFlatListItem(props: ISortableFlatListItemProps) { const { itemHeight, setScrollEnabled, renderItem, setActiveItem, itemJustifyContent, item, index, activeRef, } = props; // 省一点性能,height是顺着传下来的,放ref就好了 const styleRef = useRef( StyleSheet.create({ viewWrapper: { height: itemHeight, width: '100%', flexDirection: 'row', justifyContent: itemJustifyContent ?? 'flex-end', zIndex: defaultZIndex, }, btn: { height: itemHeight, justifyContent: 'center', alignItems: 'center', position: 'absolute', top: 0, right: 0, width: rpx(100), textAlignVertical: 'center', }, }), ); const textColor = useTextColor(); return ( {renderItem({item, index})} { if (activeRef.current !== -1) { return; } /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */ activeRef.current = index; /** 锁定滚动 */ setScrollEnabled(false); setActiveItem(item); }} style={styleRef.current.btn}> ); } const SortableFlatListItem = memo( _SortableFlatListItem, (prev, curr) => prev.index === curr.index && prev.item === curr.item, ); const FakeFlatListItem = forwardRef(function ( props: Pick< ISortableFlatListItemProps, 'itemHeight' | 'renderItem' | 'item' | 'itemJustifyContent' > & { backgroundColor?: string; }, ref: ForwardedRef, ) { const {itemHeight, renderItem, item, backgroundColor, itemJustifyContent} = props; const styleRef = useRef( StyleSheet.create({ viewWrapper: { height: itemHeight, width: '100%', flexDirection: 'row', justifyContent: itemJustifyContent ?? 'flex-end', zIndex: defaultZIndex, }, btn: { height: itemHeight, paddingHorizontal: rpx(28), justifyContent: 'center', alignItems: 'center', position: 'absolute', top: 0, right: 0, width: rpx(100), textAlignVertical: 'center', }, }), ); const textColor = useTextColor(); return ( {item ? renderItem({item, index: -1}) : null} ); }); const style = StyleSheet.create({ activeItemDefault: { opacity: 0, zIndex: -1, position: 'absolute', top: 0, left: 0, }, });