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