选项卡这东西,几乎每个 App 都有。顶部几个标签,点哪个显示哪个内容。看着简单,真写起来要考虑的东西还挺多:
- 选中状态怎么管理?
- 支不支持外部控制?
- 标签太多放不下怎么办?
- 不同的视觉风格怎么切换?
今天我们就来一步步实现这个组件,边写边聊。
完整代码
先把成品放出来,文件在 src/components/ui/Tabs.tsx:
tsx
import React, { useState } from 'react';
import { View, Text, TouchableOpacity, ScrollView, StyleSheet, ViewStyle } from 'react-native';
import { UITheme, ColorType } from './theme';
interface TabItem {
key: string;
label: string;
icon?: string;
badge?: number;
content?: React.ReactNode;
}
interface TabsProps {
items: TabItem[];
activeKey?: string;
onChange?: (key: string) => void;
variant?: 'line' | 'pills' | 'enclosed';
color?: ColorType;
fullWidth?: boolean;
style?: ViewStyle;
}
export const Tabs: React.FC<TabsProps> = ({
items,
activeKey,
onChange,
variant = 'line',
color = 'primary',
fullWidth = false,
style,
}) => {
const [internalKey, setInternalKey] = useState(items[0]?.key);
const currentKey = activeKey ?? internalKey;
const colorValue = UITheme.colors[color];
const handleChange = (key: string) => {
setInternalKey(key);
onChange?.(key);
};
const getTabStyle = (isActive: boolean): ViewStyle => {
const base: ViewStyle = {
paddingVertical: UITheme.spacing.sm,
paddingHorizontal: UITheme.spacing.lg,
flexDirection: 'row',
alignItems: 'center',
};
if (fullWidth) base.flex = 1;
switch (variant) {
case 'line':
return { ...base, borderBottomWidth: 2, borderBottomColor: isActive ? colorValue : 'transparent' };
case 'pills':
return {
...base,
backgroundColor: isActive ? colorValue : 'transparent',
borderRadius: UITheme.borderRadius.full,
marginHorizontal: 2,
};
case 'enclosed':
return {
...base,
backgroundColor: isActive ? UITheme.colors.white : UITheme.colors.gray[100],
borderTopLeftRadius: UITheme.borderRadius.md,
borderTopRightRadius: UITheme.borderRadius.md,
borderWidth: isActive ? 1 : 0,
borderBottomWidth: 0,
borderColor: UITheme.colors.gray[200],
};
default:
return base;
}
};
const activeContent = items.find(item => item.key === currentKey)?.content;
return (
<View style={style}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabBar, variant === 'line' && styles.lineBar, variant === 'enclosed' && styles.enclosedBar]}
contentContainerStyle={fullWidth && styles.fullWidth}
>
{items.map(item => {
const isActive = item.key === currentKey;
return (
<TouchableOpacity
key={item.key}
style={getTabStyle(isActive)}
onPress={() => handleChange(item.key)}
activeOpacity={0.7}
>
{item.icon && <Text style={{ marginRight: 6 }}>{item.icon}</Text>}
<Text
style={[
styles.label,
{ color: variant === 'pills' && isActive ? UITheme.colors.white : isActive ? colorValue : UITheme.colors.gray[500] },
]}
>
{item.label}
</Text>
{item.badge !== undefined && item.badge > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{item.badge}</Text>
</View>
)}
</TouchableOpacity>
);
})}
</ScrollView>
{activeContent && <View style={styles.content}>{activeContent}</View>}
</View>
);
};
const styles = StyleSheet.create({
tabBar: { flexGrow: 0 },
lineBar: { borderBottomWidth: 1, borderBottomColor: UITheme.colors.gray[200] },
enclosedBar: { backgroundColor: UITheme.colors.gray[100] },
fullWidth: { flex: 1 },
label: { fontSize: UITheme.fontSize.md, fontWeight: '500' },
badge: {
backgroundColor: UITheme.colors.danger,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 1,
marginLeft: 6,
},
badgeText: { color: UITheme.colors.white, fontSize: 10, fontWeight: '600' },
content: { paddingTop: UITheme.spacing.lg },
});
120 行左右,功能挺全的。我们来拆解。
数据结构怎么设计
tsx
interface TabItem {
key: string;
label: string;
icon?: string;
badge?: number;
content?: React.ReactNode;
}
每个选项卡需要这些信息:
key 是唯一标识。为什么不用 index?老问题了,列表顺序变了 index 就乱了。用固定的 key 更可靠,而且语义更清晰,比如 key: 'home' 比 index: 0 更容易理解。
label 是显示的文字,比如"首页"、"消息"、"我的"。
icon 是可选的图标,放在文字前面。很多 App 的底部导航都是图标+文字的组合。
badge 是角标数字,可选。消息 Tab 上经常要显示未读数量,就是这个。
content 是选中这个 Tab 后显示的内容,可选。为什么可选?因为有时候内容不是放在 Tabs 组件里的,而是在外面单独渲染。
组件参数设计
tsx
interface TabsProps {
items: TabItem[];
activeKey?: string;
onChange?: (key: string) => void;
variant?: 'line' | 'pills' | 'enclosed';
color?: ColorType;
fullWidth?: boolean;
style?: ViewStyle;
}
items 是选项卡数据数组,必传。
activeKey 和 onChange 是一对,用于受控模式。什么是受控模式?就是选中状态由外部管理,组件只负责显示和触发回调。这样外部可以完全控制哪个 Tab 被选中。
variant 是视觉风格,三种选择:
line:下划线风格,最常见的pills:胶囊风格,选中的 Tab 有背景色enclosed:卡片风格,像浏览器的标签页
color 是主题色,影响选中状态的颜色。
fullWidth 控制是否平分宽度。默认 Tab 宽度由内容决定,设为 true 后所有 Tab 平分容器宽度。
状态管理的巧思
tsx
const [internalKey, setInternalKey] = useState(items[0]?.key);
const currentKey = activeKey ?? internalKey;
const colorValue = UITheme.colors[color];
这三行代码实现了"受控/非受控"两种模式的兼容。
internalKey 是组件内部维护的状态,初始值是第一个 Tab 的 key。items[0]?.key 用了可选链,防止 items 是空数组时报错。
currentKey 是实际使用的当前选中 key。activeKey ?? internalKey 用了空值合并运算符:如果外部传了 activeKey,就用外部的(受控模式);没传就用内部的(非受控模式)。
这种设计让组件既可以独立工作,也可以被外部控制,非常灵活。
colorValue 从主题里取出具体的颜色值,后面渲染时会用到。
切换处理
tsx
const handleChange = (key: string) => {
setInternalKey(key);
onChange?.(key);
};
用户点击 Tab 时调用这个函数。
setInternalKey(key) 更新内部状态。即使是受控模式,也要更新,因为 currentKey 会优先用外部的 activeKey,内部状态更新了也不影响。
onChange?.(key) 调用外部回调。问号是可选链调用,如果 onChange 没传就不执行,不会报错。
这样不管是受控还是非受控模式,点击都能正常工作。
动态样式计算
tsx
const getTabStyle = (isActive: boolean): ViewStyle => {
const base: ViewStyle = {
paddingVertical: UITheme.spacing.sm,
paddingHorizontal: UITheme.spacing.lg,
flexDirection: 'row',
alignItems: 'center',
};
if (fullWidth) base.flex = 1;
这个函数根据选中状态和 variant 返回不同的样式。
先定义基础样式 base:上下内边距、左右内边距、水平排列、垂直居中。这些是所有风格都需要的。
如果 fullWidth 为 true,给 base 加上 flex: 1。在 flex 容器里,所有子元素都设置 flex: 1 就会平分空间。
tsx
switch (variant) {
case 'line':
return { ...base, borderBottomWidth: 2, borderBottomColor: isActive ? colorValue : 'transparent' };
line 风格:底部有一条 2px 的线。选中时是主题色,未选中时是透明的。透明而不是不设置,是为了保持高度一致,不然选中和未选中的 Tab 高度会不一样。
tsx
case 'pills':
return {
...base,
backgroundColor: isActive ? colorValue : 'transparent',
borderRadius: UITheme.borderRadius.full,
marginHorizontal: 2,
};
pills 风格:选中时有背景色,未选中时透明。borderRadius.full 是很大的圆角值,让 Tab 变成胶囊形状。marginHorizontal: 2 让相邻的 Tab 之间有点间距。
tsx
case 'enclosed':
return {
...base,
backgroundColor: isActive ? UITheme.colors.white : UITheme.colors.gray[100],
borderTopLeftRadius: UITheme.borderRadius.md,
borderTopRightRadius: UITheme.borderRadius.md,
borderWidth: isActive ? 1 : 0,
borderBottomWidth: 0,
borderColor: UITheme.colors.gray[200],
};
enclosed 风格最复杂。选中的 Tab 是白色背景,未选中是灰色。只有顶部两个角有圆角,底部是直角,和内容区域连成一体。选中时有边框但底部没有边框,造成"打开的标签页"的视觉效果。
找到当前内容
tsx
const activeContent = items.find(item => item.key === currentKey)?.content;
从 items 数组里找到当前选中的那一项,取出它的 content。
find 方法返回第一个满足条件的元素,找不到返回 undefined。后面的 ?.content 是可选链,如果 find 返回 undefined 就不会继续访问 content,避免报错。
渲染标签栏
tsx
return (
<View style={style}>
<ScrollView
horizontal
showsHorizontalScrollIndicator={false}
style={[styles.tabBar, variant === 'line' && styles.lineBar, variant === 'enclosed' && styles.enclosedBar]}
contentContainerStyle={fullWidth && styles.fullWidth}
>
最外层是个 View,接收外部传入的 style。
标签栏用 ScrollView 包裹,horizontal 让它水平滚动。当 Tab 太多放不下时,用户可以左右滑动。showsHorizontalScrollIndicator={false} 隐藏滚动条,更美观。
样式数组里根据 variant 添加不同的样式。line 风格需要底部边框,enclosed 风格需要灰色背景。
contentContainerStyle 是 ScrollView 内容容器的样式。fullWidth 模式下设置 flex: 1,让内容撑满整个宽度。
渲染每个 Tab
tsx
{items.map(item => {
const isActive = item.key === currentKey;
return (
<TouchableOpacity
key={item.key}
style={getTabStyle(isActive)}
onPress={() => handleChange(item.key)}
activeOpacity={0.7}
>
遍历 items 渲染每个 Tab。
isActive 判断当前项是否选中,后面渲染时会用到。
TouchableOpacity 是可点击的容器,点击时调用 handleChange 切换选中状态。
tsx
{item.icon && <Text style={{ marginRight: 6 }}>{item.icon}</Text>}
如果有图标就渲染,和文字之间留 6px 间距。
tsx
<Text
style={[
styles.label,
{ color: variant === 'pills' && isActive ? UITheme.colors.white : isActive ? colorValue : UITheme.colors.gray[500] },
]}
>
{item.label}
</Text>
文字颜色的逻辑有点绕,我们拆开看:
pills 风格选中时,背景是主题色,文字要用白色才能看清。
其他情况下,选中用主题色,未选中用灰色。
这是个嵌套的三元表达式:先判断是不是 pills 且选中,是就白色;不是再判断是否选中,选中就主题色,否则灰色。
tsx
{item.badge !== undefined && item.badge > 0 && (
<View style={styles.badge}>
<Text style={styles.badgeText}>{item.badge}</Text>
</View>
)}
角标的渲染条件是:badge 有值且大于 0。为什么要判断 !== undefined?因为 badge 是 number 类型,如果只写 item.badge && ...,当 badge 为 0 时也会被当作 falsy 值跳过。但 0 和 undefined 是不一样的,0 表示"有角标但数量是 0",undefined 表示"没有角标"。
不过这里又加了 > 0 的判断,所以 0 的时候也不显示。这是产品逻辑:0 条未读就不需要显示角标了。
渲染内容区域
tsx
</ScrollView>
{activeContent && <View style={styles.content}>{activeContent}</View>}
</View>
);
如果当前选中的 Tab 有 content,就渲染出来。
content 外面包了一层 View,加了顶部 padding,和标签栏之间有间距。
样式定义
tsx
const styles = StyleSheet.create({
tabBar: { flexGrow: 0 },
flexGrow: 0 防止 ScrollView 在 flex 容器里无限增长。ScrollView 默认会尽可能占据空间,这个设置让它只占内容需要的高度。
tsx
lineBar: { borderBottomWidth: 1, borderBottomColor: UITheme.colors.gray[200] },
line 风格的底部边框。注意这是整个标签栏的边框,不是单个 Tab 的。单个 Tab 的下划线是在 getTabStyle 里设置的,选中的 Tab 下划线会盖住这条边框。
tsx
enclosedBar: { backgroundColor: UITheme.colors.gray[100] },
enclosed 风格的灰色背景。选中的 Tab 是白色,和这个灰色形成对比。
tsx
fullWidth: { flex: 1 },
fullWidth 模式下内容容器的样式,让它撑满宽度。
tsx
label: { fontSize: UITheme.fontSize.md, fontWeight: '500' },
文字样式,中等字号,稍微加粗。
tsx
badge: {
backgroundColor: UITheme.colors.danger,
borderRadius: 10,
paddingHorizontal: 6,
paddingVertical: 1,
marginLeft: 6,
},
badgeText: { color: UITheme.colors.white, fontSize: 10, fontWeight: '600' },
角标样式。红色背景、圆角、白色小字。borderRadius: 10 配合小尺寸让角标接近圆形。
tsx
content: { paddingTop: UITheme.spacing.lg },
});
内容区域顶部留白,和标签栏分开。
Demo 里的用法
看看 src/screens/demos/TabsDemo.tsx 怎么用这个组件。
准备数据
tsx
const items = [
{ key: '1', label: '首页', icon: '🏠', content: <Text style={styles.content}>首页内容</Text> },
{ key: '2', label: '消息', icon: '💬', badge: 5, content: <Text style={styles.content}>消息内容</Text> },
{ key: '3', label: '设置', icon: '⚙️', content: <Text style={styles.content}>设置内容</Text> },
];
三个 Tab,都有图标和内容,消息 Tab 还有角标。
三种风格
tsx
<Tabs items={items} variant="line" />
<Tabs items={items} variant="pills" />
<Tabs items={items} variant="enclosed" />
同样的数据,不同的 variant,呈现完全不同的视觉效果。
自定义颜色
tsx
<Tabs items={items} variant="pills" color="success" />
<Tabs items={items} variant="pills" color="danger" />
pills 风格配合不同颜色,可以表达不同的语义。
全宽模式
tsx
<Tabs items={items} variant="line" fullWidth />
三个 Tab 平分宽度,适合 Tab 数量固定且较少的场景。
带角标
tsx
<Tabs
items={[
{ key: '1', label: '全部', content: <Text>全部内容</Text> },
{ key: '2', label: '未读', badge: 12, content: <Text>未读内容</Text> },
{ key: '3', label: '已读', content: <Text>已读内容</Text> },
]}
variant="line"
/>
消息列表的典型场景,未读 Tab 上显示数量。
实际应用
受控模式
tsx
const MyPage = () => {
const [tab, setTab] = useState('home');
return (
<Tabs
items={items}
activeKey={tab}
onChange={setTab}
/>
);
};
外部管理状态,可以在其他地方读取或修改当前 Tab。
配合路由
tsx
const TabNavigation = () => {
const [activeTab, setActiveTab] = useState('home');
const handleChange = (key) => {
setActiveTab(key);
// 可以在这里做路由跳转或数据加载
if (key === 'messages') {
loadMessages();
}
};
return <Tabs items={items} activeKey={activeTab} onChange={handleChange} />;
};
切换 Tab 时可以触发其他操作,比如加载数据。
动态角标
tsx
const MessageTabs = () => {
const { unreadCount } = useMessages();
const items = [
{ key: 'all', label: '全部' },
{ key: 'unread', label: '未读', badge: unreadCount },
{ key: 'read', label: '已读' },
];
return <Tabs items={items} />;
};
角标数量从状态里读取,实时更新。
写在最后
Tabs 组件的核心是状态管理和样式切换。
状态管理用了"受控/非受控兼容"的模式,让组件既能独立工作,也能被外部控制。
样式切换用了一个 getTabStyle 函数,根据 variant 和选中状态返回不同的样式对象。这种方式比写一堆条件 className 更清晰。
这个组件还有改进空间,比如加上切换动画、支持禁用某个 Tab、支持懒加载内容等。但作为基础版本,已经能覆盖大多数场景了。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
