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