ReactNative完全自定义下拉刷新

1,215 阅读2分钟

通过 react-native-gesture-handler + react-native-reanimated 实现完全自定义的下拉刷新功能。下面是可以直接复制使用的demo代码。下拉刷新行为与安卓原生下拉刷新行为一致,若需要实现iOS端下拉刷新需要禁用 bounces 、通过正值的Y轴偏移动画来添加上 overdrag 效果。

import React, { useEffect, useMemo, useRef, useState } from 'react';
import { Text, View, Pressable, ActivityIndicator, FlatList, StyleSheet, SafeAreaView, Platform, Alert } from 'react-native';
import Animated, { useAnimatedStyle, useSharedValue, withTiming, runOnJS, useAnimatedScrollHandler, interpolate } from 'react-native-reanimated';
import { Gesture, GestureDetector, GestureHandlerRootView } from 'react-native-gesture-handler';

const INDICATOR_HEIGHT = 40;
const AnimatedFlatList = Animated.createAnimatedComponent(FlatList);

const PullRefreshExample = (props) => {

const names = [' 琼恩·雪诺', ' 丹妮莉丝·坦格利安', ' 艾德·史塔克', ' 凯特琳·徒利·史塔克', ' 罗柏·史塔克', ' 珊莎·史塔克', ' 艾莉亚·史塔克', ' 布兰·史塔克', ' 瑞肯·史塔克',' 劳勃·拜拉席恩',' 史坦尼斯·拜拉席恩',' 蓝礼·拜拉席恩',' 乔佛里·拜拉席恩',' 弥赛拉·拜拉席恩',' 托曼·拜拉席恩'];

const [containerLayout, setContainerLayout] = useState({ width: 0, height: 0, x: 0, y: 0 })

    const fetchData = () => {

        //模拟执行其他事件,2秒后结束刷新

        setTimeout(() => {

            setRefreshing(false);

            console.log('Async Task Finished');

        }, 2000);
    }

    const [isRefreshing,setRefreshing] = useState(false);
    const onRefresh = () => {
        setRefreshing(true);
        console.log('refreshing now');
        fetchData();
    }

    const scrollOffset = useSharedValue(0);
    const panOffset = useSharedValue(0);

    const indicatorPanEdges = useMemo(() => {
        let bh = containerLayout.y + INDICATOR_HEIGHT;
        return [0, bh + 20, bh + 100, Number.MAX_SAFE_INTEGER];
    }, [containerLayout]);
    const inidcatorAnimatedStyle = useAnimatedStyle(() => {
        let bh = containerLayout.y + INDICATOR_HEIGHT;
        let dy = panOffset.value - scrollOffset.value;
        if(scrollOffset.value>0) dy = 0;
        let polatedDy = interpolate(dy,indicatorPanEdges,
                    [0, bh + 20, bh + 36, bh + 36]);
        return {
            opacity: 1.0,
            transform: [
                { translateY: polatedDy },
            ]
        }
    }, [indicatorPanEdges])
    
    const panRef = useRef();
    const nativeRef = useRef();

    const onContainerLayout = React.useCallback((event) => {
        setContainerLayout(event.nativeEvent.layout);
    }, [])
    const touchStartPosition = useSharedValue({ x: 0, y: 0 })
    const isOnRefresh = useSharedValue(false);

    useEffect(() => {
        if(isRefreshing === false){
        /**
         * 此时可能用户滑动事件手动取消了刷新 (isOnRefresh 为 false)。取消了刷新则不再更新 panOffset
         */
            if(isOnRefresh.value){
                panOffset.value = withTiming(0)
            }
        }
    },[isRefreshing])

    const panGesture = Gesture.Pan()
        .manualActivation(true)
        .minPointers(1)
        .onTouchesDown((event, stateManager) => {
            touchStartPosition.value = { x: event.allTouches[0].x, y: event.allTouches[0].y }
        })
        .onTouchesMove((event, stateManager) => {
            let dx = event.allTouches[0].x - touchStartPosition.value.x;
            let dy = event.allTouches[0].y - touchStartPosition.value.y;
            if(Math.abs(dy) > 2 && Math.abs(dx) < 5){
                stateManager.activate();
            }
        })
        .onUpdate(event => {
            panOffset.value = event.translationY;
        })
        .onEnd(event => {
            let ty = event.translationY;
            if(scrollOffset.value > 0) {
                panOffset.value = 0;
                return;
            }
            if(ty >= indicatorPanEdges[1]){
                //位移值达到触发刷新的指定位置
                isOnRefresh.value = true;
                panOffset.value = withTiming(indicatorPanEdges[2],undefined,(finished) => {
                    runOnJS(onRefresh)();
                })
            }else{
                //复位
                panOffset.value = withTiming(0)
                if(isOnRefresh.value) {
                    isOnRefresh.value = false;
                }
                runOnJS(setRefreshing)(false);
            }
        })
        .simultaneousWithExternalGesture(nativeRef)
        .withRef(panRef);

    const nativeGesture = Gesture.Native()
        .simultaneousWithExternalGesture(panRef)
        .withRef(nativeRef)

    const scrollhandler = useAnimatedScrollHandler({
        onScroll: (event) => {
            scrollOffset.value = event.contentOffset.y;
        }
    })

    const renderItem = ({ item, index }) => {
        return (
            <Pressable onPress={() => {Alert.alert('Greet Message',' Hi , this is '+item)}} style={styles.item}>
                <Text style={styles.item_label}>{item}</Text>
            </Pressable>
        )
    }

    return (
        <GestureHandlerRootView style={{ flex: 1 }}>
            <Animated.View style={[styles.indicator, inidcatorAnimatedStyle]}>
                <Text style={{ fontSize: 17, color: '#666' }}>自定义刷新</Text>
                <ActivityIndicator size={'small'} style={{ marginStart: 7 }} />
            </Animated.View>
            <GestureDetector gesture={panGesture}>
            <SafeAreaView style={{ flex: 1, position: 'relative' }}>
            <View style={{ flex: 1 }} onLayout={onContainerLayout}>
            <GestureDetector gesture={nativeGesture}>
                <AnimatedFlatList 
                    data={names} 
                    keyExtractor={(item, index) => item}
                    renderItem={renderItem}
                    onScroll={scrollhandler}
                />
            </GestureDetector>
            </View>
            </SafeAreaView>
            </GestureDetector>
        </GestureHandlerRootView>
        );
    }

export default PullRefreshExample;
const styles = StyleSheet.create({
    item: {
        paddingVertical: 20,
        borderBottomWidth: 1,
        borderBottomColor: '#f3f3f5'
    },
    item_label: {
        fontSize: 20,
        color: '#222'
    },
    indicator: {
        position: 'absolute',
        zIndex: 99,
        alignSelf: 'center',
        flexDirection: 'row',
        alignItems: 'center',
        justifyContent: 'center',
        top: -INDICATOR_HEIGHT,
        backgroundColor: '#FFF',
        height: INDICATOR_HEIGHT,
        paddingHorizontal: 18,
        borderRadius: 18,
        ...Platform.select({
            ios: {
                shadowColor: '#000000',
                shadowOpacity: 0.15,
                shadowRadius: 8,
                shadowOffset: { width: 3, height: 3 }
            },
            android: {
                elevation: 12
            }
        })
    }
});