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