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