xref: /MusicFree/src/components/base/SortableFlatList.tsx (revision ef40425a988669cb6767352c9e77173d448ed85c)
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    useEffect,
15    useMemo,
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 {useDerivedValue, useSharedValue} from 'react-native-reanimated';
28import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
29
30const WINDOW_WIDTH = rpx(750);
31const defaultZIndex = 10;
32
33interface ISortableFlatListProps<T> {
34    data: T[];
35    renderItem: (props: {item: T; index: number}) => JSX.Element;
36    // 高度
37    itemHeight: number;
38    // 滚动list距离顶部的距离, 这里写的不好
39    marginTop: number;
40    /** 拖拽时的背景色 */
41    activeBackgroundColor?: string;
42    /** 交换结束 */
43    onSortEnd?: (newData: T[]) => void;
44}
45
46export default function SortableFlatList<T extends any = any>(
47    props: ISortableFlatListProps<T>,
48) {
49    const {
50        data,
51        renderItem,
52        itemHeight,
53        marginTop,
54        activeBackgroundColor,
55        onSortEnd,
56    } = props;
57
58    // 不要干扰原始数据
59    const [_data, _setData] = useState([...(data ?? [])]);
60    // 是否禁止滚动
61    const [scrollEnabled, setScrollEnabled] = useState(true);
62    // 是否处在激活状态, -1表示无,其他表示当前激活的下标
63    const activeRef = useRef(-1);
64    const [activeItem, setActiveItem] = useState<T | null>(null);
65
66    const viewableItemsRef = useRef<ViewToken[] | null>(null);
67
68    const layoutRef = useRef<LayoutRectangle>();
69    // listref
70    const listRef = useRef<FlatList<T> | null>(null);
71    // fakeref
72    const fakeItemRef = useRef<View | null>(null);
73    // contentoffset
74    const contentOffsetYRef = useRef<number>(0);
75    const targetOffsetYRef = useRef<number>(0);
76
77    const direction = useSharedValue(0);
78
79    useEffect(() => {
80        _setData([...(data ?? [])]);
81    }, [data]);
82
83    const initDragPageY = useRef<number>(0);
84    const initDragLocationY = useRef<number>(0);
85    const offsetRef = useRef<number>(0);
86
87    //#region 滚动
88    const scrollingRef = useRef(false);
89
90    // 列表整体的高度
91    const getListContentHeight = useMemo(
92        () => () => itemHeight * data.length,
93        [data],
94    );
95
96    function scrollToTarget(forceScroll = false) {
97        // 未选中
98        if (activeRef.current === -1) {
99            scrollingRef.current = false;
100            return;
101        }
102
103        // 滚动中就不滚了 /
104        if (scrollingRef.current && !forceScroll) {
105            scrollingRef.current = true;
106            return;
107        }
108        // 方向是0
109        if (direction.value === 0) {
110            scrollingRef.current = false;
111            return;
112        }
113
114        const nextTarget =
115            Math.sign(direction.value) *
116                Math.max(Math.abs(direction.value), 0.3) *
117                300 +
118            contentOffsetYRef.current;
119        // 当前到极限了
120        if (
121            (contentOffsetYRef.current <= 2 &&
122                nextTarget < contentOffsetYRef.current) ||
123            (contentOffsetYRef.current >=
124                getListContentHeight() - (layoutRef.current?.height ?? 0) - 2 &&
125                nextTarget > contentOffsetYRef.current)
126        ) {
127            scrollingRef.current = false;
128            return;
129        }
130        scrollingRef.current = true;
131        // 超出区域
132        targetOffsetYRef.current = Math.min(
133            Math.max(0, nextTarget),
134            getListContentHeight() - (layoutRef.current?.height ?? 0),
135        );
136        listRef.current?.scrollToOffset({
137            animated: true,
138            offset: targetOffsetYRef.current,
139        });
140    }
141
142    useDerivedValue(() => {
143        // 正在滚动
144        if (scrollingRef.current) {
145            return;
146        } else if (direction.value !== 0) {
147            // 开始滚动
148            scrollToTarget();
149        }
150    }, []);
151
152    //#endregion
153
154    const onViewRef = useRef((vi: any) => {
155        viewableItemsRef.current = vi.viewableItems;
156    });
157    return (
158        <View style={style.flex1}>
159            {/* 纯展示 */}
160            <FakeFlatListItem
161                ref={_ => (fakeItemRef.current = _)}
162                backgroundColor={activeBackgroundColor}
163                renderItem={renderItem}
164                itemHeight={itemHeight}
165                item={activeItem}
166            />
167            <FlatList
168                scrollEnabled={scrollEnabled}
169                onViewableItemsChanged={onViewRef.current}
170                style={style.flex1}
171                ref={_ => {
172                    listRef.current = _;
173                }}
174                onLayout={evt => {
175                    layoutRef.current = evt.nativeEvent.layout;
176                }}
177                data={_data}
178                getItemLayout={(_, index) => ({
179                    length: itemHeight,
180                    offset: itemHeight * index,
181                    index,
182                })}
183                scrollEventThrottle={16}
184                onTouchStart={e => {
185                    if (activeRef.current !== -1) {
186                        // 相对于整个页面顶部的距离
187                        initDragPageY.current = e.nativeEvent.pageY;
188                        initDragLocationY.current = e.nativeEvent.locationY;
189                    }
190                }}
191                onTouchMove={e => {
192                    if (activeRef.current !== -1) {
193                        offsetRef.current =
194                            e.nativeEvent.pageY -
195                            (marginTop ?? layoutRef.current?.y ?? 0) -
196                            itemHeight / 2;
197
198                        if (offsetRef.current < 0) {
199                            offsetRef.current = 0;
200                        } else if (
201                            offsetRef.current >
202                            (layoutRef.current?.height ?? 0) - itemHeight
203                        ) {
204                            offsetRef.current =
205                                (layoutRef.current?.height ?? 0) - itemHeight;
206                        }
207                        fakeItemRef.current!.setNativeProps({
208                            top: offsetRef.current,
209                            opacity: 1,
210                            zIndex: 100,
211                        });
212
213                        // 如果超出范围,停止
214                        if (offsetRef.current < itemHeight * 2) {
215                            // 上滑
216                            direction.value =
217                                offsetRef.current / itemHeight / 2 - 1;
218                        } else if (
219                            offsetRef.current >
220                            (layoutRef.current?.height ?? 0) - 3 * itemHeight
221                        ) {
222                            // 下滑
223                            direction.value =
224                                (offsetRef.current -
225                                    (layoutRef.current?.height ?? 0) +
226                                    3 * itemHeight) /
227                                itemHeight /
228                                2;
229                        } else {
230                            // 不滑动
231                            direction.value = 0;
232                        }
233                    }
234                }}
235                onTouchEnd={e => {
236                    if (activeRef.current !== -1) {
237                        // 计算最终的位置,触发onSortEnd
238                        let index = activeRef.current;
239                        if (contentOffsetYRef.current) {
240                            index = Math.round(
241                                (contentOffsetYRef.current +
242                                    offsetRef.current) /
243                                    itemHeight,
244                            );
245                        } else {
246                            // 拖动的距离
247                            index =
248                                activeRef.current +
249                                Math.round(
250                                    (e.nativeEvent.pageY -
251                                        initDragPageY.current +
252                                        initDragLocationY.current) /
253                                        itemHeight,
254                                );
255                        }
256                        index = Math.min(data.length, Math.max(index, 0));
257                        // from: activeRef.current to: index
258                        if (activeRef.current !== index) {
259                            let nData = _data
260                                .slice(0, activeRef.current)
261                                .concat(_data.slice(activeRef.current + 1));
262                            nData.splice(index, 0, activeItem as T);
263                            onSortEnd?.(nData);
264                            // 测试用,正式时移除掉
265                            // _setData(nData);
266                        }
267                    }
268                    scrollingRef.current = false;
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                }}
278                onTouchCancel={() => {
279                    // todo: 滑动很快的时候会触发取消,native的flatlist就这样
280                    activeRef.current = -1;
281                    scrollingRef.current = false;
282                    setScrollEnabled(true);
283                    setActiveItem(null);
284                    fakeItemRef.current!.setNativeProps({
285                        top: 0,
286                        opacity: 0,
287                        zIndex: -1,
288                    });
289                    contentOffsetYRef.current = 0;
290                }}
291                onScroll={e => {
292                    contentOffsetYRef.current = e.nativeEvent.contentOffset.y;
293                    if (
294                        activeRef.current !== -1 &&
295                        Math.abs(
296                            contentOffsetYRef.current -
297                                targetOffsetYRef.current,
298                        ) < 2
299                    ) {
300                        scrollToTarget(true);
301                    }
302                }}
303                renderItem={({item, index}) => {
304                    return (
305                        <SortableFlatListItem
306                            setScrollEnabled={setScrollEnabled}
307                            activeRef={activeRef}
308                            renderItem={renderItem}
309                            item={item}
310                            index={index}
311                            setActiveItem={setActiveItem}
312                            itemHeight={itemHeight}
313                        />
314                    );
315                }}
316            />
317        </View>
318    );
319}
320
321interface ISortableFlatListItemProps<T extends any = any> {
322    item: T;
323    index: number;
324    // 高度
325    itemHeight: number;
326    setScrollEnabled: (scrollEnabled: boolean) => void;
327    renderItem: (props: {item: T; index: number}) => JSX.Element;
328    setActiveItem: (item: T | null) => void;
329    activeRef: React.MutableRefObject<number>;
330}
331function _SortableFlatListItem(props: ISortableFlatListItemProps) {
332    const {
333        itemHeight,
334        setScrollEnabled,
335        renderItem,
336        setActiveItem,
337        item,
338        index,
339        activeRef,
340    } = props;
341
342    // 省一点性能,height是顺着传下来的,放ref就好了
343    const styleRef = useRef(
344        StyleSheet.create({
345            viewWrapper: {
346                height: itemHeight,
347                width: WINDOW_WIDTH,
348                flexDirection: 'row',
349                justifyContent: 'flex-end',
350                zIndex: defaultZIndex,
351            },
352            btn: {
353                height: itemHeight,
354                paddingHorizontal: rpx(28),
355                justifyContent: 'center',
356                alignItems: 'center',
357            },
358        }),
359    );
360    const textColor = useTextColor();
361
362    return (
363        <View style={styleRef.current.viewWrapper}>
364            {renderItem({item, index})}
365            <Pressable
366                onTouchStart={() => {
367                    if (activeRef.current !== -1) {
368                        return;
369                    }
370                    /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */
371                    activeRef.current = index;
372                    /** 锁定滚动 */
373                    setScrollEnabled(false);
374                    setActiveItem(item);
375                }}
376                style={styleRef.current.btn}>
377                <Icon
378                    name="menu"
379                    size={iconSizeConst.normal}
380                    color={textColor}
381                />
382            </Pressable>
383        </View>
384    );
385}
386
387const SortableFlatListItem = memo(
388    _SortableFlatListItem,
389    (prev, curr) => prev.index === curr.index && prev.item === curr.item,
390);
391
392const FakeFlatListItem = forwardRef(function (
393    props: Pick<
394        ISortableFlatListItemProps,
395        'itemHeight' | 'renderItem' | 'item'
396    > & {
397        backgroundColor?: string;
398    },
399    ref: ForwardedRef<View>,
400) {
401    const {itemHeight, renderItem, item, backgroundColor} = props;
402
403    const styleRef = useRef(
404        StyleSheet.create({
405            viewWrapper: {
406                height: itemHeight,
407                width: WINDOW_WIDTH,
408                flexDirection: 'row',
409                justifyContent: 'flex-end',
410                zIndex: defaultZIndex,
411            },
412            btn: {
413                height: itemHeight,
414                paddingHorizontal: rpx(28),
415                justifyContent: 'center',
416                alignItems: 'center',
417            },
418        }),
419    );
420    const textColor = useTextColor();
421
422    return (
423        <View
424            ref={ref}
425            style={[
426                styleRef.current.viewWrapper,
427                style.activeItemDefault,
428                backgroundColor ? {backgroundColor} : {},
429            ]}>
430            {item ? renderItem({item, index: -1}) : <></>}
431            <Pressable style={styleRef.current.btn}>
432                <Icon
433                    name="menu"
434                    size={iconSizeConst.normal}
435                    color={textColor}
436                />
437            </Pressable>
438        </View>
439    );
440});
441
442const style = StyleSheet.create({
443    flex1: {
444        flex: 1,
445        width: WINDOW_WIDTH,
446    },
447    activeItemDefault: {
448        opacity: 0,
449        zIndex: -1,
450        position: 'absolute',
451        top: 0,
452        left: 0,
453    },
454});
455