如何在 React Native 中,为 PagerView 自定义 TabBar
本文手把手教你自定义一个丝滑的 TabBar,让你在面对同类需求时,游刃有余,效果如下:
本文将使用到 React Native Animated API,如果你不太熟悉,可能需要先了解下,这里有几篇不错的文章:
基本结构
我们先来看看页面基本结构:
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 的滚动状态,dragging
、settling
或idle
。
以上这三个回调在 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 Issues 的提示,将页面宽高都设置成 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 事件的 position
和 offset
属性。
提供动画值
修改 usePagerView
,让它提供 onPageScroll
回调函数 和 position
、offset
这两个动画值。
// 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()
这个辅助函数,将动画值映射到事件值上。在这里,会把 position
和 offset
这两个动画值映射到 onPageScroll 事件的 position
和 offset
属性上。
由于设置了 useNativeDriver
为 true
,这个映射背后并不会调用 position
或 offset
动画值的 setValue
方法,而是以一种优化的方式,在 UI 主线程完成绑定。这就使得动画是同步跟随手势的。
修改 TabBarDemo,将 onPageScroll
函数挂载到 PagerView 的 onPageScroll 事件钩子上,将 position
和 offset
这两个动画值传递给 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 中,把 position
和 offset
映射成 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 平台,onPageSelected
在 setPage
之后马上触发。而在 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
}
有了 page
和 lastPage
,可以开始编写过渡动画了。
// 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 会有比较好的过渡。
但是在 iOS 平台上,从 JavaScript 切换到 Objective-C 时,直接跳过了 position 为 0 的情况,导致没有了过渡效果。
针对这一情况,把缺失的动画补回来便可。
// 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 事件来通知页面滚动状态 (dragging
、settling
、idle
),但在 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%',
},
})
示例
这里有一个示例,供你参考。