Skip to content

React Native ScrollView 嵌套下拉刷新中的冲突解决

问题场景

页面上有一个可滚动的布局,除此之外还有其他内容。希望对整个页面添加下拉刷新支持。RN 的 RefreshControl 组件需要在 ScrollView 等组件中使用,由此就形成了嵌套关系,场景核心代码示例如下。

此代码在 iOS 下运行无问题,在 Android 中会出现内部 ScrollView 滚动未到顶但触发外部下拉刷新的问题。

tsx
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包裹。

https://docs.swmansion.com/react-native-gesture-handler/docs/gesture-handlers/about-handlers/#using-native-components

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 区域滑动才生效。

tsx
import {
  GestureHandlerRootView,
  RefreshControl,
  ScrollView,
} from "react-native-gesture-handler";

方案二

(后续方案仍旧基于从 RN 导出的组件)

监听内部 ScrollView 的 onScroll 事件,当 offsetY<=0 时,将外部 RefreshControl 的 enabled 属性设为 true。

这种方案没有第一种方案的问题,但内部滚动到顶时无法不中断手势继续下拉,需要松手再下拉才能触发刷新。

且此方案无法应对更复杂的场景,例如内部的 ScrollView 替换成 FlatList horizontal pagingEnabled,内部嵌套多个纵向的 ScrollView,此时这些 ScrollView 的 offsetY 不一定是一致的,因此无法处理这种交互。

tsx
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,因此出现冲突。

image-20250812101419464

通过查看 RN Android 中 SwipeRefreshLayout 的实现,我们可以看到以下关键代码:

https://github.com/facebook/react-native/blob/main/packages/react-native/ReactAndroid/src/main/java/com/facebook/react/views/swiperefresh/ReactSwipeRefreshLayout.kt#L67

kotlin
public override fun canChildScrollUp(): Boolean {
  val firstChild = getChildAt(0)
  return firstChild?.canScrollVertically(-1) ?: super.canChildScrollUp()
}

在 SwipeRefreshLayout 的 onInterceptTouchEvent 中,会调用 canChildScrollUp 判断是否拦截事件:

https://cs.android.com/androidx/platform/frameworks/support/+/androidx-main:swiperefreshlayout/swiperefreshlayout/src/main/java/androidx/swiperefreshlayout/widget/SwipeRefreshLayout.java;l=750;drc=df4234ef96b23c6d3725ce81dd72cd30630fd4d2

java
@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 内容高度>屏幕高度。

tsx
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

示例代码仓库

https://github.com/klxiaoniu/RNScrollTest

上次更新于: