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