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