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 useCallback, 15 useEffect, 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 Icon from 'react-native-vector-icons/MaterialCommunityIcons'; 28 29const WINDOW_WIDTH = rpx(750); 30const defaultZIndex = 10; 31 32interface ISortableFlatListProps<T> { 33 data: T[]; 34 renderItem: (props: {item: T; index: number}) => JSX.Element; 35 // 高度 36 itemHeight: number; 37 // 滚动list距离顶部的距离, 这里写的不好 38 marginTop: number; 39 /** 拖拽时的背景色 */ 40 activeBackgroundColor?: string; 41 /** 交换结束 */ 42 onSortEnd?: (newData: T[]) => void; 43} 44 45export default function SortableFlatList<T extends any = any>( 46 props: ISortableFlatListProps<T>, 47) { 48 const { 49 data, 50 renderItem, 51 itemHeight, 52 marginTop, 53 activeBackgroundColor, 54 onSortEnd, 55 } = props; 56 57 // 不要干扰原始数据 58 const [_data, _setData] = useState([...(data ?? [])]); 59 // 是否禁止滚动 60 const [scrollEnabled, setScrollEnabled] = useState(true); 61 // 是否处在激活状态, -1表示无,其他表示当前激活的下标 62 const activeRef = useRef(-1); 63 const [activeItem, setActiveItem] = useState<T | null>(null); 64 65 const viewableItemsRef = useRef<ViewToken[] | null>(null); 66 67 const layoutRef = useRef<LayoutRectangle>(); 68 // listref 69 const listRef = useRef<FlatList | null>(null); 70 // fakeref 71 const fakeItemRef = useRef<View | null>(null); 72 // contentoffset 73 const contentOffsetYRef = useRef<number>(0); 74 75 useEffect(() => { 76 _setData([...(data ?? [])]); 77 }, [data]); 78 79 const initDragPageY = useRef<number>(0); 80 const initDragLocationY = useRef<number>(0); 81 const offsetRef = useRef<number>(0); 82 83 //#region 滚动 84 const scrollControllerRef = useRef<{ 85 timer?: any; 86 direction: number; 87 }>({direction: 0}); 88 89 function scrollInterval() { 90 if (scrollControllerRef.current.timer) { 91 return; 92 } 93 scrollControllerRef.current.timer = setInterval(() => { 94 const scrollController = scrollControllerRef.current; 95 if (scrollController.direction === 0) { 96 clearScrollInterval(); 97 } else if (scrollController.direction <= 0) { 98 listRef.current?.scrollToIndex?.({ 99 index: viewableItemsRef.current 100 ? (viewableItemsRef.current[0]?.index ?? 2) - 2 101 : 0, 102 animated: true, 103 viewOffset: 0, 104 }); 105 if (viewableItemsRef.current?.[0]?.index === 0) { 106 clearScrollInterval(); 107 } 108 } else { 109 // todo: 改成offset 110 listRef.current?.scrollToIndex?.({ 111 index: viewableItemsRef.current 112 ? (viewableItemsRef.current[ 113 viewableItemsRef.current.length - 1 114 ]?.index ?? data.length - 3) + 2 115 : 0, 116 animated: true, 117 viewOffset: 1, 118 }); 119 if ( 120 viewableItemsRef.current?.[ 121 viewableItemsRef?.current.length - 1 122 ]?.index === 123 data.length - 1 124 ) { 125 clearScrollInterval(); 126 } 127 } 128 }, 250); 129 } 130 131 const clearScrollInterval = useCallback(() => { 132 scrollControllerRef.current.timer && 133 clearInterval(scrollControllerRef.current.timer); 134 scrollControllerRef.current.timer = null; 135 }, []); 136 137 useEffect(() => { 138 return () => { 139 clearScrollInterval(); 140 }; 141 }, []); 142 143 //#endregion 144 145 const onViewRef = useRef((vi: any) => { 146 viewableItemsRef.current = vi.viewableItems; 147 }); 148 return ( 149 <View style={style.flex1}> 150 {/* 纯展示 */} 151 <FakeFlatListItem 152 ref={_ => (fakeItemRef.current = _)} 153 backgroundColor={activeBackgroundColor} 154 renderItem={renderItem} 155 itemHeight={itemHeight} 156 item={activeItem} 157 /> 158 <FlatList 159 scrollEnabled={scrollEnabled} 160 onViewableItemsChanged={onViewRef.current} 161 style={style.flex1} 162 ref={_ => { 163 listRef.current = _; 164 }} 165 onLayout={evt => { 166 layoutRef.current = evt.nativeEvent.layout; 167 }} 168 data={_data} 169 getItemLayout={(_, index) => ({ 170 length: itemHeight, 171 offset: itemHeight * index, 172 index, 173 })} 174 onTouchStart={e => { 175 if (activeRef.current !== -1) { 176 // 相对于整个页面顶部的距离 177 initDragPageY.current = e.nativeEvent.pageY; 178 initDragLocationY.current = e.nativeEvent.locationY; 179 } 180 }} 181 onTouchMove={e => { 182 if (activeRef.current !== -1) { 183 offsetRef.current = 184 e.nativeEvent.pageY - 185 (marginTop ?? layoutRef.current?.y ?? 0) - 186 itemHeight / 2; 187 188 if (offsetRef.current < 0) { 189 offsetRef.current = 0; 190 } else if ( 191 offsetRef.current > 192 (layoutRef.current?.height ?? 0) - itemHeight 193 ) { 194 offsetRef.current = 195 (layoutRef.current?.height ?? 0) - itemHeight; 196 } 197 198 fakeItemRef.current!.setNativeProps({ 199 top: offsetRef.current, 200 opacity: 1, 201 zIndex: 100, 202 }); 203 204 // 如果超出范围,停止 205 if (offsetRef.current < itemHeight * 2) { 206 // 上滑 207 if (viewableItemsRef.current) { 208 } 209 scrollControllerRef.current.direction = -1; 210 scrollInterval(); 211 } else if ( 212 offsetRef.current > 213 (layoutRef.current?.height ?? 0) - 3 * itemHeight 214 ) { 215 // 下滑 216 scrollControllerRef.current.direction = 1; 217 scrollInterval(); 218 } else { 219 // 取消timer 220 clearScrollInterval(); 221 } 222 } 223 }} 224 onTouchEnd={e => { 225 clearScrollInterval(); 226 if (activeRef.current !== -1) { 227 // 计算最终的位置,触发onSortEnd 228 let index = activeRef.current; 229 if (contentOffsetYRef.current) { 230 index = Math.round( 231 (contentOffsetYRef.current + 232 offsetRef.current) / 233 itemHeight, 234 ); 235 } else { 236 // 拖动的距离 237 index = 238 activeRef.current + 239 Math.round( 240 (e.nativeEvent.pageY - 241 initDragPageY.current + 242 initDragLocationY.current) / 243 itemHeight, 244 ); 245 } 246 index = Math.min(data.length, Math.max(index, 0)); 247 // from: activeRef.current to: index 248 if (activeRef.current !== index) { 249 let nData = _data 250 .slice(0, activeRef.current) 251 .concat(_data.slice(activeRef.current + 1)); 252 nData.splice(index, 0, activeItem as T); 253 onSortEnd?.(nData); 254 // 测试用,正式时移除掉 255 // _setData(nData); 256 } 257 } 258 activeRef.current = -1; 259 setScrollEnabled(true); 260 setActiveItem(null); 261 fakeItemRef.current!.setNativeProps({ 262 top: 0, 263 opacity: 0, 264 zIndex: -1, 265 }); 266 contentOffsetYRef.current = 0; 267 }} 268 onTouchCancel={() => { 269 // todo: 滑动很快的时候会触发取消,native的flatlist就这样 270 activeRef.current = -1; 271 setScrollEnabled(true); 272 setActiveItem(null); 273 fakeItemRef.current!.setNativeProps({ 274 top: 0, 275 opacity: 0, 276 zIndex: -1, 277 }); 278 contentOffsetYRef.current = 0; 279 }} 280 onScroll={e => { 281 contentOffsetYRef.current = e.nativeEvent.contentOffset.y; 282 }} 283 renderItem={({item, index}) => { 284 return ( 285 <SortableFlatListItem 286 setScrollEnabled={setScrollEnabled} 287 activeRef={activeRef} 288 renderItem={renderItem} 289 item={item} 290 index={index} 291 setActiveItem={setActiveItem} 292 itemHeight={itemHeight} 293 /> 294 ); 295 }} 296 /> 297 </View> 298 ); 299} 300 301interface ISortableFlatListItemProps<T extends any = any> { 302 item: T; 303 index: number; 304 // 高度 305 itemHeight: number; 306 setScrollEnabled: (scrollEnabled: boolean) => void; 307 renderItem: (props: {item: T; index: number}) => JSX.Element; 308 setActiveItem: (item: T | null) => void; 309 activeRef: React.MutableRefObject<number>; 310} 311function _SortableFlatListItem(props: ISortableFlatListItemProps) { 312 const { 313 itemHeight, 314 setScrollEnabled, 315 renderItem, 316 setActiveItem, 317 item, 318 index, 319 activeRef, 320 } = props; 321 322 // 省一点性能,height是顺着传下来的,放ref就好了 323 const styleRef = useRef( 324 StyleSheet.create({ 325 viewWrapper: { 326 height: itemHeight, 327 width: WINDOW_WIDTH, 328 flexDirection: 'row', 329 justifyContent: 'flex-end', 330 zIndex: defaultZIndex, 331 }, 332 btn: { 333 height: itemHeight, 334 paddingHorizontal: rpx(28), 335 justifyContent: 'center', 336 alignItems: 'center', 337 }, 338 }), 339 ); 340 const textColor = useTextColor(); 341 342 return ( 343 <View style={styleRef.current.viewWrapper}> 344 {renderItem({item, index})} 345 <Pressable 346 onTouchStart={() => { 347 if (activeRef.current !== -1) { 348 return; 349 } 350 /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */ 351 activeRef.current = index; 352 /** 锁定滚动 */ 353 setScrollEnabled(false); 354 setActiveItem(item); 355 }} 356 style={styleRef.current.btn}> 357 <Icon 358 name="menu" 359 size={iconSizeConst.normal} 360 color={textColor} 361 /> 362 </Pressable> 363 </View> 364 ); 365} 366 367const SortableFlatListItem = memo( 368 _SortableFlatListItem, 369 (prev, curr) => prev.index === curr.index && prev.item === curr.item, 370); 371 372const FakeFlatListItem = forwardRef(function ( 373 props: Pick< 374 ISortableFlatListItemProps, 375 'itemHeight' | 'renderItem' | 'item' 376 > & { 377 backgroundColor?: string; 378 }, 379 ref: ForwardedRef<View>, 380) { 381 const {itemHeight, renderItem, item, backgroundColor} = props; 382 383 const styleRef = useRef( 384 StyleSheet.create({ 385 viewWrapper: { 386 height: itemHeight, 387 width: WINDOW_WIDTH, 388 flexDirection: 'row', 389 justifyContent: 'flex-end', 390 zIndex: defaultZIndex, 391 }, 392 btn: { 393 height: itemHeight, 394 paddingHorizontal: rpx(28), 395 justifyContent: 'center', 396 alignItems: 'center', 397 }, 398 }), 399 ); 400 const textColor = useTextColor(); 401 402 return ( 403 <View 404 ref={ref} 405 style={[ 406 styleRef.current.viewWrapper, 407 style.activeItemDefault, 408 backgroundColor ? {backgroundColor} : {}, 409 ]}> 410 {item ? renderItem({item, index: -1}) : <></>} 411 <Pressable style={styleRef.current.btn}> 412 <Icon 413 name="menu" 414 size={iconSizeConst.normal} 415 color={textColor} 416 /> 417 </Pressable> 418 </View> 419 ); 420}); 421 422const style = StyleSheet.create({ 423 flex1: { 424 flex: 1, 425 width: WINDOW_WIDTH, 426 }, 427 activeItemDefault: { 428 opacity: 0, 429 zIndex: -1, 430 position: 'absolute', 431 top: 0, 432 left: 0, 433 }, 434}); 435