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