美文网首页React native
打造React Native平台高性能下拉刷新组件

打造React Native平台高性能下拉刷新组件

作者: 基因宝研发团队 | 来源:发表于2021-05-26 21:18 被阅读0次

    背景

    基因宝App在大量页面中提供了下拉刷新功能,方便用户实时获取最新数据。在提升用户交互体验的同时,也存在了一些较为明显的问题,例如,平台差异化严重,性能较差,可定制性低,主动刷新机制缺乏等。
    为提升用户体验,实现高性能、多端交互一致性,近期,在原有基础上,我们对下拉刷新组件做了一次大升级。

    RN中提供了RefreshControl,但是iOS和Android默认样式不统一,不能进行修改。


    WechatIMG907.jpeg

    这就需要我们自定义下拉刷新组件

    RN中的实现方法:

    1. PanResponder

    View设置panResponder,Animated.View下拉动画改变translateY值

    containerTop: new Animated.Value(0),
    render() {
            const child = React.cloneElement(this.props.children, {
                bounces: false,
                alwaysBounceVertical: false,
            });
            return (
                <View  { ...this.panResponder.panHandlers }>
                    <Animated.View
                        style={{flex: 1,transform: [{ translateY: this.state.containerTop }]}}>
                        {child}
                    </Animated.View>
                </View>
            );
        }
    

    当外层向下滚动距离小于0,将此View设置为响应者,否则外层组件设置为响应者。

    onMoveShouldSetResponder = (event, gestureState) => {
            if (Math.abs(gestureState.dy) > Math.abs(gestureState.dx)) {
                if (this.innerScrollTop <= 0 && gestureState.dy > 0) {
                    return true;
                }
            }
            return false;
        };
    

    手势正在移动时,将距离赋值给containerTop

    onPanResponderMove = (event, gestureState) => {
            const dy = Math.max(0, gestureState.dy);
            this.state.containerTop.setValue(dy);
        };
    
    

    做一个重置到初始化状态方法,从手势下拉的距离到0

    resetContainerPosition(duration = 250) {
            return new Promise((resolve, inject) => {
                Animated.timing(this.state.containerTop, {
                    toValue: 0,
                    duration,
                    useNativeDriver: true,
                }).start(() => {
                    resolve();
                });
            });
        }
    

    当手释放的时候会调用onPanResponderRelease,首先判断是否达到触发刷新的条件,释放时的下拉距离大于触发高度triggerHeight时,做一个回弹动画,执行下拉距离containerTop到头部刷新组件containHeight的动画,结束调用onPanRelease方法请求数据;如果没达到刷新位置,回退到顶部。

    onPanResponderRelease = (event, gestureState) => {
            // 判断是否达到了触发刷新的条件
            const dy = Math.max(0, gestureState.dy);
            const { containHeight,triggerHeight } = this.props;
            if (dy >= triggerHeight) {
                Animated.timing(this.state.containerTop, {
                    toValue: containHeight,
                    duration: 150,
                    useNativeDriver: true,
                }).start(({ finished }) => {
                    if (finished) {
                        this.props.onPanRelease();
                    }
                });
                return;
            }
            this.resetContainerPosition();
        };
    

    onPanResponderTerminate 如果中途手势由于某种原因被中断,则将回退到顶部

    onPanResponderTerminate = (event, gestureState) => {
            this.resetContainerPosition();
        };
    

    此方法在iOS效果还可以,但是在Android上, Touch与下拉手势相互冲突,导致一直被中断,onPanResponderTerminate方法会反复调用,怎么解决这个问题呢?

    2. react-native-pull-refresh

    经过调研,此库也是利用RN中的PanResponder加多种动画,来改变marginTop实现的,并可以解决android手势冲突问题,
    可以看到,此库增加了一层ScrollView,isScrollFree控制ScrollView滚动状态

    <View ...  {...this._panResponder.panHandlers} >
            ...
           <ScrollView ...  scrollEnabled={this.state.isScrollFree}>
                  <Animated.View style={{marginTop: animateHeight}}>
                      ...
                  </Animated.View>
      </ScrollView>
    </View>
    

    设置View成为响应者的前提条件是ScrollView不能滚动

     _handleStartShouldSetPanResponder(e, gestureState) {
        return !this.state.isScrollFree;
      }
    
      _handleMoveShouldSetPanResponder(e, gestureState) {
        return !this.state.isScrollFree;
      }
    

    当ScrollView按下结束onTouchEnd和滚动结束onScrollEndDrag,并ScrollView的滚动距离为0时,将ScrollView设置成不可滚动状态

     isScrolledToTop() {
        if (this.state.scrollY._value === 0 && this.state.isScrollFree) {
          this.setState({isScrollFree: false});
        }
      }
    <ScrollView
               ...
              onTouchEnd={() => {
                this.isScrolledToTop();
              }}
              onScrollEndDrag={() => {
                this.isScrolledToTop();
              }}>
            ...
      </ScrollView>
    

    手势释放以后,判断是否达到触发条件,refreshHeight小于pullHeight时达到触发条件,并判断ScrollView的滑动距离大于0时,将isScrollFree状态设置成true,ScrollView又重新成为响应者滚动。

    _handlePanResponderEnd(e, gestureState) {
        if (!this.props.isRefreshing) {
          if (this.state.refreshHeight._value <= -this.props.pullHeight) {
            this.onScrollRelease();
           // 这里是动画代码
          } 
          if (this.state.scrollY._value > 0) {
            this.setState({isScrollFree: true});
          }
        }
      }
    

    利用Animated.parallel同时启用多个动画完成手势释放后回到悬停位置,开始刷新,刷新成功后回到顶部的过程。

     Animated.parallel([
              Animated.spring(this.state.refreshHeight, {
                toValue: -this.props.pullHeight,
              }),
              Animated.timing(this.state.initAnimationProgress, {
                toValue: 1,
                duration: 1000,
              }),
            ]).start(() => {
              this.state.initAnimationProgress.setValue(0);
              this.setState({isRefreshAnimationStarted: true});
              this.onRepeatAnimation();
            });
    

    在这里需要注意,由于使用了ScrollView,所以导致上拉的分页数据一次性都请求出来了,此库的流畅度也欠佳。
    但是需求上是有上拉加载功能,我们可以利用Native层实现的方式来避免以上问题

    Native层实现方法

    通过调研native层实现方法,发现两个库react-native-SmartRefreshLayoutreact-native-MJRefresh,分别为Android和iOS

    1. iOS react-native-MJRefresh

    • ScrollView

    此库通过重写RN中ScrollView的OC原生库,在RCTMJScrollView.m文件中,原来只有默认RCTRefreshControl的判断,现在增加了MJRefresh自定义判断,代码如下:

    - (void)insertReactSubview:(UIView *)view atIndex:(NSInteger)atIndex
    {
         ...   
         #if !TARGET_OS_TV
         if ([view isKindOfClass:[RCTRefreshControl class]]) {
         [_scrollView setRctRefreshControl:(RCTRefreshControl *)view];
         } else if ([view isKindOfClass:[MJRefreshHeader class]]){ // 增加的判断
             _scrollView.mj_header = (MJRefreshHeader *)view;
         } else
         #endif
         ...
    }
    
    • MJRefresh 自定义header

    也是Native层实现的,RN中提供了很多下拉时的API,可以直接使用。

       _onMJRefresh=()=>{
            let {onRefresh} = this.props;
            onRefresh && onRefresh();
        }
        _onMJPulling ...
        finishRefresh...
        beginRefresh...
    
    • iOS遇到的问题:

    由于库本身已经有3年没有更新了,很多代码是比较老旧的

    1. MJScrollView.js文件中是继承于RN中的ScrollView,但是在RN的新版本上,ScrollView并不是一个class,它不能被继承, RN新版ScrollView源码片段如下:
    function Wrapper(props, ref) {
      return <ScrollView {...props} scrollViewRef={ref} />;
    }
    Wrapper.displayName = 'ScrollView';
    const ForwardedScrollView = React.forwardRef(Wrapper);
    

    解决方案: 将RN中的ScrollView源码copy一份,将RCTScrollView改成RCTMJScrollView(工程比较大)

    const RCTMJScrollView = requireNativeComponent('RCTMJScrollView', MJScrollView, {
      nativeOnly: {
        onMomentumScrollBegin: true,
        onMomentumScrollEnd : true,
        onScrollBeginDrag: true,
        onScrollEndDrag: true,
      }
    })
    
    1. RN新版本上面已经没有ListView,记得将导出的地方去掉

    2. Android的react-native-SmartRefreshLayout

    提供了很多headerRefresh样式,也可以利用AnyHeader自定义样式,有兴趣的小伙伴可以按照文档都试试

    截图6.png
    遇到的问题:没有实现自动refresh功能,以下为解决方案

    此android库使用了一个插件SmartRefreshLayout,已经实现了自动refresh功能,只是没有暴露出来。

    1. SmartRefreshLayoutManager.java 新增以下位置的代码
      增加beginRefresh变量
     private static final String COMMAND_FINISH_REFRESH_NAME="finishRefresh";
     //新增
     private static final String COMMAND_BEGIN_REFRESH_NAME="beginRefresh";
    
     private static final int COMMAND_FINISH_REFRESH_ID=0;
     //新增
     private static final int COMMAND_BEGIN_REFRESH_ID=1;
    
     @Nullable
     @Override
     public Map<String, Integer> getCommandsMap() {
            return MapBuilder.of(
                    COMMAND_FINISH_REFRESH_NAME,COMMAND_FINISH_REFRESH_ID,
                    COMMAND_BEGIN_REFRESH_NAME,COMMAND_BEGIN_REFRESH_ID//新增
            );
      }
    

    当commandId为COMMAND_BEGIN_REFRESH_ID时,执行autoRefresh()

    @Override
        public void receiveCommand(ReactSmartRefreshLayout root, int commandId, @Nullable ReadableArray args) {
            switch (commandId){
                case COMMAND_FINISH_REFRESH_ID:
                  ...
                //新增
                case COMMAND_BEGIN_REFRESH_ID:
                   if(!root.isRefreshing()){
                       root.autoRefresh();
                   }
                    break;
                default:break;
            }
        }
    
    
    1. SmartRefreshControl.js 新增beginRefresh方法,commandId传值为beginRefresh。这里注意如果只调用this.dispatchCommand('beginRefresh',[]),会导致android页面原地下拉,解决方法可以先将组件滚动到顶部,再执行刷新的方法。
        finishRefresh=({delayed=-1,success=true}={delayed:-1,success:true})=>{
            this.dispatchCommand('finishRefresh',[delayed,success])
        }
         //新增
        beginRefresh=()=>{
            // android需要将组件滚动到顶部,再执行开始刷新的方法
            const { scrollRef } = this.props;
            scrollRef?.current?.scrollTo({x: 0,y: 0,animated: false,});
            this.dispatchCommand('beginRefresh',[])
        }
    

    iOS和Android平台统一化处理

    由于是两个独立的库,在使用起来平台差异比较大,所以做了iOS和Android平台统一化处理。
    iOS使用MJRefresh,Android使用SmartRefreshControl,由于android需要头部刷新组件的高度,所以多传一个headerHeight。

        if (Platform.OS === 'ios') {
            return (
              <MJRefresh {...resProps} ref={ref}>
                {headerComponent}
              </MJRefresh>
            )
        } else {
            return (
              <SmartRefreshControl 
                    {...resProps} 
                    ref={ref}
                    headerHeight={headerHeight} 
                    renderHeader={(
                        <AnyHeader>
                            {headerComponent}
                        </AnyHeader>
                    )}/>
            )
        }
    

    使用方法:

    • FlatList & SectionList
      利用renderScrollComponent定制滚动组件,使用自定义的MJScrollView,refreshControl传入替换RefreshControl的下拉自定义刷新组件LSRefresh,触发主动刷新调用this.mjrefresh?.current?.beginRefresh()此方法。
    import React, { useCallback, useRef, useState } from "react";
    import { MJScrollView } from "react-native-MJRefresh";
    import  LSRefresh from "./LSRefresh";
    import { TouchableOpacity } from "react-native";
    const FlatListTest = () => {
     const mjrefresh = useRef(null);
      const scrollRef = useRef(null);
    
      const renderCard = useCallback(({ item, index }) => {
        return <Text>{`测试${index}`}</Text>;
      }, []);
      const headerComponent = useCallback(() => {
        return <View style={{ height: 80, backgroundColor: "red" }}>下拉刷新</View>;
      }, []);
      const onClickGoToTop = useCallback(() => {
          this.mjrefresh?.current?.beginRefresh()
      }, []);
      const renderScrollComponent = useCallback((props) => {
        return (
          <MJScrollView
            ref={ref}
            refreshControl={
              <LSRefresh
                ref={mjrefresh}
                scrollRef={scrollRef}
                headerHeight={80}
                onRefresh={onRefresh}
                headerComponent={headerComponent}
                {...props}
              />
            }
            {...props}
          />
        );
      }, []);
      return (
            <>
                <FlatList
                data={list}
                renderItem={renderCard}
                renderScrollComponent={renderScrollComponent}
                />
                <TouchableOpacity onPress={onClickGoToTop}>点击主动触发下拉刷新</TouchableOpacity>
            </>
      );
    };
    

    总结

    自定义下拉刷新的切入点是要改变RN的RefreshControl,这是关键,从Native入手,写一个自定义的ScrollView来实现,才能做到与原生一样的性能和流畅度。
    效果图:


    图片.gif

    作者:基因宝前端团队-小璇

    相关文章

      网友评论

        本文标题:打造React Native平台高性能下拉刷新组件

        本文链接:https://www.haomeiwen.com/subject/ogdlsltx.html