React Native 嵌套滚动三件套

在 App 开发中,嵌套滚动是一个很常见的需求。比如一个可纵向滚动的页面中有一个可横向滚动的列表,列表中的每一项又是一个可纵向滚动的列表。这种情况下,我们就需要用到嵌套滚动。

README-2023-04-18-17-43-21

譬如下拉刷新,向下滑动时,如果还没到顶部,列表内容滚动,如果已经到达顶部,则列表自身移动。

譬如 BottomSheet,向上滑动时,如果 BottomSheet 还没完全展开,则 BottomSheet 移动,如果已经完全展开,则 BottomSheet 里面的列表滚动。

在 React Native,实现嵌套滚动非常不容易。

通常有两种实现方式:

  1. 借助 react-native-gesture-handler 和 reanimated 来实现。

  2. 封装原生 UI 组件来实现。

本文分享作者和他的伙伴们(以下也称作我们)通过封装原生 UI 组件来实现嵌套滚动的成果。

因为一共有三个组件,在 Andriod 都是通过 NestedScrolling APIopen in new window 来实现的,并且可以配合使用,所以我们将这三个组件称作嵌套滚动三件套

NestedScrollView

NestedScrollViewopen in new window 用于实现题图的效果。

安装

yarn add @sdcx/nested-scroll

使用

NestedScrollView 在使用上比较简单,将可滚动列表作为子组件放入 NestedScrollView 即可,如下:

import { NestedScrollView, NestedScrollViewHeader } from '@sdcx/nested-scroll'

const App = () => {
  return (
    <NestedScrollView>
      <NestedScrollViewHeader stickyHeaderBeginIndex={1}>
        <Image />
        <TabBar />
      </NestedScrollViewHeader>
      <PagerView>
        <FlatList nestedScrollEnabled />
        <FlashList nestedScrollEnabled />
        <ScrollView nestedScrollEnabled />
        <WebView nestedScrollEnabled />
      </PagerView>
    </NestedScrollView>
  )
}

WARNING

注意为可滚动列表开启 nestedScrollEnabledopen in new window 属性

NestedScrollViewNestedScrollViewHeader 都只有两三个属性,使用起来非常简单。

想要 sticky header 效果,可以配置 NestedScrollViewHeader 如下两个属性之一:

  • stickyHeaderBeginIndex,它表示从第几个子组件开始,子组件将会被固定在顶部。

  • stickyHeight,它表示 header 多高的区域将会被固定在顶部,优先级高于 stickyHeaderBeginIndex

PullToRefresh

React Native 内置的下拉刷新组件比较简陋,且 iOS 和 Android 平台的表现很不一致。幸运的是,它提供了一个 refreshControl 属性,可以用来自定义下拉刷新组件。

我们封装了一个原生组件 -- PullToRefreshopen in new window,用来提供自定义下拉刷新的能力。

可以实现如下效果:

安装

yarn add @sdcx/pull-to-refresh

使用

PullToRefresh 在使用上主要有以下几个步骤:

WARNING

注意为可滚动列表开启 nestedScrollEnabledopen in new window 属性

  1. 使用 PullToRefresh 提供的 RefreshControl 组件设置 refreshControl 属性,并传递 refreshingonRefresh 属性。

    import { RefreshControl } from '@sdcx/pull-to-refresh'
    
    function App() {
      const [refreshing, setRefreshing] = useState(false)
    
      return (
        <FlatList
          nestedScrollEnabled
          refreshControl={
            <RefreshControl
              refreshing={refreshing}
              onRefresh={() => {
                setRefreshing(true)
                setTimeout(() => {
                  setRefreshing(false)
                }, 2000)
              }}
            />
          }
        />
      )
    }
    
  2. PullToRefresh 提供的默认下拉刷新样式并不适用于你的 App,你需要自定义下拉刷新样式。

    参考下面的例子即可:

    import {
      PullToRefreshHeader,
      PullToRefreshHeaderProps,
      PullToRefreshOffsetChangedEvent,
      PullToRefreshStateChangedEvent,
      PullToRefreshState,
      PullToRefreshStateIdle,
      PullToRefreshStateRefreshing,
    } from '@sdcx/pull-to-refresh'
    
    function LottiePullToRefreshHeader(props: PullToRefreshHeaderProps) {
      const [progress, setProgress] = useState(0)
      const lottieRef = useRef<Lottie>(null)
      const stateRef = useRef<PullToRefreshState>(PullToRefreshStateIdle)
    
      const onOffsetChanged = useCallback((event: PullToRefreshOffsetChangedEvent) => {
        const offset = event.nativeEvent.offset
        if (stateRef.current !== PullToRefreshStateRefreshing) {
          setProgress(Math.min(1, offset / 50))
        }
      }, [])
    
      const onStateChanged = useCallback(
        (event: PullToRefreshStateChangedEvent) => {
          const state = event.nativeEvent.state
          stateRef.current = state
          if (state === PullToRefreshStateIdle) {
            lottieRef.current?.reset()
            setProgress(0)
          } else if (state === PullToRefreshStateRefreshing) {
            lottieRef.current?.play(progress)
          } else {
            HapticFeedback.trigger('impactLight')
          }
        },
        [progress]
      )
    
      return (
        <PullToRefreshHeader
          style={styles.header}
          {...props}
          onOffsetChanged={onOffsetChanged}
          onStateChanged={onStateChanged}>
          <Lottie
            ref={lottieRef}
            style={{ height: 50 }}
            source={require('./square-loading.json')}
            autoPlay={false}
            speed={1}
            cacheStrategy={'strong'}
            loop
            progress={progress}
          />
        </PullToRefreshHeader>
      )
    }
    
  3. 设置你自定义的下拉刷新样式为默认样式。通常是在 APP 启动时设置:

    // index.js
    import { PullToRefresh } from '@sdcx/pull-to-refresh'
    import { LottiePullToRefreshHeader } from './LottiePullToRefreshHeader'
    
    PullToRefresh.setDefaultHeader(LottiePullToRefreshHeader)
    

此外,PullToRefresh 还支持上拉加载更多,使用方法和下拉刷新类似,具体参考它的文档open in new window

BottomSheet

BottomSheetopen in new window 是一个类似于 Android 原生的 BottomSheetBehavioropen in new window 组件,我们在 API 设计上也尽量和 Android 原生保持一致。

可以实现如下效果:

安装

yarn add @sdcx/bottom-sheet

使用

BottomSheet 在使用上是非常简单的,没什么心智负担。

import BottomSheet from '@sdcx/bottom-sheet'

const App = () => {
  return (
    <View style={styles.container}>
      <ScrollView>...</ScrollView>
      <BottomSheet peeekHeight={200}>
        {
          // 在这里放置你的内容,可以是任何组件,如:
        }
        <View />
        <PagerView>
          <FlatList nestedScrollEnabled />
          <ScrollView nestedScrollEnabled />
          <WebView nestedScrollEnabled />
        </PagerView>
      </BottomSheet>
    </View>
  )
}

WARNING

注意为可滚动列表开启 nestedScrollEnabledopen in new window 属性

如果你熟悉原生 Android 开发,那么对 BottomSheet 的 API 并不陌生。

属性

  • peekHeight, 是指 BottomSheet 收起时,在屏幕上露出的高度,默认是 200。

  • state, 是指 BottomSheet 的状态,有三种状态:

    • 'collapsed',收起状态,此时 BottomSheet 的高度为 peekHeight

    • 'expanded',展开状态,此时 BottomSheet 的高度为父组件的高度或内容的高度,参考 fitToContents 属性。

    • 'hidden',隐藏状态,此时 BottomSheet 的高度为 0。

  • fitToContents,是指 BottomSheet 在展开时,是否适应内容的高度,默认是 false

回调

  • onStateChanged, 是指 BottomSheet 状态变化时的回调,它和 state 属性是一对,用来实现受控模式。

    export type BottomSheetState = 'collapsed' | 'expanded' | 'hidden'
    
    export interface StateChangedEventData {
      state: BottomSheetState
    }
    
    interface NativeBottomSheetProps extends ViewProps {
      onStateChanged?: (event: NativeSyntheticEvent<StateChangedEventData>) => void
    }
    
  • onSlide, 是指 BottomSheet 滑动时的回调,可以用它来实现一些动画效果。

    export interface OffsetChangedEventData {
      progress: number // 是指 BottomSheet 滑动的进度,范围是 0 到 1
      offset: number // 是指 BottomSheet 滑动的距离,范围是 0 到 BottomSheet 的高度
      expandedOffset: number // 是指 BottomSheet 展开时的 `offset`
      collapsedOffset: number // 是指 BottomSheet 收起时的 `offset`
    }
    
    interface NativeBottomSheetProps extends ViewProps {
      onSlide?: (event: NativeSyntheticEvent<OffsetChangedEventData>) => void
    }
    

源码

希望我们的经验对你有所帮助,如果你对我们的源码open in new window感兴趣,记得给颗星星哦。

上次更新: