背景
基因宝App在大量页面中提供了下拉刷新功能,方便用户实时获取最新数据。在提升用户交互体验的同时,也存在了一些较为明显的问题,例如,平台差异化严重,性能较差,可定制性低,主动刷新机制缺乏等。
为提升用户体验,实现高性能、多端交互一致性,近期,在原有基础上,我们对下拉刷新组件做了一次大升级。
RN中提供了RefreshControl,但是iOS和Android默认样式不统一,不能进行修改。
这就需要我们自定义下拉刷新组件
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年没有更新了,很多代码是比较老旧的
- 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,
}
})
- RN新版本上面已经没有ListView,记得将导出的地方去掉
2. Android的react-native-SmartRefreshLayout:
提供了很多headerRefresh样式,也可以利用AnyHeader自定义样式,有兴趣的小伙伴可以按照文档都试试
遇到的问题:没有实现自动refresh功能,以下为解决方案
此android库使用了一个插件SmartRefreshLayout,已经实现了自动refresh功能,只是没有暴露出来。
- 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;
}
}
- 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来实现,才能做到与原生一样的性能和流畅度。
效果图:
作者:基因宝前端团队-小璇