xref: /MusicFree/src/components/base/SortableFlatList.tsx (revision 99b6e7ce9876e7996ae36d9d80c25cd58691636f)
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