PC端应用的Tab页切换,说实话是个很容易被忽视的细节。很多开发者直接用系统自带的Tabs组件就完事了,切换效果就是"啪"地一下换了内容。能用吗?能。好看吗?真不好看。

我前段时间在做一个HarmonyOS6 PC端的项目,里面有个四个Tab的主页面。一开始用了默认的Tab切换效果,自己看着都觉得生硬。后来花了点时间手动实现了一个"滑动+淡入淡出"的组合过渡,效果一下子就上来了。同事路过看了一眼说:“这个切换很丝滑啊。”

今天就把这个实现过程分享给大家。

我们要实现什么

一个包含四个Tab的页面——首页、消息、发现、我的。点击任意Tab标签时,内容区域会有一个组合动画:

  • 新内容从一侧滑入,同时从透明变为不透明
  • 滑动方向跟Tab的相对位置有关:从左边的Tab切到右边的Tab,内容从右往左滑入;反过来则从左往右滑入
  • Tab标签本身有选中态——蓝色加粗文字+底部蓝色下划线

这个方向感是关键。如果不管怎么切都是同一个方向滑入,用户会觉得很迷惑。有方向感的切换,能让用户在潜意识里建立起"页面空间"的心智模型。

状态设计

先看需要哪些状态变量:

@Entry
@Component
struct TabSwitchDemo {
  @State currentPage: number = 0
  @State slideOffset: number = 0
  @State fadeIn: boolean = true
  private pages: string[] = ['首页', '消息', '发现', '我的']
  // ...
}

三个状态变量,各自负责不同的事:

  • currentPage:当前选中的Tab索引,决定显示哪个页面的内容
  • slideOffset:内容区域的水平偏移量,用来实现滑动效果
  • fadeIn:内容区域的透明度控制,true 为不透明,false 为透明

你可能会问,为什么用 slideOffset 而不是直接用 TransitionEffect?因为这里我们需要更精细地控制滑动方向,而 TransitionEffect 的方向控制相对固定。用 translate + animateTo 组合的方式更灵活。

Tab标签栏的实现

标签栏用的是 ForEach 渲染一个 Row 布局:

Row() {
  ForEach(this.pages, (page: string, idx: number) => {
    Text(page)
      .fontSize(14)
      .fontColor(idx === this.currentPage ? '#007DFF' : '#999999')
      .fontWeight(idx === this.currentPage ? FontWeight.Bold : FontWeight.Normal)
      .layoutWeight(1)
      .textAlign(TextAlign.Center)
      .padding({ top: 8, bottom: 8 })
      .border({
        width: { bottom: idx === this.currentPage ? 2 : 0 },
        color: '#007DFF'
      })
      .onClick(() => {
        // 切换逻辑...
      })
  })
}
.width('100%')

样式本身不复杂,重点说几个设计细节。

选中态的视觉反馈

选中Tab用蓝色(#007DFF)加粗文字 + 底部2像素蓝色下划线。未选中用灰色(#999999)常规文字 + 无下划线。

这个下划线效果是通过 borderwidth.bottom 来实现的,选中时设为2,未选中时设为0。这比用 Divider 组件或者单独放一个 Column 当指示器要简洁得多。

坦白讲,更精致的做法是用一个单独的指示器元素,配合 animateTo 实现指示器的滑动动画。但这里我们用 border 的方式已经足够清晰了,没必要过度设计。

为什么没用 Tabs 组件?

HarmonyOS6 ArkUI 本身提供了 Tabs 组件,自带切换功能。那为什么还要手动实现?

两个原因:

  1. 动画控制自由度。系统 Tabs 的切换动画是预设的,很难自定义"滑动方向跟随Tab相对位置"这种效果。手动实现可以用 animateTo 完全掌控动画过程。

  2. 学习价值。理解底层实现原理,遇到问题才知道怎么排查。直接用封装好的组件虽然快,但出了问题就抓瞎。

当然,在实际项目中,如果不需要特别定制化的切换效果,用系统 Tabs 组件是完全没问题的。手动实现更适合那些对动画有较高要求的场景。

核心动画逻辑:方向感知的滑动过渡

这是整篇文章最核心的部分。我们来看Tab点击时的切换逻辑:

.onClick(() => {
  // 计算滑动方向:往右切Tab,内容从右边滑入(正偏移)
  // 往左切Tab,内容从左边滑入(负偏移)
  const direction = idx > this.currentPage ? 1 : -1
  
  // 第一步:瞬间把内容"推"到起始位置
  this.slideOffset = direction * 100
  this.fadeIn = false
  
  // 第二步:用动画把内容"拉"回正常位置
  animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
    this.slideOffset = 0
    this.fadeIn = true
    this.currentPage = idx
  })
})

这段代码虽然不长,但逻辑非常精巧。我来逐步拆解。

第一步:计算方向

const direction = idx > this.currentPage ? 1 : -1

如果目标Tab在当前Tab的右边(索引更大),direction1,内容应该从右侧滑入。反之从左侧滑入。

第二步:瞬间设置初始偏移

this.slideOffset = direction * 100
this.fadeIn = false

这两行代码没有包在 animateTo 里,所以是瞬间执行的,没有动画效果。我们先把内容"推"到一个偏移位置(右偏100或左偏100),同时设为透明。

这就像拉弓——先把弓弦拉到最远的位置。

第三步:动画回到正常位置

animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
  this.slideOffset = 0
  this.fadeIn = true
  this.currentPage = idx
})

animateTo 是ArkUI的全局动画函数。它会捕获闭包内所有状态变量的变化,并为这些变化添加过渡动画。

在这个闭包里,我们把 slideOffset 从 ±100 变回 0(滑动效果),把 fadeInfalse 变回 true(淡入效果),同时更新 currentPage(切换内容)。

三个状态变化在同一个 animateTo 调用中,所以它们的动画是同步进行的,完美配合。

Curve.EaseInOut 缓动曲线让动画开头和结尾都比较柔和,中间加速。这是最通用的缓动曲线,适合绝大多数UI过渡场景。

内容区域的动画绑定

光有 animateTo 还不够。animateTo 负责"驱动"状态变化产生动画,但具体到某个组件上,它得知道自己该怎么"响应"这些状态变化。

这就是 .animation() 修饰器的作用:

Column() {
  // 内容区域...
}
.width('100%')
.height(160)
.backgroundColor(['#FF6B6B', '#FFA500', '#FFD93D', '#6BCB77', '#4ECDC4'][this.currentPage])
.borderRadius(16)
.opacity(this.fadeIn ? 1 : 0)
.translate({ x: this.slideOffset })
.animation({ duration: 350, curve: Curve.EaseInOut })

注意几个关键点:

  • .opacity(this.fadeIn ? 1 : 0):把透明度绑定到 fadeIn 状态
  • .translate({ x: this.slideOffset }):把水平偏移绑定到 slideOffset 状态
  • .animation({ duration: 350, curve: Curve.EaseInOut }):告诉这个组件,当绑定的状态变化时,用350ms的缓动动画来过渡

你可能注意到,这里的 duration 是350ms,比 animateTo 里的300ms多了50ms。这不是错误。animateTo 控制的是"状态变化的驱动时长",而 .animation() 控制的是"组件自身的响应时长"。组件的响应时间稍微长一点,会让动画的结尾有一个"余韵"的感觉,收尾更自然。

颜色随Tab变化

示例中内容区域的颜色会随Tab切换而变化。我们用了一个预设颜色数组:

private pages: string[] = ['首页', '消息', '发现', '我的']

每个Tab对应一个颜色——#FF6B6B(红)、#FFA500(橙)、#FFD93D(黄)、#6BCB77(绿)。切换Tab时,currentPage 变化,backgroundColor 也跟着变,配合 .animation() 修饰器,颜色过渡也是平滑的。

这个颜色变化在PC端其实很有用。不同的页面用不同的主题色,用户一扫颜色就知道自己在哪个Tab,不用抬头看标签栏。这在信息层级比较多的PC端应用中是个很实用的设计技巧。

完整代码

把上面说的内容整合在一起:

@Entry
@Component
struct TabSwitchDemo {
  @State currentPage: number = 0
  @State slideOffset: number = 0
  @State fadeIn: boolean = true
  private pages: string[] = ['首页', '消息', '发现', '我的']

  build() {
    Column() {
      Scroll() {
        Column() {
          Text('页面切换动画')
            .fontSize(18)
            .fontWeight(FontWeight.Bold)
            .margin({ bottom: 8 })

          Column() {
            // Tab 标签栏
            Row() {
              ForEach(this.pages, (page: string, idx: number) => {
                Text(page)
                  .fontSize(14)
                  .fontColor(idx === this.currentPage ? '#007DFF' : '#999999')
                  .fontWeight(idx === this.currentPage ? FontWeight.Bold : FontWeight.Normal)
                  .layoutWeight(1)
                  .textAlign(TextAlign.Center)
                  .padding({ top: 8, bottom: 8 })
                  .border({
                    width: { bottom: idx === this.currentPage ? 2 : 0 },
                    color: '#007DFF'
                  })
                  .onClick(() => {
                    const direction = idx > this.currentPage ? 1 : -1
                    this.slideOffset = direction * 100
                    this.fadeIn = false
                    animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
                      this.slideOffset = 0
                      this.fadeIn = true
                      this.currentPage = idx
                    })
                  })
              })
            }
            .width('100%')

            // 内容区域
            Column() {
              Column()
                .width('100%')
                .height(160)
                .backgroundColor(['#FF6B6B', '#FFA500', '#FFD93D', '#6BCB77', '#4ECDC4'][this.currentPage])
                .borderRadius(16)
                .opacity(this.fadeIn ? 1 : 0)
                .translate({ x: this.slideOffset })
                .animation({ duration: 350, curve: Curve.EaseInOut })

              Text(`当前页面: ${this.pages[this.currentPage]}`)
                .fontSize(18)
                .fontWeight(FontWeight.Bold)
                .margin({ top: 16 })
              Text('页面切换支持滑动和淡入淡出过渡')
                .fontSize(12)
                .fontColor('#999999')
                .margin({ top: 4 })
            }
            .width('100%')
            .padding(20)
            .alignItems(HorizontalAlign.Center)
          }
          .width('100%')
          .backgroundColor('#FFFFFF')
          .borderRadius(12)
          .padding(16)
        }
        .width('100%')
      }
      .layoutWeight(1)
    }
    .width('100%')
    .height('100%')
    .backgroundColor('#F5F6FA')
    .padding(16)
  }
}

页面转场设计的UX原则

聊完了代码实现,我们来聊聊页面转场设计的一些原则。这些东西不是HarmonyOS特有的,但对PC端应用开发非常重要。

方向一致性

前面我们实现的"方向感知滑动"就是这条原则的体现。在PC端应用中,用户通过Tab栏、侧边导航、面包屑等方式在不同页面间跳转。如果每次切换都有一个合理的方向感,用户的大脑会自动建立空间映射,对应用的整体结构有更清晰的认知。

时长控制

300ms是个"甜蜜点"。太短(<150ms),用户来不及感知动画,觉得突兀。太长(>500ms),用户觉得拖沓,特别是在频繁切换的场景下会变得烦人。

PC端的Tab切换我建议控制在250-350ms之间。如果是全屏页面跳转(比如从列表页进入详情页),可以适当延长到400-500ms。

不要滥用

不是所有切换都需要动画。在一个密集操作的后台管理系统里,用户可能在几秒钟内切换十几次Tab,花哨的动画反而是干扰。这种场景下,一个简单的淡入淡出就够了,甚至可以不要动画。

判断标准:如果用户在一秒内可能触发多次切换,动画要轻要快。如果切换频率不高(比如设置页的不同分区),动画可以稍微"有存在感"一些。

性能考量

PC端的性能通常比手机端好,但也不能掉以轻心。如果你的Tab页内容很复杂(比如包含大量图表、列表、图片),切换时的动画可能会和内容渲染抢GPU资源。

一个实用的优化技巧:在切换动画执行期间,延迟渲染新页面的"重"内容。先让动画跑完,再开始加载图表之类的东西。这样用户感知到的动画是流畅的,只是新页面的完整渲染会晚个几百毫秒。

扩展:Tab指示器的滑动动画

如果你觉得用 border 做下划线不够精致,可以用一个单独的指示器元素来实现滑动效果:

@State indicatorOffset: number = 0

// 在Tab栏下方放一个指示器
Stack({ alignContent: Alignment.Bottom }) {
  Row() {
    // Tab标签...
  }
  
  // 滑动指示器
  Column()
    .width('25%')  // 四个Tab,每个占25%
    .height(2)
    .backgroundColor('#007DFF')
    .translate({ x: this.indicatorOffset })
    .animation({ duration: 300, curve: Curve.EaseInOut })
}

// 切换时更新指示器位置
.onClick(() => {
  this.indicatorOffset = idx * tabWidth  // tabWidth需要动态计算
  // ...
})

这种方式的好处是指示器本身也有滑动动画,效果更精致。缺点是需要动态计算每个Tab的宽度,在PC端不同窗口尺寸下需要做适配。

踩坑记录

做这个Tab切换效果的过程中,我踩了两个坑:

坑1:瞬间设置初始偏移时也有动画

如果你把 this.slideOffset = direction * 100animateTo 放在同一个同步代码块里,ArkUI可能会把它们"合并"处理,导致你看到的不是"从偏移位置滑回",而是直接从0开始滑。

解决办法是确保 this.slideOffset = direction * 100this.fadeIn = falseanimateTo 之前执行。在ArkUI中,同步代码块里 animateTo 之前的状态变化不会被动画化,它们会立即生效。

坑2:快速连续点击Tab导致动画混乱

如果用户快速连续点击不同的Tab,动画可能会"叠加"导致视觉混乱。解决方案是加一个"动画锁"——在动画进行中忽略新的点击事件:

@State isAnimating: boolean = false

.onClick(() => {
  if (this.isAnimating) return
  this.isAnimating = true
  // ...动画代码...
  setTimeout(() => { this.isAnimating = false }, 350)
})

简单粗暴但很管用。

小结

Tab页切换动画在PC端应用中是个"锦上添花"的存在。它不是必须的,但做好了能显著提升应用的质感和用户体验。

核心就是三步:

  1. 根据目标Tab和当前Tab的相对位置计算滑动方向
  2. 瞬间设置初始偏移和透明状态
  3. animateTo 驱动回到正常位置,同时完成页面内容切换

记住 animateTo.animation() 的配合关系——前者是"发令枪",后者是"跑道"。状态变化在 animateTo 里触发,动画效果在 .animation() 里呈现。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐