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 {LayoutRectangle, Pressable, StyleSheet, View} from 'react-native'; 21import { 22 runOnJS, 23 useDerivedValue, 24 useSharedValue, 25} from 'react-native-reanimated'; 26import Icon from 'react-native-vector-icons/MaterialCommunityIcons'; 27 28const defaultZIndex = 10; 29 30interface ISortableFlatListProps<T> { 31 data: T[]; 32 renderItem: (props: {item: T; index: number}) => JSX.Element; 33 // 高度 34 itemHeight: number; 35 itemJustifyContent?: 36 | 'flex-start' 37 | 'flex-end' 38 | 'center' 39 | 'space-between' 40 | 'space-around' 41 | 'space-evenly'; 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 itemJustifyContent, 58 marginTop, 59 activeBackgroundColor, 60 onSortEnd, 61 } = props; 62 63 // 不要干扰原始数据 64 const [_data, _setData] = useState([...(data ?? [])]); 65 // 是否禁止滚动 66 const [scrollEnabled, setScrollEnabled] = useState(true); 67 // 是否处在激活状态, -1表示无,其他表示当前激活的下标 68 const activeRef = useRef(-1); 69 const [activeItem, setActiveItem] = useState<T | null>(null); 70 71 const layoutRef = useRef<LayoutRectangle>(); 72 // listref 73 const listRef = useRef<FlashList<T> | null>(null); 74 // fakeref 75 const fakeItemRef = useRef<View | null>(null); 76 // contentoffset 77 const contentOffsetYRef = useRef<number>(-1); 78 const targetOffsetYRef = useRef<number>(0); 79 80 const direction = useSharedValue(0); 81 82 useEffect(() => { 83 _setData([...(data ?? [])]); 84 }, [data]); 85 86 const initDragPageY = useRef<number>(0); 87 const initDragLocationY = useRef<number>(0); 88 const offsetRef = useRef<number>(0); 89 90 //#region 滚动 91 const scrollingRef = useRef(false); 92 93 // 列表整体的高度 94 const listContentHeight = useMemo( 95 () => itemHeight * data.length, 96 [data, itemHeight], 97 ); 98 99 function scrollToTarget(forceScroll = false) { 100 // 未选中 101 if (activeRef.current === -1) { 102 scrollingRef.current = false; 103 return; 104 } 105 106 // 滚动中就不滚了 / 107 if (scrollingRef.current && !forceScroll) { 108 scrollingRef.current = true; 109 return; 110 } 111 // 方向是0 112 if (direction.value === 0) { 113 scrollingRef.current = false; 114 return; 115 } 116 117 const nextTarget = 118 Math.sign(direction.value) * 119 Math.max(Math.abs(direction.value), 0.3) * 120 300 + 121 contentOffsetYRef.current; 122 // 当前到极限了 123 if ( 124 (contentOffsetYRef.current <= 2 && 125 nextTarget < contentOffsetYRef.current) || 126 (contentOffsetYRef.current >= 127 listContentHeight - (layoutRef.current?.height ?? 0) - 2 && 128 nextTarget > contentOffsetYRef.current) 129 ) { 130 scrollingRef.current = false; 131 return; 132 } 133 scrollingRef.current = true; 134 // 超出区域 135 targetOffsetYRef.current = Math.min( 136 Math.max(0, nextTarget), 137 listContentHeight - (layoutRef.current?.height ?? 0), 138 ); 139 listRef.current?.scrollToOffset({ 140 animated: true, 141 offset: targetOffsetYRef.current, 142 }); 143 } 144 145 useDerivedValue(() => { 146 // 正在滚动 147 if (scrollingRef.current) { 148 return; 149 } else if (direction.value !== 0) { 150 // 开始滚动 151 runOnJS(scrollToTarget)(); 152 } 153 }, []); 154 155 //#endregion 156 157 return ( 158 <View style={style.flex1}> 159 {/* 纯展示 */} 160 <FakeFlatListItem 161 ref={_ => (fakeItemRef.current = _)} 162 backgroundColor={activeBackgroundColor} 163 renderItem={renderItem} 164 itemHeight={itemHeight} 165 item={activeItem} 166 itemJustifyContent={itemJustifyContent} 167 /> 168 <FlashList 169 scrollEnabled={scrollEnabled} 170 ref={_ => { 171 listRef.current = _; 172 }} 173 onLayout={evt => { 174 layoutRef.current = evt.nativeEvent.layout; 175 }} 176 data={_data} 177 estimatedItemSize={itemHeight} 178 scrollEventThrottle={16} 179 onTouchStart={e => { 180 if (activeRef.current !== -1) { 181 // 相对于整个页面顶部的距离 182 initDragPageY.current = e.nativeEvent.pageY; 183 initDragLocationY.current = e.nativeEvent.locationY; 184 } 185 }} 186 onTouchMove={e => { 187 if (activeRef.current !== -1) { 188 offsetRef.current = 189 e.nativeEvent.pageY - 190 (marginTop ?? layoutRef.current?.y ?? 0) - 191 itemHeight / 2; 192 193 if (offsetRef.current < 0) { 194 offsetRef.current = 0; 195 } else if ( 196 offsetRef.current > 197 (layoutRef.current?.height ?? 0) - itemHeight 198 ) { 199 offsetRef.current = 200 (layoutRef.current?.height ?? 0) - itemHeight; 201 } 202 fakeItemRef.current!.setNativeProps({ 203 top: offsetRef.current, 204 opacity: 1, 205 zIndex: 100, 206 }); 207 208 // 如果超出范围,停止 209 if (offsetRef.current < itemHeight * 2) { 210 // 上滑 211 direction.value = 212 offsetRef.current / itemHeight / 2 - 1; 213 } else if ( 214 offsetRef.current > 215 (layoutRef.current?.height ?? 0) - 3 * itemHeight 216 ) { 217 // 下滑 218 direction.value = 219 (offsetRef.current - 220 (layoutRef.current?.height ?? 0) + 221 3 * itemHeight) / 222 itemHeight / 223 2; 224 } else { 225 // 不滑动 226 direction.value = 0; 227 } 228 } 229 }} 230 onTouchEnd={e => { 231 if (activeRef.current !== -1) { 232 // 计算最终的位置,触发onSortEnd 233 let index = activeRef.current; 234 if (contentOffsetYRef.current !== -1) { 235 index = Math.round( 236 (contentOffsetYRef.current + 237 offsetRef.current) / 238 itemHeight, 239 ); 240 } else { 241 // 拖动的距离 242 index = 243 activeRef.current + 244 Math.round( 245 (e.nativeEvent.pageY - 246 initDragPageY.current + 247 initDragLocationY.current) / 248 itemHeight, 249 ); 250 } 251 index = Math.min(data.length, Math.max(index, 0)); 252 // from: activeRef.current to: index 253 if (activeRef.current !== index) { 254 let nData = _data 255 .slice(0, activeRef.current) 256 .concat(_data.slice(activeRef.current + 1)); 257 nData.splice(index, 0, activeItem as T); 258 onSortEnd?.(nData); 259 // 测试用,正式时移除掉 260 // _setData(nData); 261 } 262 } 263 scrollingRef.current = false; 264 activeRef.current = -1; 265 setScrollEnabled(true); 266 setActiveItem(null); 267 fakeItemRef.current!.setNativeProps({ 268 top: 0, 269 opacity: 0, 270 zIndex: -1, 271 }); 272 }} 273 onTouchCancel={() => { 274 // todo: 滑动很快的时候会触发取消,native的flatlist就这样 275 activeRef.current = -1; 276 scrollingRef.current = false; 277 setScrollEnabled(true); 278 setActiveItem(null); 279 fakeItemRef.current!.setNativeProps({ 280 top: 0, 281 opacity: 0, 282 zIndex: -1, 283 }); 284 contentOffsetYRef.current = -1; 285 }} 286 onScroll={e => { 287 contentOffsetYRef.current = e.nativeEvent.contentOffset.y; 288 if ( 289 activeRef.current !== -1 && 290 Math.abs( 291 contentOffsetYRef.current - 292 targetOffsetYRef.current, 293 ) < 2 294 ) { 295 scrollToTarget(true); 296 } 297 }} 298 renderItem={({item, index}) => { 299 return ( 300 <SortableFlatListItem 301 setScrollEnabled={setScrollEnabled} 302 activeRef={activeRef} 303 renderItem={renderItem} 304 item={item} 305 index={index} 306 setActiveItem={setActiveItem} 307 itemJustifyContent={itemJustifyContent} 308 itemHeight={itemHeight} 309 /> 310 ); 311 }} 312 /> 313 </View> 314 ); 315} 316 317interface ISortableFlatListItemProps<T extends any = any> { 318 item: T; 319 index: number; 320 // 高度 321 itemHeight: number; 322 itemJustifyContent?: 323 | 'flex-start' 324 | 'flex-end' 325 | 'center' 326 | 'space-between' 327 | 'space-around' 328 | 'space-evenly'; 329 setScrollEnabled: (scrollEnabled: boolean) => void; 330 renderItem: (props: {item: T; index: number}) => JSX.Element; 331 setActiveItem: (item: T | null) => void; 332 activeRef: React.MutableRefObject<number>; 333} 334function _SortableFlatListItem(props: ISortableFlatListItemProps) { 335 const { 336 itemHeight, 337 setScrollEnabled, 338 renderItem, 339 setActiveItem, 340 itemJustifyContent, 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: '100%', 352 flexDirection: 'row', 353 justifyContent: itemJustifyContent ?? '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' | 'itemJustifyContent' 400 > & { 401 backgroundColor?: string; 402 }, 403 ref: ForwardedRef<View>, 404) { 405 const {itemHeight, renderItem, item, backgroundColor, itemJustifyContent} = 406 props; 407 408 const styleRef = useRef( 409 StyleSheet.create({ 410 viewWrapper: { 411 height: itemHeight, 412 width: '100%', 413 flexDirection: 'row', 414 justifyContent: itemJustifyContent ?? 'flex-end', 415 zIndex: defaultZIndex, 416 }, 417 btn: { 418 height: itemHeight, 419 paddingHorizontal: rpx(28), 420 justifyContent: 'center', 421 alignItems: 'center', 422 }, 423 }), 424 ); 425 const textColor = useTextColor(); 426 427 return ( 428 <View 429 ref={ref} 430 style={[ 431 styleRef.current.viewWrapper, 432 style.activeItemDefault, 433 backgroundColor ? {backgroundColor} : {}, 434 ]}> 435 {item ? renderItem({item, index: -1}) : null} 436 <Pressable style={styleRef.current.btn}> 437 <Icon 438 name="menu" 439 size={iconSizeConst.normal} 440 color={textColor} 441 /> 442 </Pressable> 443 </View> 444 ); 445}); 446 447const style = StyleSheet.create({ 448 flex1: { 449 flex: 1, 450 width: '100%', 451 }, 452 activeItemDefault: { 453 opacity: 0, 454 zIndex: -1, 455 position: 'absolute', 456 top: 0, 457 left: 0, 458 }, 459}); 460