React Native ScrollView 嵌套下拉刷新中的冲突解决
问题场景
页面上有一个可滚动的布局,除此之外还有其他内容。希望对整个页面添加下拉刷新支持。RN 的 RefreshControl 组件需要在 ScrollView 等组件中使用,由此就形成了嵌套关系,场景核心代码示例如下。
此代码在 iOS 下运行无问题,在 Android 中会出现内部 ScrollView 滚动未到顶但触发外部下拉刷新的问题。
import {
RefreshControl,
ScrollView,
StyleSheet,
Text,
View,
} from "react-native";
<ScrollView
style={{ flex: 1 }}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={refresh} />
}
>
<View style={{ backgroundColor: "green", height: 150, width: "100%" }} />
<ScrollView style={{ width: "100%", height: 600 }} nestedScrollEnabled>
{content.map((item) => (
<View
key={item}
style={{
width: "100%",
padding: 10,
borderColor: "black",
borderWidth: 1,
}}
>
<Text>{item}</Text>
</View>
))}
</ScrollView>
<View style={{ backgroundColor: "blue", height: 400, width: "100%" }} />
</ScrollView>;
方案一
将相关组件改为从react-native-gesture-handler
进行导入,根布局使用GestureHandlerRootView
包裹。
Gesture handler library exposes a set of components normally available in React Native that are wrapped in
NativeViewGestureHandler
. Here is a list of exposed components:
ScrollView
FlatList
Switch
TextInput
DrawerLayoutAndroid
(Android only)
这一系列组件使用原生手势识别,效果与 RN 自带的略有不同。此方案下内部 ScrollView 可自由滚动,但滚到顶部时,下拉无法触发外部的 RefreshControl 下拉刷新,只有在外层 ScrollView 区域滑动才生效。
import {
GestureHandlerRootView,
RefreshControl,
ScrollView,
} from "react-native-gesture-handler";
方案二
(后续方案仍旧基于从 RN 导出的组件)
监听内部 ScrollView 的 onScroll 事件,当 offsetY<=0 时,将外部 RefreshControl 的 enabled 属性设为 true。
这种方案没有第一种方案的问题,但内部滚动到顶时无法不中断手势继续下拉,需要松手再下拉才能触发刷新。
且此方案无法应对更复杂的场景,例如内部的 ScrollView 替换成 FlatList horizontal pagingEnabled,内部嵌套多个纵向的 ScrollView,此时这些 ScrollView 的 offsetY 不一定是一致的,因此无法处理这种交互。
const [refreshEnabled, setRefreshEnabled] = useState(true);
const onScroll = useCallback(
(event: NativeSyntheticEvent<NativeScrollEvent>) => {
const offsetY = event.nativeEvent.contentOffset.y;
setRefreshEnabled(offsetY <= 0);
},
[]
);
return (
<View style={styles.container}>
<ScrollView
style={{ flex: 1 }}
refreshControl={
<RefreshControl
enabled={refreshEnabled}
refreshing={isRefreshing}
onRefresh={refresh}
/>
}
>
<View style={{ backgroundColor: "green", height: 150, width: "100%" }} />
<ScrollView
style={{ width: "100%", height: 600 }}
nestedScrollEnabled
onScroll={onScroll}
>
{content.map((item) => (
<View
key={item}
style={{
width: "100%",
padding: 10,
borderColor: "black",
borderWidth: 1,
}}
>
<Text>{item}</Text>
</View>
))}
</ScrollView>
{/* <View style={{ backgroundColor: 'blue', height: 400, width: '100%' }} /> */}
</ScrollView>
</View>
);
方案三
通过 RN devtools 我们知道 RefreshControl 是以父组件的形式包裹了内部的 ScrollView 等组件,我们本应该传给内部的触摸事件被其 intercept,因此出现冲突。

通过查看 RN Android 中 SwipeRefreshLayout 的实现,我们可以看到以下关键代码:
public override fun canChildScrollUp(): Boolean {
val firstChild = getChildAt(0)
return firstChild?.canScrollVertically(-1) ?: super.canChildScrollUp()
}
在 SwipeRefreshLayout 的 onInterceptTouchEvent 中,会调用 canChildScrollUp 判断是否拦截事件:
@Override
public boolean onInterceptTouchEvent(MotionEvent ev) {
ensureTarget();
final int action = ev.getActionMasked();
int pointerIndex;
if (mReturningToStart && action == MotionEvent.ACTION_DOWN) {
mReturningToStart = false;
}
if (!isEnabled() || mReturningToStart || canChildScrollUp()
|| mRefreshing || mNestedScrollInProgress) {
// Fail fast if we're not in a state where a swipe is possible
return false;
}
根据上述逻辑,可知当 RefreshControl 的第一个子元素可向上滚动(即 offsetY>0)时,事件不会被拦截。而我们的场景下,第一个子元素 green View 的 offsetY 为 0,所以不论内部 ScrollView 滚到哪里,都会被判定为不可向上滚动,手势被 RefreshControl 拦截产生冲突。
基于此的一种比较取巧的方案是,当内部 ScrollView 滚动时,如果外部的 offsetY 为 0,将其改为 1,这样让 canChildScrollUp() == true。但这样就要求外部 ScrollView 内容高度>屏幕高度。
const [isRefreshing, setIsRefreshing] = useState(false);
const scrollViewRef = useRef<ScrollView>(null);
const [outerScrollY, setOuterScrollY] = useState(0);
<ScrollView
ref={scrollViewRef}
style={{ flex: 1 }}
refreshControl={
<RefreshControl refreshing={isRefreshing} onRefresh={refresh} />
}
onScroll={(event) => {
setOuterScrollY(event.nativeEvent.contentOffset.y);
}}
>
<View style={{ backgroundColor: "green", height: 150, width: "100%" }} />
<ScrollView
style={{ width: "100%", height: 600 }}
nestedScrollEnabled
onScroll={(event) => {
const { contentOffset } = event.nativeEvent;
if (contentOffset.y > 0 && outerScrollY === 0) {
scrollViewRef.current?.scrollTo({ y: 1, animated: false });
}
}}
>
{content.map((item) => (
<View
key={item}
style={{
width: "100%",
padding: 10,
borderColor: "black",
borderWidth: 1,
}}
>
<Text>{item}</Text>
</View>
))}
</ScrollView>
<View style={{ backgroundColor: "blue", height: 400, width: "100%" }} />
</ScrollView>;
方案四
基于方案三的分析,这个冲突涉及到原生代码中的逻辑,而 RN 并没有提供相关接口来修改onInterceptTouchEvent
的逻辑,所以如果要实现 Android 上的完美效果,最后的方案是自己实现一个原生组件并引入使用。参考:https://blog.csdn.net/Ever69/article/details/104315253