xref: /MusicFree/src/components/base/SortableFlatList.tsx (revision 4ddc4470fe592a5fd575ab919bab554512e88398)
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 defaultZIndex = 10;
29
30interface ISortableFlatListProps<T> {
31    data: T[];
32    renderItem: (props: {item: T; index: number}) => JSX.Element;
33    // 高度
34    itemHeight: number;
35    itemJustifyContent?:
36        | 'flex-start'
37        | 'flex-end'
38        | 'center'
39        | 'space-between'
40        | 'space-around'
41        | 'space-evenly';
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        itemJustifyContent,
58        marginTop,
59        activeBackgroundColor,
60        onSortEnd,
61    } = props;
62
63    // 不要干扰原始数据
64    const [_data, _setData] = useState([...(data ?? [])]);
65    // 是否禁止滚动
66    const [scrollEnabled, setScrollEnabled] = useState(true);
67    // 是否处在激活状态, -1表示无,其他表示当前激活的下标
68    const activeRef = useRef(-1);
69    const [activeItem, setActiveItem] = useState<T | null>(null);
70
71    const layoutRef = useRef<LayoutRectangle>();
72    // listref
73    const listRef = useRef<FlashList<T> | null>(null);
74    // fakeref
75    const fakeItemRef = useRef<View | null>(null);
76    // contentoffset
77    const contentOffsetYRef = useRef<number>(-1);
78    const targetOffsetYRef = useRef<number>(0);
79
80    const direction = useSharedValue(0);
81
82    useEffect(() => {
83        _setData([...(data ?? [])]);
84    }, [data]);
85
86    const initDragPageY = useRef<number>(0);
87    const initDragLocationY = useRef<number>(0);
88    const offsetRef = useRef<number>(0);
89
90    //#region 滚动
91    const scrollingRef = useRef(false);
92
93    // 列表整体的高度
94    const listContentHeight = useMemo(
95        () => itemHeight * data.length,
96        [data, itemHeight],
97    );
98
99    function scrollToTarget(forceScroll = false) {
100        // 未选中
101        if (activeRef.current === -1) {
102            scrollingRef.current = false;
103            return;
104        }
105
106        // 滚动中就不滚了 /
107        if (scrollingRef.current && !forceScroll) {
108            scrollingRef.current = true;
109            return;
110        }
111        // 方向是0
112        if (direction.value === 0) {
113            scrollingRef.current = false;
114            return;
115        }
116
117        const nextTarget =
118            Math.sign(direction.value) *
119                Math.max(Math.abs(direction.value), 0.3) *
120                300 +
121            contentOffsetYRef.current;
122        // 当前到极限了
123        if (
124            (contentOffsetYRef.current <= 2 &&
125                nextTarget < contentOffsetYRef.current) ||
126            (contentOffsetYRef.current >=
127                listContentHeight - (layoutRef.current?.height ?? 0) - 2 &&
128                nextTarget > contentOffsetYRef.current)
129        ) {
130            scrollingRef.current = false;
131            return;
132        }
133        scrollingRef.current = true;
134        // 超出区域
135        targetOffsetYRef.current = Math.min(
136            Math.max(0, nextTarget),
137            listContentHeight - (layoutRef.current?.height ?? 0),
138        );
139        listRef.current?.scrollToOffset({
140            animated: true,
141            offset: targetOffsetYRef.current,
142        });
143    }
144
145    useDerivedValue(() => {
146        // 正在滚动
147        if (scrollingRef.current) {
148            return;
149        } else if (direction.value !== 0) {
150            // 开始滚动
151            runOnJS(scrollToTarget)();
152        }
153    }, []);
154
155    //#endregion
156
157    return (
158        <View style={style.flex1}>
159            {/* 纯展示 */}
160            <FakeFlatListItem
161                ref={_ => (fakeItemRef.current = _)}
162                backgroundColor={activeBackgroundColor}
163                renderItem={renderItem}
164                itemHeight={itemHeight}
165                item={activeItem}
166                itemJustifyContent={itemJustifyContent}
167            />
168            <FlashList
169                scrollEnabled={scrollEnabled}
170                ref={_ => {
171                    listRef.current = _;
172                }}
173                onLayout={evt => {
174                    layoutRef.current = evt.nativeEvent.layout;
175                }}
176                data={_data}
177                estimatedItemSize={itemHeight}
178                scrollEventThrottle={16}
179                onTouchStart={e => {
180                    if (activeRef.current !== -1) {
181                        // 相对于整个页面顶部的距离
182                        initDragPageY.current = e.nativeEvent.pageY;
183                        initDragLocationY.current = e.nativeEvent.locationY;
184                    }
185                }}
186                onTouchMove={e => {
187                    if (activeRef.current !== -1) {
188                        offsetRef.current =
189                            e.nativeEvent.pageY -
190                            (marginTop ?? layoutRef.current?.y ?? 0) -
191                            itemHeight / 2;
192
193                        if (offsetRef.current < 0) {
194                            offsetRef.current = 0;
195                        } else if (
196                            offsetRef.current >
197                            (layoutRef.current?.height ?? 0) - itemHeight
198                        ) {
199                            offsetRef.current =
200                                (layoutRef.current?.height ?? 0) - itemHeight;
201                        }
202                        fakeItemRef.current!.setNativeProps({
203                            top: offsetRef.current,
204                            opacity: 1,
205                            zIndex: 100,
206                        });
207
208                        // 如果超出范围,停止
209                        if (offsetRef.current < itemHeight * 2) {
210                            // 上滑
211                            direction.value =
212                                offsetRef.current / itemHeight / 2 - 1;
213                        } else if (
214                            offsetRef.current >
215                            (layoutRef.current?.height ?? 0) - 3 * itemHeight
216                        ) {
217                            // 下滑
218                            direction.value =
219                                (offsetRef.current -
220                                    (layoutRef.current?.height ?? 0) +
221                                    3 * itemHeight) /
222                                itemHeight /
223                                2;
224                        } else {
225                            // 不滑动
226                            direction.value = 0;
227                        }
228                    }
229                }}
230                onTouchEnd={e => {
231                    if (activeRef.current !== -1) {
232                        // 计算最终的位置,触发onSortEnd
233                        let index = activeRef.current;
234                        if (contentOffsetYRef.current !== -1) {
235                            index = Math.round(
236                                (contentOffsetYRef.current +
237                                    offsetRef.current) /
238                                    itemHeight,
239                            );
240                        } else {
241                            // 拖动的距离
242                            index =
243                                activeRef.current +
244                                Math.round(
245                                    (e.nativeEvent.pageY -
246                                        initDragPageY.current +
247                                        initDragLocationY.current) /
248                                        itemHeight,
249                                );
250                        }
251                        index = Math.min(data.length, Math.max(index, 0));
252                        // from: activeRef.current to: index
253                        if (activeRef.current !== index) {
254                            let nData = _data
255                                .slice(0, activeRef.current)
256                                .concat(_data.slice(activeRef.current + 1));
257                            nData.splice(index, 0, activeItem as T);
258                            onSortEnd?.(nData);
259                            // 测试用,正式时移除掉
260                            // _setData(nData);
261                        }
262                    }
263                    scrollingRef.current = false;
264                    activeRef.current = -1;
265                    setScrollEnabled(true);
266                    setActiveItem(null);
267                    fakeItemRef.current!.setNativeProps({
268                        top: 0,
269                        opacity: 0,
270                        zIndex: -1,
271                    });
272                }}
273                onTouchCancel={() => {
274                    // todo: 滑动很快的时候会触发取消,native的flatlist就这样
275                    activeRef.current = -1;
276                    scrollingRef.current = false;
277                    setScrollEnabled(true);
278                    setActiveItem(null);
279                    fakeItemRef.current!.setNativeProps({
280                        top: 0,
281                        opacity: 0,
282                        zIndex: -1,
283                    });
284                    contentOffsetYRef.current = -1;
285                }}
286                onScroll={e => {
287                    contentOffsetYRef.current = e.nativeEvent.contentOffset.y;
288                    if (
289                        activeRef.current !== -1 &&
290                        Math.abs(
291                            contentOffsetYRef.current -
292                                targetOffsetYRef.current,
293                        ) < 2
294                    ) {
295                        scrollToTarget(true);
296                    }
297                }}
298                renderItem={({item, index}) => {
299                    return (
300                        <SortableFlatListItem
301                            setScrollEnabled={setScrollEnabled}
302                            activeRef={activeRef}
303                            renderItem={renderItem}
304                            item={item}
305                            index={index}
306                            setActiveItem={setActiveItem}
307                            itemJustifyContent={itemJustifyContent}
308                            itemHeight={itemHeight}
309                        />
310                    );
311                }}
312            />
313        </View>
314    );
315}
316
317interface ISortableFlatListItemProps<T extends any = any> {
318    item: T;
319    index: number;
320    // 高度
321    itemHeight: number;
322    itemJustifyContent?:
323        | 'flex-start'
324        | 'flex-end'
325        | 'center'
326        | 'space-between'
327        | 'space-around'
328        | 'space-evenly';
329    setScrollEnabled: (scrollEnabled: boolean) => void;
330    renderItem: (props: {item: T; index: number}) => JSX.Element;
331    setActiveItem: (item: T | null) => void;
332    activeRef: React.MutableRefObject<number>;
333}
334function _SortableFlatListItem(props: ISortableFlatListItemProps) {
335    const {
336        itemHeight,
337        setScrollEnabled,
338        renderItem,
339        setActiveItem,
340        itemJustifyContent,
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: '100%',
352                flexDirection: 'row',
353                justifyContent: itemJustifyContent ?? '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' | 'itemJustifyContent'
400    > & {
401        backgroundColor?: string;
402    },
403    ref: ForwardedRef<View>,
404) {
405    const {itemHeight, renderItem, item, backgroundColor, itemJustifyContent} =
406        props;
407
408    const styleRef = useRef(
409        StyleSheet.create({
410            viewWrapper: {
411                height: itemHeight,
412                width: '100%',
413                flexDirection: 'row',
414                justifyContent: itemJustifyContent ?? 'flex-end',
415                zIndex: defaultZIndex,
416            },
417            btn: {
418                height: itemHeight,
419                paddingHorizontal: rpx(28),
420                justifyContent: 'center',
421                alignItems: 'center',
422            },
423        }),
424    );
425    const textColor = useTextColor();
426
427    return (
428        <View
429            ref={ref}
430            style={[
431                styleRef.current.viewWrapper,
432                style.activeItemDefault,
433                backgroundColor ? {backgroundColor} : {},
434            ]}>
435            {item ? renderItem({item, index: -1}) : null}
436            <Pressable style={styleRef.current.btn}>
437                <Icon
438                    name="menu"
439                    size={iconSizeConst.normal}
440                    color={textColor}
441                />
442            </Pressable>
443        </View>
444    );
445});
446
447const style = StyleSheet.create({
448    flex1: {
449        flex: 1,
450        width: '100%',
451    },
452    activeItemDefault: {
453        opacity: 0,
454        zIndex: -1,
455        position: 'absolute',
456        top: 0,
457        left: 0,
458    },
459});
460