在这里插入图片描述

鸿蒙 ArkTS 实战:Tabs + scrollable(true) 可滑动标签页布局详解

写作环境:HarmonyOS NEXT 6.1.1(API 24)+ DevEco Studio 5.x + ArkTS 声明式开发范式。
配套示例:一个可直接编译运行的鸿蒙工程,核心源码位于 entry/src/main/ets/pages/TabsSwipeableExample.ets

目录

  • 一、引言:为什么需要「可滑动标签页」
  • 二、开发环境与工程准备
  • 三、Tabs 组件核心概念与整体认识
  • 四、需求分析与布局结构设计
  • 五、完整示例代码
  • 六、代码逐段深度解析
  • 七、关键属性详解:scrollable 与 barMode
  • 八、运行验证与调试技巧
  • 九、常见问题与避坑指南
  • 十、进阶扩展思路
  • 十一、总结

一、引言:为什么需要「可滑动标签页」

在移动端应用开发中,「多页签切换」是最常见、也最实用的导航交互形态之一。无论是电商 App 的「首页 / 分类 / 购物车 / 我的」,还是资讯类 App 的「推荐 / 关注 / 视频 / 直播」,本质上都是把多个功能模块塞进同一个页面容器里,让用户通过点击标签或滑动屏幕来快速切换。

鸿蒙 HarmonyOS NEXT 全面转向 ArkTS 声明式开发范式之后,实现这类交互变得非常简单:系统提供了 Tabs 组件作为多标签页的容器,搭配 TabContent 承载每一页的内容,再通过 tabBar() 方法为每一页挂上标签栏。更贴心的是,Tabs 天生支持「手势滑动切换」——只要把 scrollable(true) 这个属性打开,用户就能在内容区左右滑动,像翻书一样切换页面。这种交互在信息流、轮播页、多栏目新闻等场景里体验尤其流畅,因为它把「切换成本」降到了最低:拇指轻轻一拨即可完成。

不过,很多初学者容易把两个概念混淆:**「内容区滑动切换页面」「标签栏自身的滚动」**是两回事。前者由 scrollable 属性控制,后者由 barMode(标签栏模式)控制。本篇文章将以一个真实可运行的工程为蓝本,从零开始讲解如何用鸿蒙原生 ArkTS 搭建一个「Tabs + scrollable(true)」的可滑动标签页应用,并把这两个关键属性掰开揉碎讲清楚。

二、开发环境与工程准备

动手之前,先把环境准备齐全。本篇文章的所有代码均基于以下环境验证通过:

项目 版本 / 说明
操作系统 Windows 10 / 11(macOS 亦可,操作方式一致)
开发工具 DevEco Studio 5.x(HarmonyOS 专属 IDE,基于 IntelliJ IDEA)
系统版本 HarmonyOS NEXT 6.1.1(API 24)
开发语言 ArkTS(TypeScript 的超集,采用声明式 UI 范式)
运行设备 本地模拟器(Local Emulator)或真机

2.1 创建工程

打开 DevEco Studio,按照下面的步骤创建一个全新的空工程:

  1. 点击欢迎页的「Create Project」,进入工程创建向导。
  2. 模板选择「Empty Ability」(空工程模板),它自带一个最简单的 Hello World 页面,适合作为动手起点。
  3. 填写工程名称(例如 TabsScrollableDemo)、包名(Bundle Name)与存放路径。
  4. 选择兼容的 SDK 版本:由于本机安装的是 HarmonyOS NEXT 6.1.1,向导会自动选中与之匹配的 API 24 版本,直接保持默认即可。
  5. 点击 Finish,等待工程初始化完成,首次打开会自动执行 Gradle/Hvigor 的依赖同步,耐心等待即可。

工程创建成功后,目录结构大致如下:

MyApplication
├── AppScope/                      # 应用级配置(图标、应用名称等)
├── entry/                         # 模块(Module)目录
│   └── src/main/
│       ├── ets/
│       │   ├── entryability/
│       │   │   └── EntryAbility.ets   # 应用入口 Ability,负责加载首屏页面
│       │   └── pages/
│       │       └── Index.ets          # 默认首页(我们稍后新增页面文件)
│       ├── module.json5           # 模块配置
│       └── resources/             # 资源目录(字符串、颜色、图片等)
├── build-profile.json5            # 工程级构建配置
└── oh-package.json5               # 三方依赖配置

2.2 关于页面加载机制

在 Stage 模型(API 9 及以后)下,一个 Ability 通过 loadContent() 指定启动时加载哪个页面。以默认模板为例,EntryAbility.ets 中会有这样一段代码:

windowStage.loadContent('pages/Index', (err) => {
  // 页面加载完成或失败的回调
});

字符串 'pages/Index' 对应 entry/src/main/ets/pages/Index.ets 这个文件(路径不带 .ets 后缀)。这意味着:只要新建一个页面文件,并把这里的路径改成新文件名,该页面就会成为应用启动后看到的第一个页面。本文示例正是把启动页指向了新建的 TabsSwipeableExample 页面,因此运行工程后可以直接看到效果,无需任何手动跳转操作。

2.3 准备模拟器或真机

运行工程有两种方式:

  • 模拟器:在 DevEco Studio 顶部的设备列表里选择 Local Emulator,首次使用需要先下载系统镜像并创建模拟器实例。模拟器启动后会自动部署并运行应用,是最快的验证方式。
  • 真机:将 HarmonyOS 手机通过 USB 连接电脑,开启「开发者模式」与「USB 调试」,并在工程的签名配置里勾选自动签名。真机能更真实地反映手势交互手感,推荐在联调阶段使用。

三、Tabs 组件核心概念与整体认识

在进入代码之前,先建立对 Tabs 组件体系的整体认知。这是理解后续所有代码的基础。

3.1 Tabs 是什么

Tabs 是 ArkUI 提供的一个容器类组件,专门用于实现「多页签」布局。一个 Tabs 组件由三部分构成:

组成部分 对应写法 作用
容器本身 Tabs() 负责整体布局、手势识别、页面切换动画
内容页 TabContent() 每一个标签页的内容载体,一个 TabContent 就是一个页面
标签栏 .tabBar() 附着在 TabContent 上的标签(文字、图标或自定义组件)

简单说:Tabs 是「壳」,TabContent 是「页面」,tabBar 是「按钮」。三者组合,就构成了用户眼中完整的标签页。

3.2 一个最小示例

先看一个不含任何修饰的最简写法,感受一下结构:

@Entry
@Component
struct MiniTabsDemo {
  build() {
    Tabs() {
      TabContent() {
        Text('第一个页面')
      }
      .tabBar('首页')

      TabContent() {
        Text('第二个页面')
      }
      .tabBar('发现')
    }
  }
}

这段代码已经具备了完整功能:页面顶部会出现「首页」「发现」两个标签,点击可以切换;内容区左右滑动同样可以切换——因为 scrollable 属性的默认值就是 true。系统默认的标签栏样式是「文字 + 底部蓝色指示条」,简单但不够个性化。本文示例正是从这里出发,将默认样式升级为自定义高亮标签栏,并显式控制每一个影响体验的属性。

3.3 核心属性与事件一览

Tabs 组件功能强大,下面把与本文相关的核心 API 整理成表,方便随时查阅:

API 类型 说明
barPosition 构造参数 标签栏位置:BarPosition.Start(顶部,默认)、BarPosition.End(底部)
index 构造参数 当前显示的标签序号(从 0 开始),可通过状态变量动态控制
controller 构造参数 TabsController 类型的控制器,用于代码中切换页面
vertical 属性 排布方向:false 为水平(标签在上下、内容在中间),true 为垂直
scrollable 属性 内容区是否可通过手势滑动切换页面true 开启,默认 true
barMode 属性 标签栏模式:BarMode.Fixed(均分固定,默认)或 BarMode.Scrollable(可滚动)
barHeight 属性 标签栏高度
barBackgroundColor 属性 标签栏背景色
barOverlap 属性 标签栏是否与内容区重叠(沉浸式场景用)
onChange 事件 页面切换完成后的回调,参数为新的索引 index
onAnimationStart 事件 切换动画开始时的回调
onAnimationEnd 事件 切换动画结束时的回调

与之配套的 TabsController 控制器,常用方法如下:

方法 说明
changeIndex(value: number) 直接切换到指定索引的页面(会触发切换动画)
preload(index) 预加载指定页面,提升切换流畅度(API 11+)

3.4 最关键的概念区分

请务必记住这一小节,它是全文的「题眼」:

  • scrollable(true) 控制的是「内容区滑动」:用户在标签内容区域左右滑动手指,页面随之切换。这就是「标签页可左右滑动切换」的含义。
  • barMode(BarMode.Scrollable) 控制的是「标签栏滚动」:当标签数量很多、所有标签的总宽度超过屏幕宽度时,标签栏本身可以横向滑动,让用户看到被隐藏的标签。

两者一个管「翻页手势」,一个管「标签溢出」,作用对象完全不同,但经常被放在一起讨论,因为一个体验良好的多标签应用往往两者都要。

本文示例刻意放置了 8 个标签(首页、发现、消息、我的、购物、视频、音乐、设置),它们的总宽度必然超过手机屏幕,因此可以同时演示上述两种滑动效果:内容区滑动切换页面、标签栏滑动查看全部标签。

四、需求分析与布局结构设计

在动手写代码之前,先把需求翻译成布局结构。好的布局设计是「一次想清楚,代码只是落地」。

4.1 需求场景

我们期望最终效果是:

  1. 页面顶部有一个横向的标签栏,包含 8 个标签,默认选中第一个。
  2. 标签数量超过屏幕宽度,标签栏自身可以左右滑动查看全部标签。
  3. 在内容区左右滑动手指,可以切换当前显示的页面。
  4. 选中标签有明确的高亮效果(字号、粗细、颜色三方面同时变化)。
  5. 每个标签页的内容各不相同,用不同的背景色区分,便于肉眼观察切换是否成功。
  6. 提供按钮,演示通过代码(TabsController.changeIndex)切换标签,与手势切换形成对照。

4.2 页面整体结构

整个页面用「一列三段式」的结构组织,组件树如下:

Column(页面根容器,宽高 100%)
├── Text            # ① 顶部标题栏:展示页面名称
├── Tabs            # ② 核心标签容器:占据剩余全部高度(layoutWeight(1))
│   ├── TabContent  # 第 1 页(首页)
│   │   └── Column  # 页面内容:提示文案 + 大标题 + 页码
│   ├── TabContent  # 第 2 页(发现)
│   ├── TabContent  # 第 3 页(消息)
│   ├── ……          # 共 8 个 TabContent,由 ForEach 批量生成
│   └── TabContent  # 第 8 页(设置)
└── Row             # ③ 底部控制条:上一页 / 下一页 两个按钮

4.3 每一层的职责

层级 组件 职责
根容器 Column 纵向排列三个区域,width/height('100%') 撑满全屏
标题栏 Text 固定高度 52vp,居中显示「Tabs 可滑动标签页示例」
核心区 Tabs layoutWeight(1) 占满标题栏之外的所有剩余高度
标签栏 自定义 @Builder 每个标签固定宽度 100vp,高亮选中态
内容页 TabContent 承载每页内容,背景色各不相同
控制条 Row 固定高度 64vp,放置两个切换按钮

这里有一个容易踩的坑需要提前说明:Tabs 必须在父容器中明确获得高度。如果 Tabs 的直接父容器没有给它分配高度(例如放在一个高度未定义的 Column 里),标签内容区可能出现高度为 0 或者显示异常。解决办法就是示例中的做法——让 Tabs 位于 Column 内,并给外层 Column 设置 height('100%'),再给 Tabs 设置 layoutWeight(1) 让它吃下剩余空间。

4.4 关键设计决策

决策一:用 Column 做三段式骨架。 页面从上到下依次是「标题栏 / 标签区 / 控制条」,天然的纵向结构,Column 是最合适的选择。横向需求(标签栏、按钮行)交给内部的 TabsRow 处理,各司其职。

决策二:自定义标签栏而不是使用默认样式。 系统默认的标签栏是「灰色文字 + 底部蓝色指示条」,视觉上中规中矩但不够直观。示例用 @Builder 自定义了标签栏:选中项放大加粗并变成主题红色,未选中项保持常规灰色,状态切换一目了然。

决策三:放 8 个标签。 单个标签固定宽度 100vp,8 个共 800vp,远超主流手机屏幕宽度(约 360~430vp)。这样设计有两个目的:一是真实复现「标签太多放不下」的工程场景;二是让 BarMode.Scrollable 生效——标签栏只有在内容溢出时才需要滚动。

决策四:状态提升,单一数据源。 当前选中索引 currentIndex@State 形式声明在组件顶层,同时用于三处:构造 Tabs 时作为初始 index、自定义标签栏高亮判断、内容页页码显示。任何一处切换页面,onChange 回调都会更新它,UI 自动刷新,保证「显示状态」与「真实状态」始终一致,避免出现「标签高亮的是 A 页,内容显示的却是 B 页」的错位问题。

决策五:绑定 TabsController 控制器是 Tabs 对外暴露的「遥控器」。示例底部放了「上一页 / 下一页」按钮,通过 changeIndex() 实现代码切换,与手势切换互补,也方便初学者验证两种切换方式在 onChange 中的表现一致。

至此,结构与设计已经清晰,接下来进入代码环节。

五、完整示例代码

下面是本示例应用的完整源码,位于 entry/src/main/ets/pages/TabsSwipeableExample.ets。为了保证「运行即可见效果」,EntryAbility.ets 中的启动页已被改为 pages/TabsSwipeableExample。代码中的中文注释即是对布局要点的说明,建议先通读一遍,再结合第六章的逐段解析对照学习。

/**
 * ============================================================================
 * 示例应用:Tabs + scrollable(true) —— 可左右滑动切换的标签页
 * 布局方式:鸿蒙原生 ArkTS 布局(声明式 UI)
 * ----------------------------------------------------------------------------
 * 【核心技术要点】
 * 1. Tabs 组件是鸿蒙 ArkUI 中实现「多标签页 + 滑动切换」的核心容器组件。
 *    每个标签页由一个 TabContent 承载,并通过 tabBar() 设置该页对应的标签栏。
 * 2. scrollable(true)(Tabs 组件属性,默认值即为 true):
 *    ★ 决定【内容区】是否允许用户用手指左右滑动来切换页面——这正是本示例
 *      「标签页可左右滑动切换」的关键开关。若设为 false,则只能点击标签或
 *      调用 TabsController.changeIndex() 来切换。
 * 3. barMode(BarMode.Scrollable)(标签栏模式):
 *    ★ 决定【标签栏】自身是否可滚动。当标签数量较多、总宽度超出屏幕时,
 *      标签栏可以横向滑动来查看被隐藏的标签;默认的 BarMode.Fixed 模式
 *      则会把所有标签均分铺满整行。
 * 4. 整体布局结构:外层 Column 纵向容器(标题栏 / Tabs / 底部控制条),
 *    Tabs 内部由多个 TabContent 按水平方向排布,页面切换动画由系统自动完成。
 * 5. TabsController:Tabs 的控制器,可在代码中调用 changeIndex() 主动切换页面。
 * ----------------------------------------------------------------------------
 * 【运行方式】
 * 本页面已在 EntryAbility 中被设置为启动页(pages/TabsSwipeableExample),
 * 使用 DevEco Studio 直接运行到模拟器/真机即可看到效果:
 *   - 在标签【内容区】左右滑动 → 切换页面(scrollable(true) 生效)
 *   - 在【标签栏】上左右滑动   → 查看被隐藏的标签(BarMode.Scrollable 生效)
 *   - 点击底部「上一页 / 下一页」→ 通过控制器在代码中切换标签
 * ============================================================================
 */
import { promptAction } from '@kit.ArkUI'; // 导入全局提示能力,用于切换页面时弹出 Toast

// 标签标题数组(共 8 个,总宽度超过一屏,便于演示标签栏滚动效果)
const TAB_TITLES: string[] = ['首页', '发现', '消息', '我的', '购物', '视频', '音乐', '设置'];
// 每个标签页内容的背景色(与标题一一对应,便于肉眼区分当前所在页面)
const TAB_COLORS: string[] = ['#E84026', '#007DFF', '#00B96B', '#8A2BE2', '#FF8F1F', '#E91E63', '#009688', '#5C6BC0'];

@Entry // 页面入口装饰器:标记该组件为应用的入口页面
@Component // 组件装饰器:标记该结构体为可复用的 UI 组件
struct TabsSwipeableExample {
  // 当前选中的标签索引:@State 装饰后,值发生变化会自动触发依赖它的 UI 重新渲染
  // (下方自定义 tabBar 的高亮样式即依赖此状态)
  @State currentIndex: number = 0;

  // Tabs 控制器:用于在代码中(如按钮点击)主动切换标签页
  private tabsController: TabsController = new TabsController();

  /**
   * 自定义标签栏构建器(@Builder:可复用的 UI 片段构建方法)
   * @param title 标签文字
   * @param index 标签序号
   */
  @Builder
  tabBarBuilder(title: string, index: number) {
    Column() {
      Text(title)
        // 选中项:字号更大、加粗、主题色高亮;未选中项:常规灰色
        .fontSize(this.currentIndex === index ? 18 : 15)
        .fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(this.currentIndex === index ? '#E84026' : '#8A8A8A')
    }
    .width(100) // 注意:BarMode.Scrollable 模式下每个标签需设置固定宽度,标签栏才会按固定步长滚动
    .height(56) // 与下方 Tabs 的 barHeight 保持一致,保证视觉对齐
    .justifyContent(FlexAlign.Center) // 标签文字在标签内水平居中
  }

  /**
   * 标签页内容构建器:每个 TabContent 里展示不同的内容
   * @param title 页面标题
   * @param color 页面背景色
   */
  @Builder
  pageContentBuilder(title: string, color: string) {
    Column({ space: 16 }) {
      // 滑动提示文案:引导用户尝试在内容区左右滑动
      Text('← 左右滑动切换标签 →')
        .fontSize(14)
        .fontColor('#FFFFFF')
        .opacity(0.85)

      Text(title)
        .fontSize(36)
        .fontWeight(FontWeight.Bold)
        .fontColor('#FFFFFF')

      Text('第 ' + (this.currentIndex + 1) + ' 个页面')
        .fontSize(16)
        .fontColor('#FFFFFF')
        .opacity(0.9)
    }
    .width('100%')
    .height('100%')
    .justifyContent(FlexAlign.Center) // 页面内容整体垂直居中
    .backgroundColor(color)           // 每个页面使用不同背景色,直观展示滑动切换效果
  }

  build() {
    // 外层纵向容器:顶部标题栏 + 核心 Tabs + 底部控制条
    Column() {
      // ---------- 顶部标题栏 ----------
      Text('Tabs 可滑动标签页示例')
        .fontSize(18)
        .fontWeight(FontWeight.Bold)
        .fontColor('#1A1A1A')
        .width('100%')
        .height(52)
        .textAlign(TextAlign.Center)
        .backgroundColor('#F1F3F5')

      // ============================================================
      // 核心:Tabs 组件(水平布局:标签栏在上、内容区在下)
      // ============================================================
      Tabs({
        barPosition: BarPosition.Start, // 标签栏位置:Start = 顶部
        index: this.currentIndex,       // 当前显示第几个标签页(与状态同步)
        controller: this.tabsController // 绑定控制器,支持代码主动切换
      }) {
        // 通过 ForEach 根据标题数组动态生成多个 TabContent 标签页
        ForEach(TAB_TITLES, (title: string, index: number) => {
          TabContent() {
            // 每个标签页的内容
            this.pageContentBuilder(title, TAB_COLORS[index])
          }
          .tabBar(this.tabBarBuilder(title, index)) // 为该标签页设置自定义标签栏
        }, (title: string) => title) // key 生成器:以标题为唯一键,保证列表项正确复用
      }
      .scrollable(true)                // ★ 核心开关①:内容区可左右滑动切换页面(true = 可滑动)
      .barMode(BarMode.Scrollable)     // ★ 核心开关②:标签栏可滚动(标签超出屏幕时可滑动查看)
      .vertical(false)                 // 水平方向布局:标签栏在上、内容在下(false 为默认值)
      .barBackgroundColor('#FFFFFF')   // 标签栏背景色
      .barHeight(56)                   // 标签栏高度
      .onChange((index: number) => {   // 页面切换完成后的回调
        this.currentIndex = index;     // 更新状态 → 自定义 tabBar 的高亮样式随之刷新
        promptAction.showToast({ message: '已切换到:' + TAB_TITLES[index] }); // 弹出提示
      })
      .width('100%')
      .layoutWeight(1)                 // 在 Column 中占满剩余高度

      // ---------- 底部控制条:演示通过 TabsController 在代码中切换标签 ----------
      Row({ space: 24 }) {
        Button('上一页')
          .backgroundColor('#FFFFFF')
          .fontColor('#E84026')
          .onClick(() => {
            if (this.currentIndex > 0) {
              this.tabsController.changeIndex(this.currentIndex - 1);
            }
          })

        Button('下一页')
          .backgroundColor('#E84026')
          .onClick(() => {
            if (this.currentIndex < TAB_TITLES.length - 1) {
              this.tabsController.changeIndex(this.currentIndex + 1);
            }
          })
      }
      .width('100%')
      .height(64)
      .justifyContent(FlexAlign.Center) // 按钮水平居中
      .backgroundColor('#FFFFFF')
    }
    .width('100%')
    .height('100%') // 页面撑满全屏
  }
}

到这里,完整代码已经就位。接下来我们把这 160 多行代码拆开,逐段讲解每一部分为什么这么写。

六、代码逐段深度解析

6.1 导入语句与常量定义

import { promptAction } from '@kit.ArkUI';

在 HarmonyOS NEXT(API 12+)中,ArkUI 的组件(TextColumnTabs 等)和内置类(TabsControllerFontWeight 等)都在全局作用域中直接可用,不需要额外 import。真正需要 import 的是各种「能力 API」,比如这里的 promptAction——它提供 showToast() 弹出轻提示,我们在切换页面时用它提示用户「已切换到:××」。

@kit.ArkUI 是鸿蒙为 ArkUI 能力统一封装的 Kit 入口,类似的还有 @kit.AbilityKit@kit.PerformanceAnalysisKit 等。当你需要提示、弹窗、震动等系统能力时,优先从这里导入。

接着是两个模块级常量:

const TAB_TITLES: string[] = ['首页', '发现', '消息', '我的', '购物', '视频', '音乐', '设置'];
const TAB_COLORS: string[] = ['#E84026', '#007DFF', '#00B96B', '#8A2BE2', '#FF8F1F', '#E91E63', '#009688', '#5C6BC0'];

把「数据」从「UI 代码」中剥离出来,是声明式开发的基本功。标签标题与背景色一一对应,ForEach 遍历 TAB_TITLES 时按索引取出 TAB_COLORS[index],就能批量生成 8 个风格统一又各不相同的页面。以后想加一个「订单」标签,只需往两个数组里各加一项,UI 代码一行都不用改。

6.2 组件声明与状态

@Entry
@Component
struct TabsSwipeableExample {
  @State currentIndex: number = 0;
  private tabsController: TabsController = new TabsController();

@Entry 标记该组件是页面的入口(一个页面有且仅有一个),@Component 标记它是一个可被框架管理的 UI 组件。struct 是 ArkTS 声明组件的关键字,类似传统开发的「页面类」。

@State currentIndex: number = 0 是本示例的状态中枢@State 装饰的变量一旦被赋值,所有依赖它的 UI 片段都会自动重新渲染——这是声明式 UI 与命令式 UI(如早期的 XML + findViewById 方式)最本质的区别:我们只描述「数据是什么」,框架负责「界面怎么变」

tabsControllerprivate 修饰,且不需要 @State:它只是对外调用的工具对象,本身不参与界面渲染,声明为普通成员即可。

6.3 自定义标签栏 @Builder

@Builder
tabBarBuilder(title: string, index: number) {
  Column() {
    Text(title)
      .fontSize(this.currentIndex === index ? 18 : 15)
      .fontWeight(this.currentIndex === index ? FontWeight.Bold : FontWeight.Normal)
      .fontColor(this.currentIndex === index ? '#E84026' : '#8A8A8A')
  }
  .width(100)
  .height(56)
  .justifyContent(FlexAlign.Center)
}

@Builder 是 ArkTS 提供的「UI 片段构建器」:把一段可复用的 UI 封装成方法,在 build() 里通过 this.tabBarBuilder(...) 调用。它接受参数,因此同一个构建器可以为 8 个标签服务,每个标签传入自己的标题与序号。

高亮逻辑的核心是三元表达式 this.currentIndex === index:当前选中索引与「这个标签自己的索引」相等,就按选中样式渲染(18fp、加粗、主题红),否则按默认样式渲染(15fp、常规、灰)。因为 currentIndex@State,一旦它变化,tabBarBuilder 中所有依赖它的样式都会重新计算,8 个标签的高亮状态随之整体刷新——这就是「状态驱动 UI」的直观体现。

宽度设置为 100(单位 vp,可省略字面量单位)并不是随意的:在 BarMode.Scrollable 模式下,每个标签必须有确定的宽度,标签栏才能按固定步长滚动。若宽度缺省,滚动行为可能异常。高度 56 与后面 TabsbarHeight(56) 保持一致,避免标签栏与内容区之间出现错位或空隙。

6.4 页面内容构建器 @Builder

@Builder
pageContentBuilder(title: string, color: string) {
  Column({ space: 16 }) {
    Text('← 左右滑动切换标签 →')
    Text(title)
    Text('第 ' + (this.currentIndex + 1) + ' 个页面')
  }
  .width('100%')
  .height('100%')
  .justifyContent(FlexAlign.Center)
  .backgroundColor(color)
}

(为突出重点,此处省略了各 Text 的样式链,完整写法见第五章源码。)Column({ space: 16 }) 让三个文本之间保持 16vp 间距;justifyContent(FlexAlign.Center) 让内容整体在页面内垂直居中;backgroundColor(color) 接收调用方传入的颜色——每个页面因此拥有不同的背景色,滑动切换时色彩变化一目了然。

其中页码文本 '第 ' + (this.currentIndex + 1) + ' 个页面' 同样依赖 currentIndex:你滑动到第 5 页,页码会实时变成「第 5 个页面」,进一步印证「状态变化 → UI 自动刷新」的机制。

6.5 主布局 build()

build() {
  Column() {
    Text('Tabs 可滑动标签页示例')   // 标题栏
    Tabs({ ... }) { ... }            // 核心容器
    Row() { Button × 2 }             // 底部控制条
  }
  .width('100%')
  .height('100%')
}

build() 是组件的「UI 描述函数」,框架会据此构建界面。三段式结构清晰对应第四章的组件树。两个细节值得注意:

  • 标题栏 Text 设置 .height(52) 固定高度,.textAlign(TextAlign.Center) 让文字水平居中,.backgroundColor('#F1F3F5') 让它与下方白色标签栏区分开。
  • Tabs 上的 .layoutWeight(1) 是关键:在 Column 中,layoutWeight(1) 表示「吃掉主轴上的所有剩余空间」。这样无论屏幕多高,标题栏固定 52vp、控制条固定 64vp,其余高度全部归 Tabs 的内容区,布局永远撑满且不留白。

6.6 ForEach 批量生成标签页

ForEach(TAB_TITLES, (title: string, index: number) => {
  TabContent() {
    this.pageContentBuilder(title, TAB_COLORS[index])
  }
  .tabBar(this.tabBarBuilder(title, index))
}, (title: string) => title)

ForEach 是 ArkUI 的列表渲染指令,第一个参数是数据源数组,第二个参数是「为每一项生成 UI」的函数,第三个参数是 key 生成器。这里用它把 8 个标题一次性铺成 8 个 TabContent,每个都通过 .tabBar() 挂上自己的标签栏。key 生成器返回标题本身——标题互不相同,天然是稳定且唯一的键,保证框架能正确识别每个列表项、高效复用,避免数据刷新时出现「张冠李戴」。

6.7 onChange 与状态联动

.onChange((index: number) => {
  this.currentIndex = index;
  promptAction.showToast({ message: '已切换到:' + TAB_TITLES[index] });
})

onChange 在每次页面切换完成后触发(无论是手势滑动、点击标签还是 changeIndex() 调用)。回调里做两件事:把最新索引写入 currentIndex 驱动 UI 刷新,并弹一个 Toast 提示。这里可以清晰看到数据流的闭环:手势 → onChange → 更新状态 → UI 自动刷新

底部控制条的两个按钮则演示了数据流的另一条支线:

Button('下一页').onClick(() => {
  if (this.currentIndex < TAB_TITLES.length - 1) {
    this.tabsController.changeIndex(this.currentIndex + 1);
  }
})

点击按钮 → tabsController.changeIndex() → 触发切换动画 → 依然走到 onChange。边界判断 currentIndex < TAB_TITLES.length - 1 防止在最后一个页面继续「下一页」导致越界;changeIndex 传入越界索引时行为未定义,防御性检查是必要的。

七、关键属性详解:scrollable 与 barMode

第六章把代码讲透了,这一章专门回答一个高频疑问:scrollablebarMode 到底有什么区别?为什么示例里两个都要设置?

7.1 scrollable(true):内容区滑动切换

scrollableTabs 组件上最核心的交互开关,直译就是「可滚动」:

取值 行为
true(默认) 内容区支持手势左右滑动切换页面,滑动跟手、带切换动画
false 内容区不可滑动,只能通过点击标签或 TabsController.changeIndex() 切换

它作用于内容区——也就是 TabContent 所在的区域。打开它,用户就像翻卡片一样左右滑动浏览各页;关闭它,内容区变成「静止的」,交互入口只剩标签点击。多数场景下保持默认 true 即可,示例中显式写出 scrollable(true) 是为了突出主题,并让读者知道这个属性的存在与作用。

实现原理上,Tabs 内部是一个水平滚动的容器:所有 TabContent 从左到右依次排布,scrollable(true) 开启手势联动,滑动结束后由框架自动吸附到最近的页面并播放切换动画。这也是为什么切换如此丝滑——它本质上是一次「平滑滚动 + 对齐吸附」。

7.2 barMode:标签栏自身的排布与滚动

barMode 控制的是标签栏(顶部的标签行)的排布方式,枚举值有两个:

取值 布局行为 适用场景
BarMode.Fixed(默认) 所有标签均分整行宽度,铺满屏幕 标签少(2~4 个),希望标签铺满一行
BarMode.Scrollable 每个标签保持自身宽度,总宽超出屏幕时可左右滑动 标签多(5 个及以上),需要容纳大量标签

Fixed 模式下,8 个标签会把一行宽度切成 8 段,每个标签只有四十多 vp 宽,文字稍长就会被截断或换行,观感很差。因此当标签数量多时,工程上几乎必然选择 Scrollable 模式——这也是本示例选择 8 个标签的用意:让标签栏真正「可滑动」。

7.3 一张表分清两者

对比维度 scrollable(true) barMode(BarMode.Scrollable)
作用对象 内容区(TabContent 区域) 标签栏(tabBar 区域)
效果 手指左右滑动切换页面 手指左右滑动查看被隐藏的标签
默认值 true Fixed(不滚动)
关闭方式 scrollable(false) 改回 BarMode.Fixed
与页面切换的关系 直接触发切换 不切换页面,只浏览标签

一句话总结:scrollable 决定「内容能不能滑着换」,barMode 决定「标签栏放不下时怎么展示」。两者独立设置、互不干扰,一个体验完善的多标签应用通常同时启用。

7.4 使用 barMode(Scrollable) 的三个注意点

  1. 自定义标签必须设置固定宽度(如示例中的 .width(100)),否则标签栏不知道每个标签该占多宽,滚动步长会错乱。
  2. 标签栏高度要与 barHeight 对齐。示例中标签 Column 高度 56 与 Tabs.barHeight(56) 一致,避免标签文字被裁剪或出现多余留白。
  3. BarMode.Scrollable 与自定义 tabBar 更搭。默认样式下滚动时文字可能被截断,自定义标签配合固定宽度后,滚动、高亮、点击三者都能稳定工作。

八、运行验证与调试技巧

8.1 运行步骤

  1. 打开 DevEco Studio,等待工程同步完成(右下角出现「Sync Successful」)。
  2. 确认 EntryAbility.etsloadContent 的参数是 'pages/TabsSwipeableExample'
  3. 在设备列表选择模拟器或已连接的真机,点击工具栏的绿色「Run」按钮(或直接按 Shift+F10)。
  4. 首次运行会自动完成构建、签名、部署,数秒后应用在设备上启动。

8.2 预期效果对照

应用启动后,按下面的清单逐项验证,确保每个布局要点都真实生效:

操作 预期效果 对应的技术点
观察首屏 顶部标题栏 + 8 个标签,第一个标签高亮为红色加粗 默认 index: 0、自定义 tabBar 高亮
内容区左右滑动 页面随之切换,背景色变化,顶部弹出「已切换到:××」Toast scrollable(true) + onChange
标签栏左右滑动 标签行整体平移,露出「购物、视频、音乐、设置」等后续标签 barMode(BarMode.Scrollable)
点击任意标签 立即切换到对应页面,高亮同步移动 点击切换与状态联动
点击底部「下一页」 跳转到下一页,高亮与页码同步更新 TabsController.changeIndex()
在最后一个页面点「下一页」 无反应(按钮被边界判断拦截),不报错 越界防御

8.3 调试技巧

技巧一:善用 Previewer 预览器。 DevEco Studio 右侧的 Previewer 可以实时预览当前页面,无需启动模拟器就能看到布局效果。修改代码保存后预览自动刷新,非常适合快速迭代样式。注意:Previewer 对手势类交互支持有限,滑动切换仍建议在模拟器/真机上验证。

技巧二:用 hilog 输出日志。onChange 回调里临时加一行日志,可以观察到每次切换的索引变化:

hilog.info(0x0000, 'TabsDemo', '当前切换到第 %{public}d 个页面', index);

配合 DevEco Studio 的 Log 窗口(过滤标签 TabsDemo),可以清晰看到手势切换与 changeIndex() 切换都汇入同一条回调,验证两条切换路径的一致性。

技巧三:善用热重载。 模拟器/真机运行状态下修改代码并保存,DevEco Studio 会自动触发热重载(Hot Reload),界面即时更新,不必每次重新部署。注意:修改了 build() 之外的逻辑或新增文件时,热重载可能失效,需要手动重新运行。

九、常见问题与避坑指南

Q1:内容区左右滑动没有反应,页面切换不了?
检查 Tabs 上是否设置了 .scrollable(false),或者被父组件拦截了手势。另外确认 Tabs 内容区高度正常(见 Q4),若内容区高度为 0,手势无从谈起。

Q2:标签栏不能左右滑动,标签被挤在一起?
大概率是 barMode 仍为默认的 BarMode.FixedFixed 模式会把所有标签均分铺满一行,永远不会滚动。改为 barMode(BarMode.Scrollable) 并给每个自定义标签设置固定宽度即可。

Q3:自定义 tabBar 显示异常(文字截断、错位、高度不对)?
三处高度必须对齐:标签内容 Column 的高度、TabsbarHeight、以及标签内部文字的行高。示例中标签 ColumnbarHeight 均为 56,文字 15~18fp 在 56vp 高度内垂直居中,不会裁剪。

Q4:Tabs 内容区一片空白,或页面底部出现大片空白?
检查 Tabs 是否获得了明确高度。常见错误是把 Tabs 直接放在高度为「自适应」的 Column 中——此时 Tabs 可能被压缩到 0 或撑出异常高度。解法:外层容器设 height('100%')TabslayoutWeight(1)(如本示例),或用 height('100%') 直接固定。

Q5:onChange 回调里访问数组越界?
TAB_TITLES[index] 在正常情况下不会越界,因为 index 恒在 0 ~ 长度-1 范围内。但如果对数组做了动态增删(如后续在运行时添加标签),要同步保证 TAB_COLORS 长度一致,否则 TAB_COLORS[index] 可能取到 undefined 导致背景色异常。

Q6:切换后高亮状态与页面内容不一致?
几乎都是「状态不同步」造成的:比如把 currentIndex 写在了别处、忘记在 onChange 里赋值,或手动改了 Tabsindex 构造参数却没有同步 currentIndex。本示例的设计(单一 currentIndex 数据源 + onChange 统一回写)从结构上杜绝了这类错位。

Q7:页面切换有「白屏」或卡顿感?
内容较重的页面建议在 TabContent 里使用懒加载(LazyForEach),让页面滚动到附近时才真正渲染;也可以调用 controller.preload(index) 预加载相邻页面。另外避免在 TabContent 里放大型图片资源时未做压缩,这也是常见卡顿来源。

十、进阶扩展思路

本示例是「能跑、能看」的入门级实现,把它推向生产环境时,可以从以下几个方向扩展:

扩展一:数据驱动动态标签。TAB_TITLESTAB_COLORS 换成响应式数据结构(如 @State 数组或 @Observed 类),运行时增删标签、调整顺序,ForEach 会根据 key 自动做 diff 更新。比如资讯类 App 的「频道管理」功能,就是典型的动态标签增删场景。

扩展二:懒加载与预加载优化。 当每个标签页包含长列表或复杂组件时,把 TabContent 内的内容改为 LazyForEach 懒加载,并配合 controller.preload() 预加载相邻页面,可显著提升切换流畅度。

扩展三:状态管理与跨页通信。 真实 App 中标签页之间常常需要共享数据(如购物车角标、登录态)。可以使用 @Provide / @ConsumeAppStorage 在页面间共享状态,让切换页面不丢失数据。

扩展四:沉浸式与视觉定制。barOverlap(true) 让标签栏与内容区重叠,配合渐变背景实现沉浸式效果;或自定义标签栏为「图标 + 文字」组合,套用主题色与动效,把默认标签栏改造成品牌化的导航条。

扩展五:底部导航模式。barPosition 改为 BarPosition.EndTabs 立刻变成经典的底部导航(类似微信、淘宝的底部 Tab),一套代码两种形态,性价比极高。

扩展六:手势与动画细节。 通过 onAnimationStart / onAnimationEnd 在切换动画期间做额外处理(如联动顶部标题、统计埋点);结合 ScrollSwiper 等组件,可以组合出「顶部标签 + 内容轮播」的更复杂交互。

十一、总结

本文围绕一个可运行的鸿蒙工程,完整讲解了「Tabs + scrollable(true)」可滑动标签页布局。回顾全文,最重要的三个认知:

  1. 结构认知Tabs(容器)+ TabContent(页面)+ tabBar(标签)三位一体,外层用 Column + layoutWeight 解决高度分配问题。
  2. 双滑动认知scrollable(true) 让内容区可左右滑动切换页面(本示例主题),barMode(BarMode.Scrollable) 让标签栏可滚动容纳更多标签——两者作用对象不同、可以同时启用。
  3. 状态驱动认知:用 @State currentIndex 作为唯一数据源,配合 onChange 回写与 @Builder 复用,实现「数据一变,界面自动刷新」的声明式开发精髓。

从「最小示例」到「完整工程」,从「能跑」到「好用」,中间差的正是对组件属性、布局约束与状态管理的深入理解。希望这篇文章能成为你鸿蒙多标签开发路上的一份可靠参考。动手把代码跑起来,亲手滑动几次,你会有更直观的体会。

Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐