打造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 (
            <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

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

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 158,425评论 4 361
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 67,058评论 1 291
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 108,186评论 0 243
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 43,848评论 0 204
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 52,249评论 3 286
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 40,554评论 1 216
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 31,830评论 2 312
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 30,536评论 0 197
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 34,239评论 1 241
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 30,505评论 2 244
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 32,004评论 1 258
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 28,346评论 2 253
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 32,999评论 3 235
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 26,060评论 0 8
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 26,821评论 0 194
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 35,574评论 2 271
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 35,480评论 2 267