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