1/** 2 * 支持长按拖拽排序的flatlist,右边加个固定的按钮,拖拽排序。 3 * 考虑到方便实现+节省性能,整个app内的拖拽排序都遵守以下实现。 4 * 点击会出现 5 */ 6 7import {iconSizeConst} from '@/constants/uiConst'; 8import useTextColor from '@/hooks/useTextColor'; 9import rpx from '@/utils/rpx'; 10import React, { 11 ForwardedRef, 12 forwardRef, 13 memo, 14 useEffect, 15 useMemo, 16 useRef, 17 useState, 18} from 'react'; 19import { 20 LayoutRectangle, 21 Pressable, 22 StyleSheet, 23 View, 24 ViewToken, 25} from 'react-native'; 26import {FlatList} from 'react-native-gesture-handler'; 27import { 28 runOnJS, 29 useDerivedValue, 30 useSharedValue, 31} from 'react-native-reanimated'; 32import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; 33 34const WINDOW_WIDTH = rpx(750); 35const defaultZIndex = 10; 36 37interface ISortableFlatListProps<T> { 38 data: T[]; 39 renderItem: (props: {item: T; index: number}) => JSX.Element; 40 // 高度 41 itemHeight: number; 42 // 滚动list距离顶部的距离, 这里写的不好 43 marginTop: number; 44 /** 拖拽时的背景色 */ 45 activeBackgroundColor?: string; 46 /** 交换结束 */ 47 onSortEnd?: (newData: T[]) => void; 48} 49 50export default function SortableFlatList<T extends any = any>( 51 props: ISortableFlatListProps<T>, 52) { 53 const { 54 data, 55 renderItem, 56 itemHeight, 57 marginTop, 58 activeBackgroundColor, 59 onSortEnd, 60 } = props; 61 62 // 不要干扰原始数据 63 const [_data, _setData] = useState([...(data ?? [])]); 64 // 是否禁止滚动 65 const [scrollEnabled, setScrollEnabled] = useState(true); 66 // 是否处在激活状态, -1表示无,其他表示当前激活的下标 67 const activeRef = useRef(-1); 68 const [activeItem, setActiveItem] = useState<T | null>(null); 69 70 const viewableItemsRef = useRef<ViewToken[] | null>(null); 71 72 const layoutRef = useRef<LayoutRectangle>(); 73 // listref 74 const listRef = useRef<FlatList<T> | null>(null); 75 // fakeref 76 const fakeItemRef = useRef<View | null>(null); 77 // contentoffset 78 const contentOffsetYRef = useRef<number>(0); 79 const targetOffsetYRef = useRef<number>(0); 80 81 const direction = useSharedValue(0); 82 83 useEffect(() => { 84 _setData([...(data ?? [])]); 85 }, [data]); 86 87 const initDragPageY = useRef<number>(0); 88 const initDragLocationY = useRef<number>(0); 89 const offsetRef = useRef<number>(0); 90 91 //#region 滚动 92 const scrollingRef = useRef(false); 93 94 // 列表整体的高度 95 const getListContentHeight = useMemo( 96 () => () => itemHeight * data.length, 97 [data], 98 ); 99 100 function scrollToTarget(forceScroll = false) { 101 // 未选中 102 if (activeRef.current === -1) { 103 scrollingRef.current = false; 104 return; 105 } 106 107 // 滚动中就不滚了 / 108 if (scrollingRef.current && !forceScroll) { 109 scrollingRef.current = true; 110 return; 111 } 112 // 方向是0 113 if (direction.value === 0) { 114 scrollingRef.current = false; 115 return; 116 } 117 118 const nextTarget = 119 Math.sign(direction.value) * 120 Math.max(Math.abs(direction.value), 0.3) * 121 300 + 122 contentOffsetYRef.current; 123 // 当前到极限了 124 if ( 125 (contentOffsetYRef.current <= 2 && 126 nextTarget < contentOffsetYRef.current) || 127 (contentOffsetYRef.current >= 128 getListContentHeight() - (layoutRef.current?.height ?? 0) - 2 && 129 nextTarget > contentOffsetYRef.current) 130 ) { 131 scrollingRef.current = false; 132 return; 133 } 134 scrollingRef.current = true; 135 // 超出区域 136 targetOffsetYRef.current = Math.min( 137 Math.max(0, nextTarget), 138 getListContentHeight() - (layoutRef.current?.height ?? 0), 139 ); 140 listRef.current?.scrollToOffset({ 141 animated: true, 142 offset: targetOffsetYRef.current, 143 }); 144 } 145 146 useDerivedValue(() => { 147 // 正在滚动 148 if (scrollingRef.current) { 149 return; 150 } else if (direction.value !== 0) { 151 // 开始滚动 152 runOnJS(scrollToTarget)(); 153 } 154 }, []); 155 156 //#endregion 157 158 const onViewRef = useRef((vi: any) => { 159 viewableItemsRef.current = vi.viewableItems; 160 }); 161 return ( 162 <View style={style.flex1}> 163 {/* 纯展示 */} 164 <FakeFlatListItem 165 ref={_ => (fakeItemRef.current = _)} 166 backgroundColor={activeBackgroundColor} 167 renderItem={renderItem} 168 itemHeight={itemHeight} 169 item={activeItem} 170 /> 171 <FlatList 172 scrollEnabled={scrollEnabled} 173 onViewableItemsChanged={onViewRef.current} 174 style={style.flex1} 175 ref={_ => { 176 listRef.current = _; 177 }} 178 onLayout={evt => { 179 layoutRef.current = evt.nativeEvent.layout; 180 }} 181 data={_data} 182 getItemLayout={(_, index) => ({ 183 length: itemHeight, 184 offset: itemHeight * index, 185 index, 186 })} 187 scrollEventThrottle={16} 188 onTouchStart={e => { 189 if (activeRef.current !== -1) { 190 // 相对于整个页面顶部的距离 191 initDragPageY.current = e.nativeEvent.pageY; 192 initDragLocationY.current = e.nativeEvent.locationY; 193 } 194 }} 195 onTouchMove={e => { 196 if (activeRef.current !== -1) { 197 offsetRef.current = 198 e.nativeEvent.pageY - 199 (marginTop ?? layoutRef.current?.y ?? 0) - 200 itemHeight / 2; 201 202 if (offsetRef.current < 0) { 203 offsetRef.current = 0; 204 } else if ( 205 offsetRef.current > 206 (layoutRef.current?.height ?? 0) - itemHeight 207 ) { 208 offsetRef.current = 209 (layoutRef.current?.height ?? 0) - itemHeight; 210 } 211 fakeItemRef.current!.setNativeProps({ 212 top: offsetRef.current, 213 opacity: 1, 214 zIndex: 100, 215 }); 216 217 // 如果超出范围,停止 218 if (offsetRef.current < itemHeight * 2) { 219 // 上滑 220 direction.value = 221 offsetRef.current / itemHeight / 2 - 1; 222 } else if ( 223 offsetRef.current > 224 (layoutRef.current?.height ?? 0) - 3 * itemHeight 225 ) { 226 // 下滑 227 direction.value = 228 (offsetRef.current - 229 (layoutRef.current?.height ?? 0) + 230 3 * itemHeight) / 231 itemHeight / 232 2; 233 } else { 234 // 不滑动 235 direction.value = 0; 236 } 237 } 238 }} 239 onTouchEnd={e => { 240 if (activeRef.current !== -1) { 241 // 计算最终的位置,触发onSortEnd 242 let index = activeRef.current; 243 if (contentOffsetYRef.current) { 244 index = Math.round( 245 (contentOffsetYRef.current + 246 offsetRef.current) / 247 itemHeight, 248 ); 249 } else { 250 // 拖动的距离 251 index = 252 activeRef.current + 253 Math.round( 254 (e.nativeEvent.pageY - 255 initDragPageY.current + 256 initDragLocationY.current) / 257 itemHeight, 258 ); 259 } 260 index = Math.min(data.length, Math.max(index, 0)); 261 // from: activeRef.current to: index 262 if (activeRef.current !== index) { 263 let nData = _data 264 .slice(0, activeRef.current) 265 .concat(_data.slice(activeRef.current + 1)); 266 nData.splice(index, 0, activeItem as T); 267 onSortEnd?.(nData); 268 // 测试用,正式时移除掉 269 // _setData(nData); 270 } 271 } 272 scrollingRef.current = false; 273 activeRef.current = -1; 274 setScrollEnabled(true); 275 setActiveItem(null); 276 fakeItemRef.current!.setNativeProps({ 277 top: 0, 278 opacity: 0, 279 zIndex: -1, 280 }); 281 }} 282 onTouchCancel={() => { 283 // todo: 滑动很快的时候会触发取消,native的flatlist就这样 284 activeRef.current = -1; 285 scrollingRef.current = false; 286 setScrollEnabled(true); 287 setActiveItem(null); 288 fakeItemRef.current!.setNativeProps({ 289 top: 0, 290 opacity: 0, 291 zIndex: -1, 292 }); 293 contentOffsetYRef.current = 0; 294 }} 295 onScroll={e => { 296 contentOffsetYRef.current = e.nativeEvent.contentOffset.y; 297 if ( 298 activeRef.current !== -1 && 299 Math.abs( 300 contentOffsetYRef.current - 301 targetOffsetYRef.current, 302 ) < 2 303 ) { 304 scrollToTarget(true); 305 } 306 }} 307 renderItem={({item, index}) => { 308 return ( 309 <SortableFlatListItem 310 setScrollEnabled={setScrollEnabled} 311 activeRef={activeRef} 312 renderItem={renderItem} 313 item={item} 314 index={index} 315 setActiveItem={setActiveItem} 316 itemHeight={itemHeight} 317 /> 318 ); 319 }} 320 /> 321 </View> 322 ); 323} 324 325interface ISortableFlatListItemProps<T extends any = any> { 326 item: T; 327 index: number; 328 // 高度 329 itemHeight: number; 330 setScrollEnabled: (scrollEnabled: boolean) => void; 331 renderItem: (props: {item: T; index: number}) => JSX.Element; 332 setActiveItem: (item: T | null) => void; 333 activeRef: React.MutableRefObject<number>; 334} 335function _SortableFlatListItem(props: ISortableFlatListItemProps) { 336 const { 337 itemHeight, 338 setScrollEnabled, 339 renderItem, 340 setActiveItem, 341 item, 342 index, 343 activeRef, 344 } = props; 345 346 // 省一点性能,height是顺着传下来的,放ref就好了 347 const styleRef = useRef( 348 StyleSheet.create({ 349 viewWrapper: { 350 height: itemHeight, 351 width: WINDOW_WIDTH, 352 flexDirection: 'row', 353 justifyContent: 'flex-end', 354 zIndex: defaultZIndex, 355 }, 356 btn: { 357 height: itemHeight, 358 paddingHorizontal: rpx(28), 359 justifyContent: 'center', 360 alignItems: 'center', 361 }, 362 }), 363 ); 364 const textColor = useTextColor(); 365 366 return ( 367 <View style={styleRef.current.viewWrapper}> 368 {renderItem({item, index})} 369 <Pressable 370 onTouchStart={() => { 371 if (activeRef.current !== -1) { 372 return; 373 } 374 /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */ 375 activeRef.current = index; 376 /** 锁定滚动 */ 377 setScrollEnabled(false); 378 setActiveItem(item); 379 }} 380 style={styleRef.current.btn}> 381 <Icon 382 name="menu" 383 size={iconSizeConst.normal} 384 color={textColor} 385 /> 386 </Pressable> 387 </View> 388 ); 389} 390 391const SortableFlatListItem = memo( 392 _SortableFlatListItem, 393 (prev, curr) => prev.index === curr.index && prev.item === curr.item, 394); 395 396const FakeFlatListItem = forwardRef(function ( 397 props: Pick< 398 ISortableFlatListItemProps, 399 'itemHeight' | 'renderItem' | 'item' 400 > & { 401 backgroundColor?: string; 402 }, 403 ref: ForwardedRef<View>, 404) { 405 const {itemHeight, renderItem, item, backgroundColor} = props; 406 407 const styleRef = useRef( 408 StyleSheet.create({ 409 viewWrapper: { 410 height: itemHeight, 411 width: WINDOW_WIDTH, 412 flexDirection: 'row', 413 justifyContent: 'flex-end', 414 zIndex: defaultZIndex, 415 }, 416 btn: { 417 height: itemHeight, 418 paddingHorizontal: rpx(28), 419 justifyContent: 'center', 420 alignItems: 'center', 421 }, 422 }), 423 ); 424 const textColor = useTextColor(); 425 426 return ( 427 <View 428 ref={ref} 429 style={[ 430 styleRef.current.viewWrapper, 431 style.activeItemDefault, 432 backgroundColor ? {backgroundColor} : {}, 433 ]}> 434 {item ? renderItem({item, index: -1}) : <></>} 435 <Pressable style={styleRef.current.btn}> 436 <Icon 437 name="menu" 438 size={iconSizeConst.normal} 439 color={textColor} 440 /> 441 </Pressable> 442 </View> 443 ); 444}); 445 446const style = StyleSheet.create({ 447 flex1: { 448 flex: 1, 449 width: WINDOW_WIDTH, 450 }, 451 activeItemDefault: { 452 opacity: 0, 453 zIndex: -1, 454 position: 'absolute', 455 top: 0, 456 left: 0, 457 }, 458}); 459