1/** 2 * 支持长按拖拽排序的flatlist,右边加个固定的按钮,拖拽排序。 3 * 考虑到方便实现+节省性能,整个app内的拖拽排序都遵守以下实现。 4 * 点击会出现 5 */ 6 7import globalStyle from '@/constants/globalStyle'; 8import {iconSizeConst} from '@/constants/uiConst'; 9import useTextColor from '@/hooks/useTextColor'; 10import rpx from '@/utils/rpx'; 11import {FlashList} from '@shopify/flash-list'; 12import React, { 13 ForwardedRef, 14 forwardRef, 15 memo, 16 useEffect, 17 useMemo, 18 useRef, 19 useState, 20} from 'react'; 21import {LayoutRectangle, Pressable, StyleSheet, View} from 'react-native'; 22import { 23 runOnJS, 24 useDerivedValue, 25 useSharedValue, 26} from 'react-native-reanimated'; 27import Icon from '@/components/base/icon.tsx'; 28 29const defaultZIndex = 10; 30 31interface ISortableFlatListProps<T> { 32 data: T[]; 33 renderItem: (props: {item: T; index: number}) => JSX.Element; 34 // 高度 35 itemHeight: number; 36 itemJustifyContent?: 37 | 'flex-start' 38 | 'flex-end' 39 | 'center' 40 | 'space-between' 41 | 'space-around' 42 | 'space-evenly'; 43 // 滚动list距离顶部的距离, 这里写的不好 44 marginTop: number; 45 /** 拖拽时的背景色 */ 46 activeBackgroundColor?: string; 47 /** 交换结束 */ 48 onSortEnd?: (newData: T[]) => void; 49} 50 51export default function SortableFlatList<T extends any = any>( 52 props: ISortableFlatListProps<T>, 53) { 54 const { 55 data, 56 renderItem, 57 itemHeight, 58 itemJustifyContent, 59 marginTop, 60 activeBackgroundColor, 61 onSortEnd, 62 } = props; 63 64 // 不要干扰原始数据 65 const [_data, _setData] = useState([...(data ?? [])]); 66 // 是否禁止滚动 67 const [scrollEnabled, setScrollEnabled] = useState(true); 68 // 是否处在激活状态, -1表示无,其他表示当前激活的下标 69 const activeRef = useRef(-1); 70 const [activeItem, setActiveItem] = useState<T | null>(null); 71 72 const layoutRef = useRef<LayoutRectangle>(); 73 // listref 74 const listRef = useRef<FlashList<T> | null>(null); 75 // fakeref 76 const fakeItemRef = useRef<View | null>(null); 77 // contentoffset 78 const contentOffsetYRef = useRef<number>(-1); 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 listContentHeight = useMemo( 96 () => itemHeight * data.length, 97 [data, itemHeight], 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 listContentHeight - (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 listContentHeight - (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 return ( 159 <View style={globalStyle.fwflex1}> 160 {/* 纯展示 */} 161 <FakeFlatListItem 162 ref={_ => (fakeItemRef.current = _)} 163 backgroundColor={activeBackgroundColor} 164 renderItem={renderItem} 165 itemHeight={itemHeight} 166 item={activeItem} 167 itemJustifyContent={itemJustifyContent} 168 /> 169 <FlashList 170 scrollEnabled={scrollEnabled} 171 ref={_ => { 172 listRef.current = _; 173 }} 174 onLayout={evt => { 175 layoutRef.current = evt.nativeEvent.layout; 176 }} 177 data={_data} 178 estimatedItemSize={itemHeight} 179 scrollEventThrottle={16} 180 onTouchStart={e => { 181 if (activeRef.current !== -1) { 182 // 相对于整个页面顶部的距离 183 initDragPageY.current = e.nativeEvent.pageY; 184 initDragLocationY.current = e.nativeEvent.locationY; 185 } 186 }} 187 onTouchMove={e => { 188 if (activeRef.current !== -1) { 189 offsetRef.current = 190 e.nativeEvent.pageY - 191 (marginTop ?? layoutRef.current?.y ?? 0) - 192 itemHeight / 2; 193 194 if (offsetRef.current < 0) { 195 offsetRef.current = 0; 196 } else if ( 197 offsetRef.current > 198 (layoutRef.current?.height ?? 0) - itemHeight 199 ) { 200 offsetRef.current = 201 (layoutRef.current?.height ?? 0) - itemHeight; 202 } 203 fakeItemRef.current!.setNativeProps({ 204 top: offsetRef.current, 205 opacity: 1, 206 zIndex: 100, 207 }); 208 209 // 如果超出范围,停止 210 if (offsetRef.current < itemHeight * 2) { 211 // 上滑 212 direction.value = 213 offsetRef.current / itemHeight / 2 - 1; 214 } else if ( 215 offsetRef.current > 216 (layoutRef.current?.height ?? 0) - 3 * itemHeight 217 ) { 218 // 下滑 219 direction.value = 220 (offsetRef.current - 221 (layoutRef.current?.height ?? 0) + 222 3 * itemHeight) / 223 itemHeight / 224 2; 225 } else { 226 // 不滑动 227 direction.value = 0; 228 } 229 } 230 }} 231 onTouchEnd={e => { 232 if (activeRef.current !== -1) { 233 // 计算最终的位置,触发onSortEnd 234 let index = activeRef.current; 235 if (contentOffsetYRef.current !== -1) { 236 index = Math.round( 237 (contentOffsetYRef.current + 238 offsetRef.current) / 239 itemHeight, 240 ); 241 } else { 242 // 拖动的距离 243 index = 244 activeRef.current + 245 Math.round( 246 (e.nativeEvent.pageY - 247 initDragPageY.current + 248 initDragLocationY.current) / 249 itemHeight, 250 ); 251 } 252 index = Math.min(data.length, Math.max(index, 0)); 253 // from: activeRef.current to: index 254 if (activeRef.current !== index) { 255 let nData = _data 256 .slice(0, activeRef.current) 257 .concat(_data.slice(activeRef.current + 1)); 258 nData.splice(index, 0, activeItem as T); 259 onSortEnd?.(nData); 260 // 测试用,正式时移除掉 261 // _setData(nData); 262 } 263 } 264 scrollingRef.current = false; 265 activeRef.current = -1; 266 setScrollEnabled(true); 267 setActiveItem(null); 268 fakeItemRef.current!.setNativeProps({ 269 top: 0, 270 opacity: 0, 271 zIndex: -1, 272 }); 273 }} 274 onTouchCancel={() => { 275 // todo: 滑动很快的时候会触发取消,native的flatlist就这样 276 activeRef.current = -1; 277 scrollingRef.current = false; 278 setScrollEnabled(true); 279 setActiveItem(null); 280 fakeItemRef.current!.setNativeProps({ 281 top: 0, 282 opacity: 0, 283 zIndex: -1, 284 }); 285 contentOffsetYRef.current = -1; 286 }} 287 onScroll={e => { 288 contentOffsetYRef.current = e.nativeEvent.contentOffset.y; 289 if ( 290 activeRef.current !== -1 && 291 Math.abs( 292 contentOffsetYRef.current - 293 targetOffsetYRef.current, 294 ) < 2 295 ) { 296 scrollToTarget(true); 297 } 298 }} 299 renderItem={({item, index}) => { 300 return ( 301 <SortableFlatListItem 302 setScrollEnabled={setScrollEnabled} 303 activeRef={activeRef} 304 renderItem={renderItem} 305 item={item} 306 index={index} 307 setActiveItem={setActiveItem} 308 itemJustifyContent={itemJustifyContent} 309 itemHeight={itemHeight} 310 /> 311 ); 312 }} 313 /> 314 </View> 315 ); 316} 317 318interface ISortableFlatListItemProps<T extends any = any> { 319 item: T; 320 index: number; 321 // 高度 322 itemHeight: number; 323 itemJustifyContent?: 324 | 'flex-start' 325 | 'flex-end' 326 | 'center' 327 | 'space-between' 328 | 'space-around' 329 | 'space-evenly'; 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 itemJustifyContent, 342 item, 343 index, 344 activeRef, 345 } = props; 346 347 // 省一点性能,height是顺着传下来的,放ref就好了 348 const styleRef = useRef( 349 StyleSheet.create({ 350 viewWrapper: { 351 height: itemHeight, 352 width: '100%', 353 flexDirection: 'row', 354 justifyContent: itemJustifyContent ?? 'flex-end', 355 zIndex: defaultZIndex, 356 }, 357 btn: { 358 height: itemHeight, 359 justifyContent: 'center', 360 alignItems: 'center', 361 position: 'absolute', 362 top: 0, 363 right: 0, 364 width: rpx(100), 365 textAlignVertical: 'center', 366 }, 367 }), 368 ); 369 const textColor = useTextColor(); 370 371 return ( 372 <View style={styleRef.current.viewWrapper}> 373 {renderItem({item, index})} 374 <Pressable 375 onTouchStart={() => { 376 if (activeRef.current !== -1) { 377 return; 378 } 379 /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */ 380 activeRef.current = index; 381 /** 锁定滚动 */ 382 setScrollEnabled(false); 383 setActiveItem(item); 384 }} 385 style={styleRef.current.btn}> 386 <Icon 387 name="bars-3" 388 size={iconSizeConst.normal} 389 color={textColor} 390 /> 391 </Pressable> 392 </View> 393 ); 394} 395 396const SortableFlatListItem = memo( 397 _SortableFlatListItem, 398 (prev, curr) => prev.index === curr.index && prev.item === curr.item, 399); 400 401const FakeFlatListItem = forwardRef(function ( 402 props: Pick< 403 ISortableFlatListItemProps, 404 'itemHeight' | 'renderItem' | 'item' | 'itemJustifyContent' 405 > & { 406 backgroundColor?: string; 407 }, 408 ref: ForwardedRef<View>, 409) { 410 const {itemHeight, renderItem, item, backgroundColor, itemJustifyContent} = 411 props; 412 413 const styleRef = useRef( 414 StyleSheet.create({ 415 viewWrapper: { 416 height: itemHeight, 417 width: '100%', 418 flexDirection: 'row', 419 justifyContent: itemJustifyContent ?? 'flex-end', 420 zIndex: defaultZIndex, 421 }, 422 btn: { 423 height: itemHeight, 424 paddingHorizontal: rpx(28), 425 justifyContent: 'center', 426 alignItems: 'center', 427 position: 'absolute', 428 top: 0, 429 right: 0, 430 width: rpx(100), 431 textAlignVertical: '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="bars-3" 449 size={iconSizeConst.normal} 450 color={textColor} 451 /> 452 </Pressable> 453 </View> 454 ); 455}); 456 457const style = StyleSheet.create({ 458 activeItemDefault: { 459 opacity: 0, 460 zIndex: -1, 461 position: 'absolute', 462 top: 0, 463 left: 0, 464 }, 465}); 466