react native中实现一个tab页签切换组件

203 阅读9分钟

最近在研习RN,没有找到比较合适的组件库,tab页签组件是比较常用的组件,今天分享一下我自己在RN中的封装,整体效果是选中页签高亮,底部下划线滑动到对应标签下,长度适配文字长度。具体效果如下图: 1.文字标签栏不带有右侧额外固定项

pic3.png

2.底部下划线根据标题文字长度变化动态变化

pic1.png

3.超过页面宽度的列表项且右侧额外选项

pic22.png

整体效果大伙都很熟悉,我在编写过程主要遇到如下问题:

  1. tabItem的对应下划线长度如何确定
  2. tab横向列表长度可能会超过屏幕宽度,如果此时带有右侧额外按钮如何布局
  3. 底部下划线在tab切换时如何确定滑动距离

首先如果tabItem数量过多超过屏幕宽度,肯定涉及到横向滚动,所以考虑使用ScrollView组件横向滚动,隐藏滚动条,然后是要计算出每个tabItem的实际宽度和tab文字的实际宽度,tabItem可以有间距,可以使用paddingHorizontal。有了tabItem的width和text的width才可以计算得出下划线的真正宽度

先看一下组件参数类型TS定义:

/**定义forwardRef的返回值参数类型 传递给父组件自定义的forwardRef */
export type ImpreativeType = { handlePress: (id: string, i: number, isChangePage?: boolean) => void; }
type LayoutType = 'extra' | 'text' | 'tab';
/** forwardRef接收组件的props参数类型定义 */
type Props = {
    style?: StyleProp<ViewStyle>
    data: { title: string, id: string }[]
    onPress: (id: string) => void
    extra?: ReactElement,
    /**新增每一项的paddingHorizontal设置,因为ScrollView的
     * contentContainerStyle样式没有固定宽度,此时设置
     * paddingHorizontal会增加item的宽度,影响布局,
     * 默认为16
     */
    itemPaddingHorizontal?: number;
    /**
     * 新增右侧扩展区域渐变颜色配置 第一个颜色应该是透明色
     * 最后一个颜色才是不透明的,让颜色从透明到不透明,
     * 标签滑动到扩展区域时会有半透明效果
     */
    extraGradientColor?: ColorValue;
    /**扩展区域向左侧遮挡的宽度设置,默认50 */
    extraShadowWidth?: number;
    /**下划线的高度 */
    underlineHeight?: number;
    /**下划线颜色*/
    underlineColor?: ColorValue;
}

导出的ImpreativeType是当手动切换tab内容时调用该组件内部修改当前高亮tab的方法 Props则是组件接收的配置,这里有一个关于RN元素宽度的问题:RN中的元素“盒模型”是类似于web端的怪异盒模型,具体在原生开发中什么情况我不了解,但是在RN中,如果一个元素设置宽高,和padding,则元素向内压缩,不像标准盒模型向外撑大,如果元素没有设置固定宽高,但是设置padding,则元素会向外扩张,这便是为什么Props中增加了一个itemPaddingHorizontal参数,并且它会影响后续的下划线计算

对于右侧的额外按钮,不随着组件一起滚动的tabItem,使用了线性渐变制作了阴影效果,而线性渐变在RN中实现方式也不是web中的backgroundImage:linearGradient(),而需要使用依赖库expo-linear-gradient

底部下划线滑动则使用Animated。具体组件中所用依赖如下:

import { LinearGradient } from "expo-linear-gradient";
import React, { forwardRef, memo, useImperativeHandle, useState, type ReactElement } from "react";
import { Pressable, ScrollView, StyleSheet, Text, View, type ColorValue, type LayoutChangeEvent, type StyleProp, type ViewStyle } from "react-native";
import Animated, { useAnimatedStyle, withTiming } from "react-native-reanimated";

接下来是UI部分,结构很简单:ScrollView和extra组件在外层组件中横向排列,下划线则在ScrollView中定位到底部:

const CustomTabs = forwardRef<ImpreativeType, Props>(({
    style, data, onPress, extra, underlineColor = '#bf6e00', underlineHeight = 4,
    itemPaddingHorizontal = 16, extraGradientColor = '#000', extraShadowWidth = 50
}, ref) => {
    return (
        <View style={[styles.container, style]}>
            <ScrollView
                style={{ flexGrow: 1 }}
                horizontal
                showsHorizontalScrollIndicator={false}
                contentContainerStyle={[styles.scrollContent, { paddingRight: extra ? extraWidth : 0 }]}
            >
                {
                    data.map(({ title, id }, i) => {
                        return (
                            <Pressable
                                style={[styles.item, { paddingHorizontal: itemPaddingHorizontal }]}
                                key={id}
                                onPress={() => handlePress(id, i)}
                                onLayout={handleLayout('tab', i)}
                            >
                                <Text onLayout={handleLayout('text', i)} style={[styles.tabText, activeIndex === i && styles.activeTabText]}>
                                    {title}
                                </Text>
                            </Pressable>
                        )
                    })
                }
                {/* 下划线使用Animated.View */}
                <Animated.View
                    style={[styles.underline, animatedStyle, { height: underlineHeight, backgroundColor: underlineColor }]}
                >
                    <View
                        style={[styles.ball, { backgroundColor: underlineColor, width: underlineHeight, height: underlineHeight, left: ballPosition }]}
                    />
                    <View
                        style={[styles.ball, { backgroundColor: underlineColor, width: underlineHeight, height: underlineHeight, right: ballPosition }]}
                    />
                </Animated.View>
            </ScrollView>
            {/* 右侧扩展区域 改为LinearGradient 以增加遮挡效果 */}
            {extra && <LinearGradient
                start={{ x: 0, y: .5 }}
                end={{ x: 1, y: .5 }}
                colors={['transparent', extraGradientColor]}
                locations={[0, .3]}
                onLayout={handleLayout('extra', 0)}
                style={[styles.rightContent, { paddingLeft: extraShadowWidth }]}
            >
                {extra}</LinearGradient>}
        </View>
    )
})

const styles = StyleSheet.create({
    container: {
        height: 37,
        width: '100%',
        flexDirection: 'row',
        alignItems: 'center',
        paddingHorizontal: 3,
        position: 'relative',
    },
    scrollContent: {
        /** 移除掉scrollcontent的flexGrow,因为它会导致发生横滚时底部的下划线隐藏*/
        // flexGrow: 1,
        position: 'relative',
        alignItems: 'center',
    },
    tabText: {
        fontSize: 16,
        color: '#ccc',
    },
    activeTabText: {
        color: '#fff',
        fontWeight: 'bold',
    },
    item: {
        paddingVertical: 5,
    },
    /**
     * 元素的borderWidth不会直接影响元素的宽高尺寸
     * 但是会占用额外的空间,也就是说borderWidth会增加
     * 整体占用空间,比如元素宽100,左右加了borderWidth:5
     * 元素内容区域还是100,但是它占用的总空间宽度变为了110
     * padding是向内挤压,border是向外扩展
     * 
     * 如果是固定尺寸,比如指定元素为100那border就会占用额外空间
     * 可能导致元素超出父容器或者兄弟元素
     * 
     * 弹性尺寸flexborder则会占用父容器可用空间,间接影响内容区域
     * 的分配
     * 未设置尺寸 元素的尺寸将由内容撑开,border会增加整体占用空间
     */

    /**
     * 这里用元素和边框透明色控制线条的宽度模拟下划线,核心属性borderRadius
     * 边框的默认行为
     * rn中边框通过CSS的border模型实现,当设置borderLeftWidth 和 borderRightWidth 时,
     * 边框会向外扩展,但默认情况下,边框的边角是直角
     * 但是如果不设置borderRadius某些平台比如android可能会忽略透明边框的绘制
     * 导致视觉上边框仍然占据空间,仿佛透明边框失效
     * 
     * borderRadius的作用:
     * 它会强制边框的绘制,它会出发边框的完整绘制逻辑,包括透明部分
     * 此时两个透明边框就按预期生效了
     * 此时borderRadius即使设置为0也会强制绘制边框
     *
     */
    underline: {
        borderRightColor: 'transparent',
        borderLeftColor: 'transparent',
        // 关键属性borderRadius强制绘制边框,如果值设为0,会触发完全绘制,不忽略透明边框
        borderRadius: 0,
        bottom: 0,
        position: 'absolute',
    },
    ball: {
        position: 'absolute',
        borderRadius: '50%',
        backgroundColor: 'red'
    },
    rightContent: {
        position: 'absolute',
        top: 0,
        bottom: 0,
        right: 0,
        flexDirection: 'row',
        justifyContent: 'center',
        alignItems: 'center',
    },
})

在styles中有两个样式相关的细节:

1. border对元素宽度的影响

2. borderRadius对边框绘制的影响

因为我要用borderColor:'transparent'控制下划线的宽度和text进行匹配

如果你和我一样是RN小白,我建议仔细阅读注释!

接下来是逻辑相关代码,我们要计算得出tab列表总体宽度以及tabItem的宽度和text文字元素的宽度,并存储它们:

    /**
     *1.tabWidths
     *tab列表每一项宽度组成的数组
     *由于它会在后续下划线的滑动动画中影响渲染,所以不要使用
     *useRef创建
     *
     *2.activeIndex
     *当前高亮tabItem的index
     *
     *3.textWidths
     *tab列表中每一项text文字宽度组成的数组
     *
     *4.extraWidth 
     *右侧额外元素的宽度
     */
    const [tabWidths, setTabWidths] = useState<number[]>([]);
    const [activeIndex, setActiveIndex] = useState<number>(0);
    const [textWidths, setTextWidths] = useState<number[]>([]);
    const [extraWidth, setExtraWidth] = useState<number>(0);

接下来在元素onLayout事件触发后修改以上状态,因为onLayout中逻辑一样,编写一个简易的柯里化版:

    const handleLayout = (type: LayoutType, i: number) => (event: LayoutChangeEvent) => {
        const { width } = event.nativeEvent.layout;
        if (type === 'extra') {
            setExtraWidth(width);
        }
        if (type === 'tab') {
            setTabWidths(prevWidths => {
                const updatedWidths = [...prevWidths];
                updatedWidths[i] = width;
                return updatedWidths;
            });
        }
        if (type === 'text') {
            setTextWidths(prevWidths => {
                const updatedWidths = [...prevWidths];
                updatedWidths[i] = width;
                return updatedWidths;
            });
        }
    }

接下来是核心的下划线滑动距离控制和下划线宽度以及下划线的圆角实现:

  1. 以上onLayout计算tabWidths即每个tabItem的实际宽度组成的数组,它是tabItem每次切换时下划线向左或者向右滑动距离的依据

比如当高亮tabItem由第一个切换为第三个,则它滑动的距离应该是tabWidths的下标0,1,2累加

2.获取当前高亮tabItem的文字宽度,将高亮tabItem的整体宽度-和文字宽度做差除以2,得到当前下划线左右两个透明边框的borderWidth:

pic4.png

具体下划线的animatedStyle代码如下:

   /**
     * 下划线滚动动画中动态更新
     * 下划线的左右透明边框宽度来达到和
     * 文字宽度一致的效果
     * 除此之外还特别需要下划线的borderRadius属性设置
     * 来保证边框的绘制防止透明边框显示失效的问题
     */
    const animatedStyle = useAnimatedStyle(() => {
        /** 
          *当前高亮tabItem的宽度,和下面的textWidth结合计算下划线左右透明边框的borderWidth
          *并修改当前下划线的宽度
          */
        const width = tabWidths[activeIndex] || 0;
        const textWidth = textWidths[activeIndex] || 0;
        const empty = Math.floor((width - textWidth) / 2);
        /** 计算当前下划线应该translateX的具体值 */
        const translateX = tabWidths.slice(0, activeIndex).reduce((sum, w) => sum + w, 0)
        return ({
            width: withTiming(width, { duration: 300 }),
            /** 动态修改左右两边的宽度 因为是透明色,所以间接调整了下划线宽度 */
            borderLeftWidth: empty,
            borderRightWidth: empty,
            transform: [{
                translateX: withTiming(translateX, { duration: 300 })
            }],
        })
    })

由于使用了borderRightColor和borderLeftColor : transparent,所以如果想让下划线有圆角效果而直接设置borderRadius是看不到效果的,但是还是必须要设置borderRadius,具体看style中的underline的注释

这时的圆角则是使用两个圆形元素分别定位到下划线显示位置的两端,拼接成一个有圆角的元素,这两个元素的定位位置是:

/** underlineHeight是用户传递的下划线高度 */
const ballPosition = underlineHeight / 2 * -1
...
                <Animated.View
                    style={[styles.underline, animatedStyle, { height: underlineHeight, backgroundColor: underlineColor }]}
                >
                    <View
                        style={[styles.ball, { backgroundColor: underlineColor, width: underlineHeight, height: underlineHeight, left: ballPosition }]}
                    />
                    <View
                        style={[styles.ball, { backgroundColor: underlineColor, width: underlineHeight, height: underlineHeight, right: ballPosition }]}
                    />
                </Animated.View>
                
                ...

右侧额外按钮则是使用Lineargradent组件实现渐变效果,如果有extra元素则需要在ScrollView的contentContainerStyle中设置渐变区域的paddingRight,让滚动区域再宽一点,避免遮挡,

修复一个异常:组件初始化时,指定下标tab项目文字高亮,但是下划线滚动异常:

    useEffect(() => {
        if (data.length > 0 && tabWidths.length === data.length) {
            handlePress(data[defaultActiveIndex].id, defaultActiveIndex, true);
        }
    }, [data, tabWidths]);

在组件初始化和tab列表项目变化时计算并执行一次点击,让下划线滑动到指定位置

如上便是组件的整体实现,核心在于如何实现下划线宽度计算,和一些RN中的特殊情况。

本人是RN小白自己再研究学习模仿音乐软件编写demo,目前想要实现一个音乐播放器的通知栏控件,期待大佬指教, 项目地址