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