xref: /MusicFree/src/components/base/SortableFlatList.tsx (revision c446f2b83c1bd26ef7ee96c37926a9cbde29bc16)
1/**
2 * 支持长按拖拽排序的flatlist,右边加个固定的按钮,拖拽排序。
3 * 考虑到方便实现+节省性能,整个app内的拖拽排序都遵守以下实现。
4 * 点击会出现
5 */
6
7import {iconSizeConst} from '@/constants/uiConst';
8import useTextColor from '@/hooks/useTextColor';
9import rpx from '@/utils/rpx';
10import {FlashList} from '@shopify/flash-list';
11import React, {
12    ForwardedRef,
13    forwardRef,
14    memo,
15    useEffect,
16    useMemo,
17    useRef,
18    useState,
19} from 'react';
20import {LayoutRectangle, Pressable, StyleSheet, View} from 'react-native';
21import {
22    runOnJS,
23    useDerivedValue,
24    useSharedValue,
25} from 'react-native-reanimated';
26import Icon from 'react-native-vector-icons/MaterialCommunityIcons';
27
28const defaultZIndex = 10;
29
30interface ISortableFlatListProps<T> {
31    data: T[];
32    renderItem: (props: {item: T; index: number}) => JSX.Element;
33    // 高度
34    itemHeight: number;
35    itemJustifyContent?:
36        | 'flex-start'
37        | 'flex-end'
38        | 'center'
39        | 'space-between'
40        | 'space-around'
41        | 'space-evenly';
42    // 滚动list距离顶部的距离, 这里写的不好
43    marginTop: number;
44    /** 拖拽时的背景色 */
45    activeBackgroundColor?: string;
46    /** 交换结束 */
47    onSortEnd?: (newData: T[]) => void;
48}
49
50export default function SortableFlatList<T extends any = any>(
51    props: ISortableFlatListProps<T>,
52) {
53    const {
54        data,
55        renderItem,
56        itemHeight,
57        itemJustifyContent,
58        marginTop,
59        activeBackgroundColor,
60        onSortEnd,
61    } = props;
62
63    // 不要干扰原始数据
64    const [_data, _setData] = useState([...(data ?? [])]);
65    // 是否禁止滚动
66    const [scrollEnabled, setScrollEnabled] = useState(true);
67    // 是否处在激活状态, -1表示无,其他表示当前激活的下标
68    const activeRef = useRef(-1);
69    const [activeItem, setActiveItem] = useState<T | null>(null);
70
71    const layoutRef = useRef<LayoutRectangle>();
72    // listref
73    const listRef = useRef<FlashList<T> | null>(null);
74    // fakeref
75    const fakeItemRef = useRef<View | null>(null);
76    // contentoffset
77    const contentOffsetYRef = useRef<number>(-1);
78    const targetOffsetYRef = useRef<number>(0);
79
80    const direction = useSharedValue(0);
81
82    useEffect(() => {
83        _setData([...(data ?? [])]);
84    }, [data]);
85
86    const initDragPageY = useRef<number>(0);
87    const initDragLocationY = useRef<number>(0);
88    const offsetRef = useRef<number>(0);
89
90    //#region 滚动
91    const scrollingRef = useRef(false);
92
93    // 列表整体的高度
94    const listContentHeight = useMemo(() => itemHeight * data.length, [data]);
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                listContentHeight - (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            listContentHeight - (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            runOnJS(scrollToTarget)();
149        }
150    }, []);
151
152    //#endregion
153
154    return (
155        <View style={style.flex1}>
156            {/* 纯展示 */}
157            <FakeFlatListItem
158                ref={_ => (fakeItemRef.current = _)}
159                backgroundColor={activeBackgroundColor}
160                renderItem={renderItem}
161                itemHeight={itemHeight}
162                item={activeItem}
163                itemJustifyContent={itemJustifyContent}
164            />
165            <FlashList
166                scrollEnabled={scrollEnabled}
167                ref={_ => {
168                    listRef.current = _;
169                }}
170                onLayout={evt => {
171                    layoutRef.current = evt.nativeEvent.layout;
172                }}
173                data={_data}
174                estimatedItemSize={itemHeight}
175                scrollEventThrottle={16}
176                onTouchStart={e => {
177                    if (activeRef.current !== -1) {
178                        // 相对于整个页面顶部的距离
179                        initDragPageY.current = e.nativeEvent.pageY;
180                        initDragLocationY.current = e.nativeEvent.locationY;
181                    }
182                }}
183                onTouchMove={e => {
184                    if (activeRef.current !== -1) {
185                        offsetRef.current =
186                            e.nativeEvent.pageY -
187                            (marginTop ?? layoutRef.current?.y ?? 0) -
188                            itemHeight / 2;
189
190                        if (offsetRef.current < 0) {
191                            offsetRef.current = 0;
192                        } else if (
193                            offsetRef.current >
194                            (layoutRef.current?.height ?? 0) - itemHeight
195                        ) {
196                            offsetRef.current =
197                                (layoutRef.current?.height ?? 0) - itemHeight;
198                        }
199                        fakeItemRef.current!.setNativeProps({
200                            top: offsetRef.current,
201                            opacity: 1,
202                            zIndex: 100,
203                        });
204
205                        // 如果超出范围,停止
206                        if (offsetRef.current < itemHeight * 2) {
207                            // 上滑
208                            direction.value =
209                                offsetRef.current / itemHeight / 2 - 1;
210                        } else if (
211                            offsetRef.current >
212                            (layoutRef.current?.height ?? 0) - 3 * itemHeight
213                        ) {
214                            // 下滑
215                            direction.value =
216                                (offsetRef.current -
217                                    (layoutRef.current?.height ?? 0) +
218                                    3 * itemHeight) /
219                                itemHeight /
220                                2;
221                        } else {
222                            // 不滑动
223                            direction.value = 0;
224                        }
225                    }
226                }}
227                onTouchEnd={e => {
228                    if (activeRef.current !== -1) {
229                        // 计算最终的位置,触发onSortEnd
230                        let index = activeRef.current;
231                        if (contentOffsetYRef.current !== -1) {
232                            index = Math.round(
233                                (contentOffsetYRef.current +
234                                    offsetRef.current) /
235                                    itemHeight,
236                            );
237                        } else {
238                            // 拖动的距离
239                            index =
240                                activeRef.current +
241                                Math.round(
242                                    (e.nativeEvent.pageY -
243                                        initDragPageY.current +
244                                        initDragLocationY.current) /
245                                        itemHeight,
246                                );
247                        }
248                        index = Math.min(data.length, Math.max(index, 0));
249                        // from: activeRef.current to: index
250                        if (activeRef.current !== index) {
251                            let nData = _data
252                                .slice(0, activeRef.current)
253                                .concat(_data.slice(activeRef.current + 1));
254                            nData.splice(index, 0, activeItem as T);
255                            onSortEnd?.(nData);
256                            // 测试用,正式时移除掉
257                            // _setData(nData);
258                        }
259                    }
260                    scrollingRef.current = false;
261                    activeRef.current = -1;
262                    setScrollEnabled(true);
263                    setActiveItem(null);
264                    fakeItemRef.current!.setNativeProps({
265                        top: 0,
266                        opacity: 0,
267                        zIndex: -1,
268                    });
269                }}
270                onTouchCancel={() => {
271                    // todo: 滑动很快的时候会触发取消,native的flatlist就这样
272                    activeRef.current = -1;
273                    scrollingRef.current = false;
274                    setScrollEnabled(true);
275                    setActiveItem(null);
276                    fakeItemRef.current!.setNativeProps({
277                        top: 0,
278                        opacity: 0,
279                        zIndex: -1,
280                    });
281                    contentOffsetYRef.current = -1;
282                }}
283                onScroll={e => {
284                    contentOffsetYRef.current = e.nativeEvent.contentOffset.y;
285                    if (
286                        activeRef.current !== -1 &&
287                        Math.abs(
288                            contentOffsetYRef.current -
289                                targetOffsetYRef.current,
290                        ) < 2
291                    ) {
292                        scrollToTarget(true);
293                    }
294                }}
295                renderItem={({item, index}) => {
296                    return (
297                        <SortableFlatListItem
298                            setScrollEnabled={setScrollEnabled}
299                            activeRef={activeRef}
300                            renderItem={renderItem}
301                            item={item}
302                            index={index}
303                            setActiveItem={setActiveItem}
304                            itemJustifyContent={itemJustifyContent}
305                            itemHeight={itemHeight}
306                        />
307                    );
308                }}
309            />
310        </View>
311    );
312}
313
314interface ISortableFlatListItemProps<T extends any = any> {
315    item: T;
316    index: number;
317    // 高度
318    itemHeight: number;
319    itemJustifyContent?:
320        | 'flex-start'
321        | 'flex-end'
322        | 'center'
323        | 'space-between'
324        | 'space-around'
325        | 'space-evenly';
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        itemJustifyContent,
338        item,
339        index,
340        activeRef,
341    } = props;
342
343    // 省一点性能,height是顺着传下来的,放ref就好了
344    const styleRef = useRef(
345        StyleSheet.create({
346            viewWrapper: {
347                height: itemHeight,
348                width: '100%',
349                flexDirection: 'row',
350                justifyContent: itemJustifyContent ?? 'flex-end',
351                zIndex: defaultZIndex,
352            },
353            btn: {
354                height: itemHeight,
355                paddingHorizontal: rpx(28),
356                justifyContent: 'center',
357                alignItems: 'center',
358            },
359        }),
360    );
361    const textColor = useTextColor();
362
363    return (
364        <View style={styleRef.current.viewWrapper}>
365            {renderItem({item, index})}
366            <Pressable
367                onTouchStart={() => {
368                    if (activeRef.current !== -1) {
369                        return;
370                    }
371                    /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */
372                    activeRef.current = index;
373                    /** 锁定滚动 */
374                    setScrollEnabled(false);
375                    setActiveItem(item);
376                }}
377                style={styleRef.current.btn}>
378                <Icon
379                    name="menu"
380                    size={iconSizeConst.normal}
381                    color={textColor}
382                />
383            </Pressable>
384        </View>
385    );
386}
387
388const SortableFlatListItem = memo(
389    _SortableFlatListItem,
390    (prev, curr) => prev.index === curr.index && prev.item === curr.item,
391);
392
393const FakeFlatListItem = forwardRef(function (
394    props: Pick<
395        ISortableFlatListItemProps,
396        'itemHeight' | 'renderItem' | 'item' | 'itemJustifyContent'
397    > & {
398        backgroundColor?: string;
399    },
400    ref: ForwardedRef<View>,
401) {
402    const {itemHeight, renderItem, item, backgroundColor, itemJustifyContent} =
403        props;
404
405    const styleRef = useRef(
406        StyleSheet.create({
407            viewWrapper: {
408                height: itemHeight,
409                width: '100%',
410                flexDirection: 'row',
411                justifyContent: itemJustifyContent ?? 'flex-end',
412                zIndex: defaultZIndex,
413            },
414            btn: {
415                height: itemHeight,
416                paddingHorizontal: rpx(28),
417                justifyContent: 'center',
418                alignItems: 'center',
419            },
420        }),
421    );
422    const textColor = useTextColor();
423
424    return (
425        <View
426            ref={ref}
427            style={[
428                styleRef.current.viewWrapper,
429                style.activeItemDefault,
430                backgroundColor ? {backgroundColor} : {},
431            ]}>
432            {item ? renderItem({item, index: -1}) : null}
433            <Pressable style={styleRef.current.btn}>
434                <Icon
435                    name="menu"
436                    size={iconSizeConst.normal}
437                    color={textColor}
438                />
439            </Pressable>
440        </View>
441    );
442});
443
444const style = StyleSheet.create({
445    flex1: {
446        flex: 1,
447        width: '100%',
448    },
449    activeItemDefault: {
450        opacity: 0,
451        zIndex: -1,
452        position: 'absolute',
453        top: 0,
454        left: 0,
455    },
456});
457