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