xref: /MusicFree/src/components/base/SortableFlatList.tsx (revision bec1e603c6b2f7d8e3a11b0c41d3499e77fd9167)
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 {
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    // 滚动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        marginTop,
58        activeBackgroundColor,
59        onSortEnd,
60    } = props;
61
62    // 不要干扰原始数据
63    const [_data, _setData] = useState([...(data ?? [])]);
64    // 是否禁止滚动
65    const [scrollEnabled, setScrollEnabled] = useState(true);
66    // 是否处在激活状态, -1表示无,其他表示当前激活的下标
67    const activeRef = useRef(-1);
68    const [activeItem, setActiveItem] = useState<T | null>(null);
69
70    const viewableItemsRef = useRef<ViewToken[] | null>(null);
71
72    const layoutRef = useRef<LayoutRectangle>();
73    // listref
74    const listRef = useRef<FlatList<T> | null>(null);
75    // fakeref
76    const fakeItemRef = useRef<View | null>(null);
77    // contentoffset
78    const contentOffsetYRef = useRef<number>(0);
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 getListContentHeight = useMemo(
96        () => () => itemHeight * data.length,
97        [data],
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                getListContentHeight() - (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            getListContentHeight() - (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    const onViewRef = useRef((vi: any) => {
159        viewableItemsRef.current = vi.viewableItems;
160    });
161    return (
162        <View style={style.flex1}>
163            {/* 纯展示 */}
164            <FakeFlatListItem
165                ref={_ => (fakeItemRef.current = _)}
166                backgroundColor={activeBackgroundColor}
167                renderItem={renderItem}
168                itemHeight={itemHeight}
169                item={activeItem}
170            />
171            <FlatList
172                scrollEnabled={scrollEnabled}
173                onViewableItemsChanged={onViewRef.current}
174                style={style.flex1}
175                ref={_ => {
176                    listRef.current = _;
177                }}
178                onLayout={evt => {
179                    layoutRef.current = evt.nativeEvent.layout;
180                }}
181                data={_data}
182                getItemLayout={(_, index) => ({
183                    length: itemHeight,
184                    offset: itemHeight * index,
185                    index,
186                })}
187                scrollEventThrottle={16}
188                onTouchStart={e => {
189                    if (activeRef.current !== -1) {
190                        // 相对于整个页面顶部的距离
191                        initDragPageY.current = e.nativeEvent.pageY;
192                        initDragLocationY.current = e.nativeEvent.locationY;
193                    }
194                }}
195                onTouchMove={e => {
196                    if (activeRef.current !== -1) {
197                        offsetRef.current =
198                            e.nativeEvent.pageY -
199                            (marginTop ?? layoutRef.current?.y ?? 0) -
200                            itemHeight / 2;
201
202                        if (offsetRef.current < 0) {
203                            offsetRef.current = 0;
204                        } else if (
205                            offsetRef.current >
206                            (layoutRef.current?.height ?? 0) - itemHeight
207                        ) {
208                            offsetRef.current =
209                                (layoutRef.current?.height ?? 0) - itemHeight;
210                        }
211                        fakeItemRef.current!.setNativeProps({
212                            top: offsetRef.current,
213                            opacity: 1,
214                            zIndex: 100,
215                        });
216
217                        // 如果超出范围,停止
218                        if (offsetRef.current < itemHeight * 2) {
219                            // 上滑
220                            direction.value =
221                                offsetRef.current / itemHeight / 2 - 1;
222                        } else if (
223                            offsetRef.current >
224                            (layoutRef.current?.height ?? 0) - 3 * itemHeight
225                        ) {
226                            // 下滑
227                            direction.value =
228                                (offsetRef.current -
229                                    (layoutRef.current?.height ?? 0) +
230                                    3 * itemHeight) /
231                                itemHeight /
232                                2;
233                        } else {
234                            // 不滑动
235                            direction.value = 0;
236                        }
237                    }
238                }}
239                onTouchEnd={e => {
240                    if (activeRef.current !== -1) {
241                        // 计算最终的位置,触发onSortEnd
242                        let index = activeRef.current;
243                        if (contentOffsetYRef.current) {
244                            index = Math.round(
245                                (contentOffsetYRef.current +
246                                    offsetRef.current) /
247                                    itemHeight,
248                            );
249                        } else {
250                            // 拖动的距离
251                            index =
252                                activeRef.current +
253                                Math.round(
254                                    (e.nativeEvent.pageY -
255                                        initDragPageY.current +
256                                        initDragLocationY.current) /
257                                        itemHeight,
258                                );
259                        }
260                        index = Math.min(data.length, Math.max(index, 0));
261                        // from: activeRef.current to: index
262                        if (activeRef.current !== index) {
263                            let nData = _data
264                                .slice(0, activeRef.current)
265                                .concat(_data.slice(activeRef.current + 1));
266                            nData.splice(index, 0, activeItem as T);
267                            onSortEnd?.(nData);
268                            // 测试用,正式时移除掉
269                            // _setData(nData);
270                        }
271                    }
272                    scrollingRef.current = false;
273                    activeRef.current = -1;
274                    setScrollEnabled(true);
275                    setActiveItem(null);
276                    fakeItemRef.current!.setNativeProps({
277                        top: 0,
278                        opacity: 0,
279                        zIndex: -1,
280                    });
281                }}
282                onTouchCancel={() => {
283                    // todo: 滑动很快的时候会触发取消,native的flatlist就这样
284                    activeRef.current = -1;
285                    scrollingRef.current = false;
286                    setScrollEnabled(true);
287                    setActiveItem(null);
288                    fakeItemRef.current!.setNativeProps({
289                        top: 0,
290                        opacity: 0,
291                        zIndex: -1,
292                    });
293                    contentOffsetYRef.current = 0;
294                }}
295                onScroll={e => {
296                    contentOffsetYRef.current = e.nativeEvent.contentOffset.y;
297                    if (
298                        activeRef.current !== -1 &&
299                        Math.abs(
300                            contentOffsetYRef.current -
301                                targetOffsetYRef.current,
302                        ) < 2
303                    ) {
304                        scrollToTarget(true);
305                    }
306                }}
307                renderItem={({item, index}) => {
308                    return (
309                        <SortableFlatListItem
310                            setScrollEnabled={setScrollEnabled}
311                            activeRef={activeRef}
312                            renderItem={renderItem}
313                            item={item}
314                            index={index}
315                            setActiveItem={setActiveItem}
316                            itemHeight={itemHeight}
317                        />
318                    );
319                }}
320            />
321        </View>
322    );
323}
324
325interface ISortableFlatListItemProps<T extends any = any> {
326    item: T;
327    index: number;
328    // 高度
329    itemHeight: number;
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        item,
342        index,
343        activeRef,
344    } = props;
345
346    // 省一点性能,height是顺着传下来的,放ref就好了
347    const styleRef = useRef(
348        StyleSheet.create({
349            viewWrapper: {
350                height: itemHeight,
351                width: WINDOW_WIDTH,
352                flexDirection: 'row',
353                justifyContent: 'flex-end',
354                zIndex: defaultZIndex,
355            },
356            btn: {
357                height: itemHeight,
358                paddingHorizontal: rpx(28),
359                justifyContent: 'center',
360                alignItems: 'center',
361            },
362        }),
363    );
364    const textColor = useTextColor();
365
366    return (
367        <View style={styleRef.current.viewWrapper}>
368            {renderItem({item, index})}
369            <Pressable
370                onTouchStart={() => {
371                    if (activeRef.current !== -1) {
372                        return;
373                    }
374                    /** 使用ref避免其它组件重新渲染; 由于事件冒泡,这里会先触发 */
375                    activeRef.current = index;
376                    /** 锁定滚动 */
377                    setScrollEnabled(false);
378                    setActiveItem(item);
379                }}
380                style={styleRef.current.btn}>
381                <Icon
382                    name="menu"
383                    size={iconSizeConst.normal}
384                    color={textColor}
385                />
386            </Pressable>
387        </View>
388    );
389}
390
391const SortableFlatListItem = memo(
392    _SortableFlatListItem,
393    (prev, curr) => prev.index === curr.index && prev.item === curr.item,
394);
395
396const FakeFlatListItem = forwardRef(function (
397    props: Pick<
398        ISortableFlatListItemProps,
399        'itemHeight' | 'renderItem' | 'item'
400    > & {
401        backgroundColor?: string;
402    },
403    ref: ForwardedRef<View>,
404) {
405    const {itemHeight, renderItem, item, backgroundColor} = props;
406
407    const styleRef = useRef(
408        StyleSheet.create({
409            viewWrapper: {
410                height: itemHeight,
411                width: WINDOW_WIDTH,
412                flexDirection: 'row',
413                justifyContent: 'flex-end',
414                zIndex: defaultZIndex,
415            },
416            btn: {
417                height: itemHeight,
418                paddingHorizontal: rpx(28),
419                justifyContent: 'center',
420                alignItems: 'center',
421            },
422        }),
423    );
424    const textColor = useTextColor();
425
426    return (
427        <View
428            ref={ref}
429            style={[
430                styleRef.current.viewWrapper,
431                style.activeItemDefault,
432                backgroundColor ? {backgroundColor} : {},
433            ]}>
434            {item ? renderItem({item, index: -1}) : <></>}
435            <Pressable style={styleRef.current.btn}>
436                <Icon
437                    name="menu"
438                    size={iconSizeConst.normal}
439                    color={textColor}
440                />
441            </Pressable>
442        </View>
443    );
444});
445
446const style = StyleSheet.create({
447    flex1: {
448        flex: 1,
449        width: WINDOW_WIDTH,
450    },
451    activeItemDefault: {
452        opacity: 0,
453        zIndex: -1,
454        position: 'absolute',
455        top: 0,
456        left: 0,
457    },
458});
459