rn_for_openharmony常用组件_Tabs选项卡

项目开源地址:https://atomgit.com/nutpi/rn_for_openharmony_element

选项卡这东西,几乎每个 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 是选项卡数据数组,必传。

activeKeyonChange 是一对,用于受控模式。什么是受控模式?就是选中状态由外部管理,组件只负责显示和触发回调。这样外部可以完全控制哪个 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

相关推荐
特立独行的猫a18 小时前
低成本搭建鸿蒙PC运行环境:基于 Docker 的 x86_64 服务器
docker·容器·harmonyos·鸿蒙pc
赵民勇18 小时前
ES6中的const用法详解
javascript·es6
大厂技术总监下海19 小时前
从Hadoop MapReduce到Apache Spark:一场由“磁盘”到“内存”的速度与范式革命
大数据·hadoop·spark·开源
Van_captain19 小时前
React Native for OpenHarmony Toast 轻提示组件:自动消失的操作反馈
javascript·开源·harmonyos
Van_captain19 小时前
React Native for OpenHarmony Modal 模态框组件:阻断式交互的设计与实现
javascript·开源·harmonyos
xkxnq19 小时前
第一阶段:Vue 基础入门(第 14天)
前端·javascript·vue.js
前端小臻19 小时前
列举react中类组件和函数组件常用到的方法
前端·javascript·react.js
cn_mengbei19 小时前
鸿蒙原生PC应用开发实战:从零搭建到性能优化,掌握ArkTS与DevEco Studio高效开发技巧
华为·性能优化·harmonyos
研☆香19 小时前
html css js文件开发规范
javascript·css·html