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 activeRef.current = -1; 270 setScrollEnabled(true); 271 setActiveItem(null); 272 fakeItemRef.current!.setNativeProps({ 273 top: 0, 274 opacity: 0, 275 zIndex: -1, 276 }); 277 contentOffsetYRef.current = 0; 278 }} 279 onScroll={e => { 280 contentOffsetYRef.current = e.nativeEvent.contentOffset.y; 281 }} 282 renderItem={({item, index}) => { 283 return ( 284 <SortableFlatListItem 285 setScrollEnabled={setScrollEnabled} 286 activeRef={activeRef} 287 renderItem={renderItem} 288 item={item} 289 index={index} 290 setActiveItem={setActiveItem} 291 itemHeight={itemHeight} 292 /> 293 ); 294 }} 295 /> 296 </View> 297 ); 298} 299 300interface ISortableFlatListItemProps<T extends any = any> { 301 item: T; 302 index: number; 303 // 高度 304 itemHeight: number; 305 setScrollEnabled: (scrollEnabled: boolean) => void; 306 renderItem: (props: {item: T; index: number}) => JSX.Element; 307 setActiveItem: (item: T | null) => void; 308 activeRef: React.MutableRefObject<number>; 309} 310function _SortableFlatListItem(props: ISortableFlatListItemProps) { 311 const { 312 itemHeight, 313 setScrollEnabled, 314 renderItem, 315 setActiveItem, 316 item, 317 index, 318 activeRef, 319 } = props; 320 321 // 省一点性能,height是顺着传下来的,放ref就好了 322 const styleRef = useRef( 323 StyleSheet.create({ 324 viewWrapper: { 325 height: itemHeight, 326 width: WINDOW_WIDTH, 327 flexDirection: 'row', 328 justifyContent: 'flex-end', 329 zIndex: defaultZIndex, 330 }, 331 btn: { 332 height: itemHeight, 333 paddingHorizontal: rpx(28), 334 justifyContent: 'center', 335 alignItems: 'center', 336 }, 337 }), 338 ); 339 const textColor = useTextColor(); 340 341 return ( 342 <View style={styleRef.current.viewWrapper}> 343 {renderItem({item, index})} 344 <Pressable 345 onTouchStart={() => { 346 if (activeRef.current !== -1) { 347 return; 348 } 349 /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */ 350 activeRef.current = index; 351 /** 锁定滚动 */ 352 setScrollEnabled(false); 353 setActiveItem(item); 354 }} 355 style={styleRef.current.btn}> 356 <Icon 357 name="menu" 358 size={iconSizeConst.normal} 359 color={textColor} 360 /> 361 </Pressable> 362 </View> 363 ); 364} 365 366const SortableFlatListItem = memo( 367 _SortableFlatListItem, 368 (prev, curr) => prev.index === curr.index && prev.item === curr.item, 369); 370 371const FakeFlatListItem = forwardRef(function ( 372 props: Pick< 373 ISortableFlatListItemProps, 374 'itemHeight' | 'renderItem' | 'item' 375 > & { 376 backgroundColor?: string; 377 }, 378 ref: ForwardedRef<View>, 379) { 380 const {itemHeight, renderItem, item, backgroundColor} = props; 381 382 const styleRef = useRef( 383 StyleSheet.create({ 384 viewWrapper: { 385 height: itemHeight, 386 width: WINDOW_WIDTH, 387 flexDirection: 'row', 388 justifyContent: 'flex-end', 389 zIndex: defaultZIndex, 390 }, 391 btn: { 392 height: itemHeight, 393 paddingHorizontal: rpx(28), 394 justifyContent: 'center', 395 alignItems: 'center', 396 }, 397 }), 398 ); 399 const textColor = useTextColor(); 400 401 return ( 402 <View 403 ref={ref} 404 style={[ 405 styleRef.current.viewWrapper, 406 style.activeItemDefault, 407 backgroundColor ? {backgroundColor} : {}, 408 ]}> 409 {item ? renderItem({item, index: -1}) : <></>} 410 <Pressable style={styleRef.current.btn}> 411 <Icon 412 name="menu" 413 size={iconSizeConst.normal} 414 color={textColor} 415 /> 416 </Pressable> 417 </View> 418 ); 419}); 420 421const style = StyleSheet.create({ 422 flex1: { 423 flex: 1, 424 width: WINDOW_WIDTH, 425 }, 426 activeItemDefault: { 427 opacity: 0, 428 zIndex: -1, 429 position: 'absolute', 430 top: 0, 431 left: 0, 432 }, 433}); 434