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