引子:底部 Tab 太丑,换成顶部导航

星空运势应用有三个页面:转盘、星图、塔罗。一开始用的是底部 Tab 导航,但放上去发现和暗色主题不搭——Tab 栏的白色背景在深色页面上特别突兀。

索性把导航移到顶部,用自定义的图标+文字+下划线实现。效果比底部 Tab 好很多,但实现起来有几个细节要注意:图标选中时的缩放动画、下划线的宽度过渡、和 Swiper 的联动。

完整效果
在这里插入图片描述

顶部导航栏的结构

导航栏分两部分:标题区和图标区。

┌─────────────────────────────┐
│         塔罗牌阵             │  ← 标题(随页面切换)
│   默念问题·翻开命运之牌      │  ← 副标题
├─────────────────────────────┤
│  🎡      ✨      🃏         │  ← 图标(选中放大)
│  转盘运势  星座星图  塔罗占卜  │  ← 文字(选中变色)
│  ━━━━━━━                   │  ← 下划线(选中显示)
└─────────────────────────────┘

标题区:动态切换

Column() {
  Text(this.navTitle())
    .fontSize(16).fontWeight(FontWeight.Bold).fontColor('#E8DFF8')
    .animation({ duration: 250 })
  Text(this.navSub())
    .fontSize(10).fontColor('#6B5B9A').margin({ top: 4 })
    .animation({ duration: 250 })
}
.width('100%').alignItems(HorizontalAlign.Center)
.padding({ top: 10, bottom: 8 })

在这里插入图片描述

标题和副标题通过 this.page 状态动态切换:

navTitle(): string {
  if (this.page === 0) { return '命运转盘'; }
  if (this.page === 1) { return '星座星图'; }
  return '塔罗牌阵';
}

animation({ duration: 250 }) 让标题切换时有渐变效果。250ms 是经过考虑的——太短看不清变化,太长会觉得卡顿。

图标区:三栏导航

Row() {
  ForEach([0, 1, 2], (i: number) => {
    Column() {
      Text(this.navIcon(i))
        .fontSize(22)
        .opacity(i === this.page ? 1 : 0.4)
        .scale({ x: i === this.page ? 1.15 : 1, y: i === this.page ? 1.15 : 1 })
        .animation({ duration: 300, curve: Curve.EaseOut })
      Text(this.navLabel(i))
        .fontSize(11)
        .fontWeight(i === this.page ? FontWeight.Bold : FontWeight.Normal)
        .fontColor(i === this.page ? '#FFD700' : '#5B4FAF')
        .animation({ duration: 250 })
        .margin({ top: 6 })
      Divider()
        .width(i === this.page ? 24 : 0).height(2).color('#FFD700').borderRadius(1)
        .margin({ top: 8 })
        .animation({ duration: 300, curve: Curve.EaseOut })
    }
    .width(90).padding({ top: 8, bottom: 4 })
    .onClick(() => { this.page = i; })
  })
}
.width('100%').justifyContent(FlexAlign.SpaceEvenly)

在这里插入图片描述

这个导航栏有三个视觉层次:图标、文字、下划线。每个层次在选中/未选中状态之间切换,带动画。

选中状态的三重反馈

选中状态通过三个维度反馈给用户:

1. 图标:透明度 + 缩放

Text(this.navIcon(i))
  .fontSize(22)
  .opacity(i === this.page ? 1 : 0.4)
  .scale({ x: i === this.page ? 1.15 : 1, y: i === this.page ? 1.15 : 1 })
  .animation({ duration: 300, curve: Curve.EaseOut })
  • 选中:不透明(1)+ 放大 15%(1.15)
  • 未选中:半透明(0.4)+ 原始大小(1)

为什么用 1.15 而不是 1.2?

放大 15% 刚好能感觉到变化,但不会太大导致布局抖动。1.2 会让图标明显"跳出",视觉上不协调。

2. 文字:粗细 + 颜色

Text(this.navLabel(i))
  .fontSize(11)
  .fontWeight(i === this.page ? FontWeight.Bold : FontWeight.Normal)
  .fontColor(i === this.page ? '#FFD700' : '#5B4FAF')
  .animation({ duration: 250 })
  • 选中:加粗 + 金色
  • 未选中:正常 + 暗紫色

金色(#FFD700)和暗紫色(#5B4FAF)是这个应用的主题色,选中状态用主题色突出,未选中用低饱和度的颜色弱化。

3. 下划线:宽度过渡

Divider()
  .width(i === this.page ? 24 : 0).height(2).color('#FFD700').borderRadius(1)
  .margin({ top: 8 })
  .animation({ duration: 300, curve: Curve.EaseOut })
  • 选中:宽度 24px
  • 未选中:宽度 0(隐藏)

下划线的宽度动画是最明显的反馈——用户一眼就能看出哪个页面被选中。borderRadius(1) 让下划线两端圆润,不会太生硬。

为什么需要三重反馈?

单独一个维度(比如只改颜色)也能区分选中状态,但三重反馈的好处是:

  1. 容错性:即使用户色弱(看不清颜色差异),也能通过大小和下划线判断
  2. 层次感:三个维度同时变化,视觉冲击更强
  3. 专业感:细节到位的应用看起来更精致

Swiper 联动:导航栏和页面的同步

导航栏和 Swiper 的联动通过 this.page 状态实现:

// Swiper 的 onChange 回调
Swiper() { ... }
  .onChange((idx: number) => { this.page = idx; })

// 导航栏的点击事件
.onClick(() => { this.page = i; })

双向绑定

  • 滑动 Swiper → 更新 this.page → 导航栏高亮对应图标
  • 点击导航栏 → 更新 this.page → Swiper 切换到对应页面

这种"状态驱动"的联动方式比手动操作 DOM 简洁得多。

Swiper 的配置

Swiper() {
  this.WheelPage();
  this.StarPage();
  this.TarotPage();
}
.index(this.page)
.indicator(false)
.loop(false)
.duration(300)
.onChange((idx: number) => { this.page = idx; })
.layoutWeight(1)
.width('100%')
  • .indicator(false):隐藏默认指示器(用自定义导航代替)
  • .loop(false):禁用循环滑动(三个独立页面不需要循环)
  • .duration(300):页面切换动画时长
  • .layoutWeight(1):占据剩余空间

颜色系统:紫色 + 金色的设计逻辑

这个应用的颜色系统以紫色和金色为主调。为什么选这两个颜色?

紫色:神秘感

紫色在色彩心理学中代表神秘、灵性、魔法。星座和塔罗都和"神秘"相关,紫色是天然的选择。

代码中用了多种紫色:

颜色 用途 说明
#06060F 页面背景 最深的紫黑色
#08081A 导航栏背景 略浅的深紫色
#0E0B25 卡片背景 中等深度的紫色
#1C1545 选中背景 较亮的紫色
#5B4FAF 次要文字 中饱和度紫色
#8B7BC0 辅助文字 浅紫色
#D4C5F0 标题文字 亮紫色
#E8DFF8 导航标题 最亮的紫色

从深到浅,形成了完整的紫色梯度。这种"同色系多梯度"的设计让界面有层次感,不会单调。

金色:高级感

金色代表财富、权力、高级感。在这个应用中,金色用于强调和高亮:

颜色 用途
#FFD700 选中状态、主按钮、重要文字
#F59E0B 转盘指针、星座图标边框

金色和紫色是互补色,搭配在一起视觉冲击很强。但金色不能用太多,否则会显得"土豪"。代码中金色只用于关键元素(选中状态、按钮、指针),比例控制得当。

暗色主题的实现

暗色主题不是简单地把背景换成黑色,而是需要重新设计整个颜色体系:

  1. 背景色:不是纯黑 #000000,而是深紫 #06060F。纯黑在 OLED 屏幕上会导致"发光"效果
  2. 文字色:不是纯白 #FFFFFF,而是浅紫 #E8DFF8。纯白在深色背景上对比度太高,刺眼
  3. 边框色:用低饱和度的紫色 #1C1545,而不是灰色。保持色调统一
  4. 强调色:用金色 #FFD700,在深色背景上特别醒目

颜色的可访问性

暗色主题最容易犯的错误是对比度不够。代码中的颜色选择考虑了可访问性:

  • 标题文字 #E8DFF8 在背景 #06060F 上的对比度约 12:1(远超 WCAG AA 标准的 4.5:1)
  • 次要文字 #8B7BC0 在背景 #0E0B25 上的对比度约 5:1(刚好达标)
  • 弱化文字 #4B3F70 在背景 #06060F 上的对比度约 3:1(低于标准,但用于装饰性文字可接受)

踩坑记录

坑 1:导航栏的高度不固定

导航栏的高度取决于标题和副标题的行数。如果标题太长换行,导航栏高度会变化,导致 Swiper 区域抖动。

解决方案是给导航栏设置固定高度,或者用 aspectRatio 限制比例。

坑 2:图标的缩放导致布局抖动

.scale(1.15) 会让图标占用更多空间,可能挤占相邻元素。代码中给每个导航项设置了固定宽度(90),避免了这个问题。

坑 3:动画时长不一致

图标动画 300ms,文字动画 250ms,下划线动画 300ms。这种微小的差异让用户感觉"有节奏",而不是所有元素同时变化。但如果差异太大(比如图标 500ms,文字 100ms),会显得混乱。

坑 4:Swiper 的预加载

Swiper 会预加载相邻页面,如果页面很复杂,可能导致内存占用过高。可以通过 .cachedCount(1) 控制预加载数量。

代码改进建议

1. 导航栏组件化

当前导航栏代码直接写在主组件里,如果要复用(比如在其他应用中),需要提取成独立组件:

@Component
struct TopNav {
  @Prop page: number = 0;
  @Prop titles: string[] = [];
  @Prop icons: string[] = [];
  @Prop labels: string[] = [];
  onNavigate: (idx: number) => void = () => {};
  
  build() { ... }
}

2. 颜色系统外部化

当前颜色值散落在代码各处。建议像骰子应用一样,用主题配置文件统一管理:

const Theme = {
  bg: '#06060F',
  surface: '#0E0B25',
  primary: '#FFD700',
  text: '#E8DFF8',
  // ...
};

3. 导航栏的响应式设计

当前导航栏固定三栏,如果要扩展到四栏或五栏,需要修改布局。建议用 GridFlex 实现自适应。

4. 无障碍支持

当前导航栏没有无障碍属性。建议添加 accessibilityText,让屏幕阅读器能识别导航项。

总结

顶部导航栏和颜色系统是这个应用的"门面"——用户第一眼看到的就是导航栏和整体配色。好的导航栏让用户快速理解应用结构,好的颜色系统让用户愿意继续使用。

适用边界:这个部分适合用作 ArkUI 自定义导航和暗色主题的学习案例,涵盖了自定义导航栏、动画编排、颜色系统、Swiper 联动等核心知识点。但如果要上架应用商店,还需要补充无障碍支持、响应式设计、组件化、主题外部化等内容。建议在此基础上逐步扩展,而不是一次性做完所有功能。

说白了,导航栏就是"告诉用户在哪",颜色系统就是"让用户愿意待着"。两个都做好了,应用的"第一印象"就稳了。

Logo

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

更多推荐