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

背景

基因宝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 (
            
                
                    {child}
                
            
        );
    }

当外层向下滚动距离小于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成为响应者的前提条件是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});
    }
  }
 {
            this.isScrolledToTop();
          }}
          onScrollEndDrag={() => {
            this.isScrolledToTop();
          }}>
        ...
  

手势释放以后,判断是否达到触发条件,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-SmartRefreshLayout、react-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 ;
}
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 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 (
          
            {headerComponent}
          
        )
    } else {
        return (
          
                        {headerComponent}
                    
                )}/>
        )
    }

使用方法:

  • 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 {`测试${index}`};
  }, []);
  const headerComponent = useCallback(() => {
    return 下拉刷新;
  }, []);
  const onClickGoToTop = useCallback(() => {
      this.mjrefresh?.current?.beginRefresh()
  }, []);
  const renderScrollComponent = useCallback((props) => {
    return (
      
        }
        {...props}
      />
    );
  }, []);
  return (
        <>
            
            点击主动触发下拉刷新
        
  );
};

总结

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


图片.gif

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

你可能感兴趣的:(打造React Native平台高性能下拉刷新组件)