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