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