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