如何在 React Native 中,为 PagerView 自定义 TabBar

本文手把手教你自定义一个丝滑的 TabBar,让你在面对同类需求时,游刃有余,效果如下:

本文将使用到 React Native Animated APIopen in new window,如果你不太熟悉,可能需要先了解下,这里有几篇不错的文章:

基本结构

我们先来看看页面基本结构:

import PagerView from 'react-native-pager-view'

const langs = ['JavaScript', 'Golang', 'Objective-C']

function TabBarDemo() {
  return (
    <View style={styles.container}>
      <TabBar style={styles.tabbar} />
      <PagerView
        onPageScroll={onPageScroll}
        onPageSelected={onPageSelected}
        onPageScrollStateChanged={onPageScrollStateChanged}>
        {langs.map((lang) => (
          <LangPage key={lang} language={lang} />
        ))}
      </PagerView>
    </View>
  )
}

PagerView

PagerView 有三个比较重要的回调:

  • onPageScroll 当以手动或编程方式切换页面时,告知当前所在页面以及页面偏移量。

  • onPageSelected 一旦 PagerView 完成了对所选页面的导航,会触发此回调,告知当前所在页面。

  • onPageScrollStateChanged 告知 PagerView 的滚动状态,draggingsettlingidle

以上这三个回调在 iOS 和 Andriod 平台上的调用时机略有差异,这使得我们需要做一些适配工作。

LangPage

LangPage 就是一个个可切换的页面,它是非常简单的

// LangPage.tsx
export default function LangPage({ language }: LangPageProps) {
  return (
    <View style={styles.container}>
      <Text style={styles.text}>{language}</Text>
    </View>
  )
}

const styles = StyleSheet.create({
  container: {
    width: '100%',
    height: '100%',
    alignItems: 'center',
  },
})











 
 



这里根据 react-native-pager-view Known Issuesopen in new window 的提示,将页面宽高都设置成 100%,但好像设置成 flex: 1 也没什么问题。

TabBar

TabBar 是本文的主角,它的初始代码如下:

// TabBar.tsx
import TabBarItem from './TabBarItem'

export default function TabBar() {
  return (
    <View style={[styles.tabbar, style]}>
      {tabs.map((tab: string, index: number) => {
        return <TabBarItem key={tab} title={tab} />
      })}
    </View>
  )
}

它使用到的 TabBarItem 代码也很简单

TabBarItem 也称作 Tab,本文使用 Tab 指代 TabBarItem。

// TabBarItem.tsx
export default function TabBarItem() {
  return (
    <Pressable onPress={onPress} style={[styles.tab, style]}>
      <Text style={[styles.label, labelStyle]}>{title}</Text>
    </Pressable>
  )
}

现在页面长下面这个样子

关联 TabBar 和 PagerView

PagerView 需要回调函数,而 TabBar 需要能够从 PagerView 的回调函数中获得数据。这是一个较为通用的逻辑。

我们封装一个可复用的 React Hook,把 PagerView 和 TabBar 相关联的逻辑写在一起,并为它们提供各自所需的东西,譬如,为 PagerView 提供回调,为 TabBar 提供数据。

现在它看起来比较简单,但随着本文的展开,它会变得完善起来。

// usePagerView.ts
import type { default as PagerView } from 'react-native-pager-view'

export default function usePagerView() {
  const pagerRef = useRef<PagerView>(null)

  const setPage = useCallback((page: number, animated = true) => {
    if (animated) {
      pagerRef.current?.setPage(page)
    } else {
      pagerRef.current?.setPageWithoutAnimation(page)
    }
    console.log(time() + ' setPage', page)
  }, [])

  return {
    pagerRef,
    setPage,
  }
}

usePagerView 提供了一个 ref 来引用 PagerView,一个函数来以编程方式切换页面。

修改 TabBarDemo,将 pagerRef 传递给 PagerView,将 setPage 传递给 TabBar。

function TabBarDemo() {
  const { pagerRef, setPage } = usePagerView()

  return (
    <View style={styles.container}>
      <TabBar tabs={langs} onTabPress={setPage} />
      <PagerView ref={pagerRef}>
        {langs.map((lang) => (
          <LangPage key={lang} language={lang} />
        ))}
      </PagerView>
    </View>
  )
}

 



 
 







TabBar 在调用 setPage 时,传入 Tab 的索引。

// TabBar.tsx
export default function TabBar({ onTabPress }) {
  return (
    <View style={[styles.tabbar, style]}>
      {tabs.map((tab: string, index: number) => {
        return (
          <TabBarItem key={tab} title={tab} onPress={() => onTabPress(index)} />
        )
      })}
    </View>
  )
}






 





现在点击 Tab 就会切换到相应的页面。

指示器

我们需要一个指示器来表示当前正处于哪个页面,哪个位置。它实质是对 PagerView 的 onPageScroll 事件的响应。

指示器的初始代码如下:

// TabBarIndicator.tsx
interface TabBarIndicatorProps {
  style: StyleProp<ViewStyle>
  scrollX: Animated.AnimatedInterpolation
}

export default function TabBarIndicator({
  style,
  scrollX,
}: TabBarIndicatorProps) {
  return (
    <Animated.View
      key={'indicator'}
      style={[
        styles.indicator,
        style,
        { transform: [{ translateX: scrollX }] },
      ]}
    />
  )
}

const styles = StyleSheet.create({
  indicator: {
    position: 'absolute',
    left: 0,
    bottom: 0,
    width: 24,
    height: 4,
    backgroundColor: '#448AFF',
    borderRadius: 2,
  },
})

TabBarIndicator 的布局方式是绝对定位,然后根据 scrollX 的值来动态调整位置。scrollX 是个动画值,它映射到 onPageScroll 事件的 positionoffset 属性。

提供动画值

修改 usePagerView,让它提供 onPageScroll 回调函数 和 positionoffset 这两个动画值。

// usePagerView.ts
export default function usePagerView() {
  const position = useRef(new Animated.Value(0)).current
  const offset = useRef(new Animated.Value(0)).current

  const onPageScroll = useMemo(
    () =>
      Animated.event<PagerViewOnPageScrollEventData>(
        [
          {
            nativeEvent: {
              position: position,
              offset: offset,
            },
          },
        ],
        {
          listener: ({ nativeEvent: { position, offset } }) => {
            console.log(time() + ' onPageScroll', position, offset)
          },
          useNativeDriver: true,
        }
      ),
    [position, offset]
  )

  return {
    pagerRef,
    setPage,
    position,
    offset,
    onPageScroll,
  }
}

onPageScroll 没有像下面这样使用普通的函数来实现:

const onPageScroll = useCallback(
  (event: NativeSyntheticEvent<PagerViewOnPageScrollEventData>) => {
    position.setValue(event.nativeEvent.position)
    offset.setValue(event.nativeEvent.offset)
  },
  [offset, position]
)

而是使用了 Animated.event() 这个辅助函数,将动画值映射到事件值上。在这里,会把 positionoffset 这两个动画值映射到 onPageScroll 事件的 positionoffset 属性上。

由于设置了 useNativeDrivertrue,这个映射背后并不会调用 positionoffset 动画值的 setValue 方法,而是以一种优化的方式,在 UI 主线程完成绑定。这就使得动画是同步跟随手势的。

修改 TabBarDemo,将 onPageScroll 函数挂载到 PagerView 的 onPageScroll 事件钩子上,将 positionoffset 这两个动画值传递给 TabBar。

因为 onPageScroll 使用了一个 Animated 事件,普通组件无法理解 Animated 相关 API,需要使用 Animated.createAnimatedComponent() 将 PagerView 变成动画组件。

const AnimatedPagerView = Animated.createAnimatedComponent<typeof PagerView>(PagerView)

function TabBarDemo() {
  const { onPageScroll, position, offset } = usePagerView()

  return (
    <View style={styles.container}>
      <TabBar position={position} offset={offset} />
      <AnimatedPagerView onPageScroll={onPageScroll}>
        {langs.map((lang) => (
          <LangPage key={lang} language={lang} />
        ))}
      </AnimatedPagerView>
    </View>
  )
}
 






 
 



 



计算 scrollX

接下来就是艰难的部分了。我们需要在 TabBar 中,把 positionoffset 映射成 scrollX

// TabBar.tsx
const scrollX = Animated.add(position, offset).interpolate({
  inputRange,
  outputRange,
})

position 是个整数,表示当前页面,它的取值范围就是 Tab 索引的集合。

offset 表示页面偏移量,它的取值范围是 [0, 1]

Animated.add(position, offset) 的取值范围是 [0, 最大 Tab 索引]

scrollX 是指示器在 x 轴的偏移量,由 interpolate 映射得到。

interpolate 用于将指定输入范围的数据按照某种曲线映射到指定输出范围的数据上。

首先确定输入范围 inputRange,其实就是 Tab 索引的集合。

// TabBar.tsx
const inputRange = useMemo(() => tabs.map((_, index) => index), [tabs])

接下来计算输出范围 outputRange,其实就是指示器在 x 轴偏移量的集合,一个 Tab 索引对应一个 x 轴偏移量。

// 将每个 Tab 对应的指示器 x 轴偏移量都初始化为 0
const [outputRange, setOutputRange] = useState(inputRange.map(() => 0))
// 获取指示器的宽度,这个值一般是固定的
const indicatorWidth = getIndicatorWidth(indicatorStyle)
// 存放每个 Tab 的位置和大小
const layouts = useRef<Layout[]>([]).current

const handleTabLayout = useCallback(
  (index: number, layout: Layout) => {
    layouts[index] = layout

    const length = layouts.filter((layout) => layout.width > 0).length
    if (length !== tabs.length) {
      // 等所有 Tab 都已经测量完成,再更新 `outputRange`
      return
    }

    const range: number[] = []
    for (let index = 0; index < length; index++) {
      const { x, width } = layouts[index]
      // 我们希望指示器和所选 Tab 垂直居中对齐
      // 那么指示器的 x 轴偏移量就是 Tab 的 center.x - 指示器的 center.x
      const tabCenterX = x + width / 2
      const indicatorCenterX = indicatorWidth / 2
      range.push(tabCenterX - indicatorCenterX)
    }
    console.log('---------------onTabLayout-------------------')
    setOutputRange(range)
  },
  [tabs, layouts, indicatorWidth]
)

注册事件

把上面的 handleTabLayout 函数注册到 Tab 的 onLayout 事件钩子上。

export default function TabBar() {
  return (
    <View style={[styles.tabbar, style]}>
      {tabs.map((tab: string, index: number) => {
        return (
          <TabBarItem
            key={tab}
            title={tab}
            onLayout={(event) =>
              handleTabLayout(index, event.nativeEvent.layout)
            }
          />
        )
      })}
      <TabBarIndicator
        style={[styles.indicator, indicatorStyle]}
        scrollX={scrollX}
      />
    </View>
  )
}

至此,指示器就可以正常工作了。

Tab 的平滑过渡

假如我们并不仅仅满足于指示器,我们希望选中页面和未选中页面的 Tab 在样式上有所区分。

基本实现

首先,我们需要让 TabBar 知道,选中的页面是哪个。

修改 usePagerView,提供 onPageSelected 回调函数和 page 状态变量。

// usePagerView.tsx
export default function usePagerView(initialPage = 0) {
  const [activePage, setActivePage] = useState(initialPage)
  const setPage = useCallback((page: number, animated = true) => {
    if (animated) {
      pagerRef.current?.setPage(page)
    } else {
      pagerRef.current?.setPageWithoutAnimation(page)
    }
    console.log(time() + " setPage", page)
    setActivePage(page)
  }, [])

  const onPageSelected = useCallback((e: PagerViewOnPageSelectedEvent) => {
    console.log(time() + " onPageSelected", e.nativeEvent.position)
    setActivePage(e.nativeEvent.position)
  }, [])

  return {
    setPage,
    page: activePage,
    onPageSelected,
  }
}


 







 




 








上述代码中,为什么要在 setPage 中也调用 setActivePage 呢?

这主要是因为 onPageSelected 在两个平台上的调用时机不同。在 Android 平台,onPageSelectedsetPage 之后马上触发。而在 iOS 平台,调用 setPage 之后,要等到动画结束,onPageSelected 才会被调用。

setPage 中调用 setActivePage,一方面能让 TabBar 尽早知道当前选中的页面,另一方面也统了两个平台的行为。

修改 TabBarDemo,将 onPageSelected 回调函数挂载到 PagerView 的 onPageSelected 事件钩子上,将 page 状态变量传递给 TabBar。

function TabBarDemo() {
  const { page, onPageSelected } = usePagerView()

  return (
    <View style={styles.container}>
      <TabBar style={styles.tabbar} tabs={langs} page={page} />
      <AnimatedPagerView style={styles.pager} onPageSelected={onPageSelected}>
        {langs.map((lang) => (
          <LangPage key={lang} language={lang} />
        ))}
      </AnimatedPagerView>
    </View>
  )
}





 
 







修改 TabBar 代码,给选中的和未选中的 Tab 设置不同的样式。

export default function TabBar({ tabs, page }) {
  return (
    <View style={[styles.tabbar, style]}>
      {tabs.map((tab: string, index: number) => {
        const isActive = index === page
        const opacity = isActive ? 1 : 0.8
        const scale = isActive ? 1.2 : 1

        return (
          <TabBarItem
            key={tab}
            title={tab}
            style={[tabStyle, { marginLeft: index === 0 ? 0 : spacing }]}
            labelStyle={[labelStyle, { opacity, transform: [{ scale }] }]}
          />
        )
      })}
      <TabBarIndicator scrollX={scrollX} />
    </View>
  )
}





 
 






 







现在 Tab 的过渡效果有些生硬,这是因为没有过渡动画。

添加过渡动画

首先让 Tab 支持动画,使用 Animated.Text 替换 Text

export default function TabBarItem() {
  return (
    <Pressable style={[styles.tab, style]} onPress={onPress} onLayout={onLayout}>
      <Animated.Text style={[styles.label, labelStyle]}>{title}</Animated.Text>
    </Pressable>
  )
}



 



当前选中的 Tab 和上次选中的 Tab 都是需要设置过渡动画的。

page 就是当前选中的 Tab 的索引。那么上次选中的 Tab 的索引就是 page 的上一个值,我们需要在恰当的时机把这个值记录下来。而这个时机就是 PagerView 处于 idle 状态时。

修改 usePagerView 代码,添加一个 isIdle 状态变量。

export default function usePagerView(initialPage = 0) {
  const [activePage, setActivePage] = useState(initialPage)
  const [isIdle, setIdle] = useState(true)

  const setPage = useCallback(
    (page: number, animated = true) => {
      console.log(time() + " setPage", page)
      setActivePage(page)
      if (activePage !== page) {
        setIdle(false)
      }
    },
    [activePage]
  )

  const onPageSelected = useCallback((e: PagerViewOnPageSelectedEvent) => {
    console.log(time() + " onPageSelected", e.nativeEvent.position)
    setActivePage(e.nativeEvent.position)
    if (Platform.OS === "ios") {
      setIdle(true)
    }
  }, [])


  const onPageScrollStateChanged = useCallback(
    ({ nativeEvent: { pageScrollState } }: PageScrollStateChangedNativeEvent) => {
      console.log(time() + " onPageScrollStateChanged", pageScrollState)
      setIdle(pageScrollState === "idle")
    },
    []
  )

  return {
    isIdle,
    onPageScrollStateChanged,
  }
}


 





 
 
 







 
 
 





 










当调用 setPage 切换页面时,我们就认为 PagerView 处于非空闲状态。

而设置为空闲状态的时机,则因为 PagerView 的回调事件在不同平台的触发时机不同而不同。

  • 在 Android 平台,onPageScrollStateChanged 事件会在页面切换动画完成后触发。显然,这是设置为空闲状态的最佳时机。

  • 在 iOS 平台,当通过编程方式,也就是调用 setPage 切换页面时,onPageScrollStateChanged 事件并不会被触发。幸运的是,在页面切换动画完成后,onPageSelected 事件会被触发,因此 iOS 在这里设置为空闲状态。

  • 但在 Android 平台,onPageSelected 事件在调用 setPage 之后,会立即触发,然后播放页面切换动画。显然不是设置为空闲状态的时机。

现在我们知道 PagerView 是否处于 idle 状态了,让我们来记录 page 的上一个值。

export default function TabBar({ tabs, page, idle }) {
  const lastPage = useLastPage(page, idle)
}

function useLastPage(page: number, idle: boolean) {
  const lastPageRef = useRef(0)

  useEffect(() => {
    if (idle) {
      lastPageRef.current = page
    }
  }, [idle, page])

  return lastPageRef.current
}

 













有了 pagelastPage,可以开始编写过渡动画了。

// TabBar.tsx
export default function TabBar() {
  const lastPage = useLastPage(page, isIdle)

  return (
    <View style={[styles.tabbar, style]}>
      {tabs.map((tab: string, index: number) => {
        const enhanced = index === page || index === lastPage

        const scale = Animated.add(offset, position).interpolate({
          inputRange: [index - 1, index, index + 1],
          outputRange: [1, enhanced ? 1.2 : 1, 1],
          extrapolate: 'clamp',
        })

        const opacity = Animated.add(offset, position).interpolate({
          inputRange: [index - 1, index, index + 1],
          outputRange: [0.8, enhanced ? 1 : 0.8, 0.8],
          extrapolate: 'clamp',
        })

        return (
          <TabBarItem
            style={[tabStyle, { marginLeft: index === 0 ? 0 : spacing }]}
            // @ts-ignore
            labelStyle={[labelStyle, { opacity, transform: [{ scale }] }]}
          />
        )
      })}
      <TabBarIndicator scrollX={scrollX} />
    </View>
  )
}

动画原理

我们讲解下如下关键代码是什么意思

const enhanced = index === page || index === lastPage

const scale = Animated.add(offset, position).interpolate({
  inputRange: [index - 1, index, index + 1],
  outputRange: [1, enhanced ? 1.2 : 1, 1],
  extrapolate: 'clamp',
})

首先要明白,这段代码是在一个循环里的,每个 Tab 都有它自己的动画。

因此要保证每个 Tab 的动画都不会影响到其他的 Tab。

position 是个整数,表示当前页面,它的取值范围就是 Tab 索引的集合。

offset 表示页面偏移量,它的取值范围是 [0, 1]

Animated.add(offset, position) 的取值范围是 [0, 最大 Tab 索引]

inputRange: [index - 1, index, index + 1] 配合 extrapolate: "clamp" 使用,非常有意思。保证了每个 Tab 的动画都不会影响到其他的 Tab。

假设当前处于页面 1 lastPage === 1,用户点击 Tab 往页面 2 page === 2 移动。

那么 position 的值是 1,offset 的值是 0.x,它俩的和是 1.x。

1.x 命中了索引为 1 的 Tab 的 inputRange: [0, 1, 2] ,根据 outputRange: [1, 1.2, 1] 设定, Tab 的 scale 值介于 1.2 和 1 之间,趋向缩小。

1.x 命中了索引为 2 的 Tab 的 inputRange: [1, 2, 3] ,根据 outputRange: [1, 1.2, 1] 设定,Tab 的 scale 值介于 1 和 1.2 之间,趋向放大。

1.x 没有命中索引为 3 的 Tab 的 inputRange: [2, 3, 4] , 1.x 在 2 的左边,由于设置了 extrapolate: "clamp",Tab 的 scale 值为 outputRange: [1, 1, 1] 最左边的值,也就是 1。

处理 iOS 跳页问题

现在点击 Tab,Android 可以优雅地工作了,但是 iOS 还存在一些问题。

当点击相邻的 Tab 时,iOS 也可以正常地工作,但是当点击相邻较远的 Tab 时,前一个 Tab 没有过渡效果,显得比较突兀。如图,在 JavaScript 和 Golang 之间切换,一切正常,但在 JavaScript 和 Objective-C 之间切换,就有些问题了。

查看 Android 的日志,从 JavaScript 切换到 Objective-C 时,position 会有比较好的过渡。

tabbar-2022-07-17-17-53-05

但是在 iOS 平台上,从 JavaScript 切换到 Objective-C 时,直接跳过了 position 为 0 的情况,导致没有了过渡效果。

tabbar-2022-07-17-17-57-02

针对这一情况,把缺失的动画补回来便可。

// TabBar.tsx
const enhanced = index === page || index === lastPage

let scale =  Animated.add(...)
let opacity = Animated.add(...)

if (Platform.OS === "ios" && Math.abs(page - lastPage) > 1 && index === lastPage) {
  scale = Animated.add(offset, position).interpolate({
    inputRange: [page - 1, page, page + 1],
    outputRange: [1.2, 1, 1.2],
    extrapolate: "clamp",
  })

  opacity = Animated.add(offset, position).interpolate({
    inputRange: [page - 1, page, page + 1],
    outputRange: [1, 0.8, 1],
    extrapolate: "clamp",
  })
}

处理手动切换页面的问题

以上都是通过点击 Tab,也就是编程方式,来进行页面切换,但如果是手动方式,也就是拖拽页面的方式呢?

PagerView 提供了 onPageScrollStateChanged 事件来通知页面滚动状态 (draggingsettlingidle),但在 iOS 和 Android 平台上有所差异。iOS 只有在手动方式下,才会触发该事件,Android 在编程方式下也会触发该事件。幸运的是,Android 只有在手动方式下,才会产生 dragging 状态。

修改 usePagerView,暴露 scrollState 状态。

// usePagerView.ts
export default function usePagerView(initialPage = 0) {
  const [scrollState, setScrollState] = useState<PageScrollState>("idle")

  const onPageScrollStateChanged = useCallback(
    ({ nativeEvent: { pageScrollState } }: PageScrollStateChangedNativeEvent) => {
      console.log(time() + " onPageScrollStateChanged", pageScrollState)
      setScrollState(pageScrollState)
      setIdle(pageScrollState === "idle")
    },
    []
  )
}

return {
  scrollState,
  onPageScrollStateChanged,
}


 




 










由于平台差异,我们先定义什么是手动中

  • 只要通过拖拽页面进入 dragging 状态,就被判定为手动中。

  • 对于 Android 平台,只要进入 idle 状态,就被判定为非手动中。

  • 对于 iOS 平台,在接收到 idle 状态时,只有上一个状态是 settling,才允许判定为非手动中。此外,需要将 PageView 的 overdrag 属性设置为 true,以防止只有 settling 没有 idle 的情况。

// TabBar.tsx
function useInteractive(scrollState: 'idle' | 'dragging' | 'settling') {
  const interactiveRef = useRef(false)
  const scrollStateRef = useRef<'idle' | 'dragging' | 'settling'>(scrollState)

  useEffect(() => {
    scrollStateRef.current = scrollState
  }, [scrollState])

  if (scrollState === 'dragging') {
    interactiveRef.current = true
  }

  if (
    scrollState === 'idle' &&
    // 防止 overdrag 时,读到过期的 idle 回调信息
    (Platform.OS === 'android' || scrollStateRef.current === 'settling')
  ) {
    interactiveRef.current = false
  }

  return interactiveRef.current
}

修改 TabBarDemo,设置 PagerView 的 overdrag 属性为 true

function TabBarDemo() {
  return (
    <View style={styles.container}>
      <TabBar style={styles.tabbar} />
      <AnimatedPagerView
        ref={pagerRef}
        style={styles.pager}
        overdrag={true}
        overScrollMode="always">
        {langs.map((lang) => (
          <LangPage key={lang} language={lang} />
        ))}
      </AnimatedPagerView>
    </View>
  )
}







 
 







接下来,只需要稍稍改动下 TabBar,就可以在手动切换页面时,让 Tab 的样式平滑地过渡了。

export default function TabBar() {
  const lastPage = useLastPage(page, isIdle)
  const interactive = useInteractive(scrollState)

  return (
    <View style={[styles.tabbar, style]}>
      {tabs.map((tab: string, index: number) => {
        const enhanced = interactive || index === page || index === lastPage

        return (<TabBarItem />)
      })}
      <TabBarIndicator scrollX={scrollX} />
    </View>
  )
}


 




 







根据前面提到的动画原理,你能分析出,为什么拖拽时,只有相邻的两个 Tab 才会有动画效果吗?

实现滚动效果

当前我们的页面较少,Tab 的数量也少,但如果 Tab 的数量比较多呢?

现在去 TabBarDemo 添加多几门语言,结果发现放不下了。

解决办法就是让 TabBar 滚动起来,可以在 TabBar 里面放一个 ScrollView,但也可以把 TabBar 放在 ScrollView 里面。

作者的选择是把 TabBar 放在 ScrollView 里面,因为这样复用性、组合性、维护性都比较好。

现在封装一个叫 ScrollBar 的组件,其结构如下:

export default function ScrollBar({ style, page, children }) {
  return (
    <ScrollView
      ref={scrollRef}
      style={[styles.scrollbar, style]}
      horizontal
      onContentSizeChange={onContentSizeChange}
      onLayout={onLayout}
      bounces={true}
      showsHorizontalScrollIndicator={false}
      {...props}>
      {React.cloneElement(children as any, { onTabsLayout })}
    </ScrollView>
  )
}

获取布局信息

上面代码,有三个回调,通过这三个回调,我们获得了所需要的布局信息,然后就可以计算出 ScrollView 应该滚动到的位置。

  • 通过 onContentSizeChange 回调获得 TabBar 的宽度。

  • 通过 onLayout 回调获得 ScrollBar 的宽度,通常和屏幕宽度一致。

  • 上面的 children 就是 TabBar,onTabsLayout 用来获取 Tab 的位置和大小,我们在前面的指示器的实现中已经获得过了。

稍微修改下 TabBar,让它接受 onTabsLayout 参数

export default function TabBar({ onTabsLayout }: TabBarProps) {
  const layouts = useRef<Layout[]>([]).current

  const handleTabLayout = useCallback(
    (index: number, layout: Layout) => {
      layouts[index] = layout

      console.log("---------------onTabLayout-------------------")
      setOutputRange(range)
      onTabsLayout?.(layouts)
    },
    [onTabsLayout, tabs, layouts, indicatorWidth]
  )
}









 




改动不大,对吧。

计算滚动距离

我们尽可能让选中的 Tab 居中显示,为了满足这个需求,需要计算出 ScrollView 应该滚动到哪。

以下是计算步骤:

useEffect(() => {
  if (
    tabLayouts.length - 1 < page ||
    contentWidth === 0 ||
    scrollBarWidth === 0
  ) {
    return
  }

  // 获得选中的 Tab 布局数据
  const tabLayout = tabLayouts[page]
  // 计算 Tab 中心到 ScrollBar 中心的 x 轴距离
  const dx = tabLayout.x + tabLayout.width / 2 - scrollBarWidth / 2
  // 计算出 ScrollView 的最大可滚动距离,ScrollView 的可滚动范围是 [0, maxScrollX]
  const maxScrollX = contentWidth - scrollBarWidth
  // 计算出 ScrollView 应该滚动到的 x 坐标,它必须大于等于 0 并且小于等于 maxScrollX
  const x = Math.min(Math.max(0, dx), maxScrollX)
  scrollRef.current?.scrollTo({ x })
}, [page, tabLayouts, contentWidth, scrollBarWidth])

组合

使用方式也比较简单,把 TabBar 嵌套在 ScrollBar 里面即可,别忘了调整 TabBar 的样式属性,让它的高度等同于 ScrollBar 的高度。在给 ScrollBar 指定高度时,需要设置 flexGrow: 0,否则它会尽可能占满屏幕,这是因为 ScrollView 有一个默认的 flex: 1 样式属性。

function TabBarDemo() {
  return (
    <View style={styles.container}>
      <ScrollBar style={styles.scrollbar} page={page}>
        <TabBar style={styles.tabbar} />
      </ScrollBar>
      <AnimatedPagerView ref={pagerRef} style={styles.pager}>
        {langs.map((lang) => (
          <LangPage key={lang} language={lang} />
        ))}
      </AnimatedPagerView>
    </View>
  )
}

const styles = StyleSheet.create({
  scrollbar: {
    height: 48,
    flexGrow: 0,
  },
  tabbar: {
    height: '100%',
  },
})

示例

这里有一个示例open in new window,供你参考。

上次更新: