底部导航栏的"工具"Tab 跳转到的不是单个功能页,而是一个聚合页面——旅行工具箱。这个页面把四个独立的功能模块塞进了一个 Scroll 里:常用工具网格、出行清单、小费计算器、世界时间。每个模块的交互模式都不一样,放在一起恰好展示了 ArkTS 里几种典型的数据处理方式。
完整效果
在这里插入图片描述
在这里插入图片描述

页面结构概览

从截图看,页面从上到下依次是:

  1. 常用工具:2×2 网格,四个工具卡片,点击跳转到对应页面
  2. 出行清单:10 项待办列表,可勾选,带进度条
  3. 小费计算器:输入金额 + 选择比例 + 实时计算结果
  4. 世界时间:6 个城市的当前时间,基于系统时钟 + UTC 偏移计算

整体布局用 Scroll > Column 实现,四个模块依次纵向排列,内容超出屏幕时上下滚动。

常用工具:2×2 网格

Grid() {
  GridItem() { this.ToolCard('💱', '汇率换算', '#00B894', () => { router.pushUrl({ url: 'pages/CurrencyPage' }) }) }
  GridItem() { this.ToolCard('🗣️', '旅行短语', '#8A5DFA', () => { router.pushUrl({ url: 'pages/PhraseBook' }) }) }
  GridItem() { this.ToolCard('🧳', '行程规划', '#5DADE2', () => { router.pushUrl({ url: 'pages/TripPlanner' }) }) }
  GridItem() { this.ToolCard('🍜', '美食指南', '#E8734A', () => { router.pushUrl({ url: 'pages/CuisinePage' }) }) }
}
.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)

在这里插入图片描述

和首页的旅行工具区域功能相同,但布局不同——首页是横向一排四个按钮,这里是一个 2×2 的网格。同样的功能入口,不同的展示方式,这是 App 里"首页快捷入口 vs 工具页完整列表"的常见分工。

ToolCard Builder 的实现和首页的 ToolBtn 类似,但多了一个圆角背景:

@Builder ToolCard(icon: string, label: string, color: string, action: () => void) {
  Column() {
    Text(icon).fontSize(32).margin({ bottom: 8 })
    Text(label).fontSize(13).fontWeight(FontWeight.Medium).fontColor(T1)
  }.width('100%').padding(16).backgroundColor(color + '10').borderRadius(14)
  .onClick(action)
}

color + '10' 是主色加 6% 透明度的浅色背景,和首页的 ToolBtn 用的是同一个透明度值。这种一致的视觉处理让用户在不同页面看到相同的功能入口时,能快速识别"这是同一类东西"。

出行清单:带进度的待办列表

数据结构

interface CheckItem { name: string; done: boolean; icon: string }

@State checkItems: CheckItem[] = [
  { name: '护照/签证', done: false, icon: '📕' },
  { name: '机票/行程单', done: false, icon: '✈️' },
  { name: '充电器/转换插头', done: false, icon: '🔌' },
  { name: '常用药品', done: false, icon: '💊' },
  { name: '换洗衣物', done: false, icon: '👕' },
  { name: '洗漱用品', done: false, icon: '🪥' },
  { name: '相机', done: false, icon: '📷' },
  { name: '雨伞/雨衣', done: false, icon: '☂️' },
  { name: '移动电源', done: false, icon: '🔋' },
  { name: '防晒霜', done: false, icon: '🧴' },
]

在这里插入图片描述

和行程规划的 PlanItem 结构类似,但没有 day 字段——出行清单是扁平列表,不需要天数序号。多了 icon 字段给每项配一个 emoji 图标。

勾选逻辑

private toggleCheck(idx: number): void {
  this.checkItems[idx].done = !this.checkItems[idx].done
  const copy: CheckItem[] = []
  for (let i: number = 0; i < this.checkItems.length; i++) { copy.push(this.checkItems[i]) }
  this.checkItems = copy
}

和行程规划的 toggleDay 完全一样的模式——修改属性 + 复制数组 + 替换引用。这个"复制触发刷新"的模式在需要动态修改数组的场景里反复出现,已经是一个固定套路了。

进度展示

Row() {
  Text('🎒 出行清单').fontSize(16).fontWeight(FontWeight.Bold).fontColor(T1)
  Blank()
  Text(this.doneCount() + '/' + this.checkItems.length).fontSize(12)
   .fontColor(A).fontWeight(FontWeight.Bold)
}
Row() {}
  .width((this.doneCount() / this.checkItems.length) * 100)
  .height(4).borderRadius(2).backgroundColor(A)

进度条比行程规划的更细——高度只有 4vp(行程规划是 6vp)。因为出行清单的卡片更紧凑(每项只有文字 + 勾选框),进度条太粗会显得不协调。这个高度差异是根据卡片密度调整的。

勾选框的圆角差异

Row() {}.width(20).height(20).borderRadius(6)
  .backgroundColor(item.done ? A : 'transparent')
  .border({ width: 1.5, color: item.done ? A : '#D0CED8' })

出行清单的勾选框用 borderRadius(6)(圆角 6),而行程规划用 borderRadius(11)(圆形)。视觉上一个是小方块,一个是正圆。这种差异可能是有意的——出行清单的卡片更矮更紧凑,小方块比正圆更节省垂直空间。

小费计算器:实时计算的输入输出

交互流程

用户输入消费金额 → 选择小费比例 → 实时显示小费金额。三个操作都会触发重新计算。

数据定义

@State tipAmount: string = '100'
@State tipPercent: number = 15
@State tipResult: string = '15'

三个 @State 分别对应输入金额、选中比例、计算结果。tipAmount 是字符串类型,因为 TextInputonChange 回调返回的是 string,直接存字符串避免了类型转换。

计算逻辑

private calcTip(): void {
  const amt: number = parseFloat(this.tipAmount) || 0
  this.tipResult = (amt * this.tipPercent / 100).toFixed(1)
}

parseFloat || 0 的防御性处理和汇率换算页面一样。计算公式是 金额 × 比例 / 100,结果用 toFixed(1) 保留一位小数。

这个方法在三个地方被调用:

  • 输入金额变化时:this.tipAmount = v; this.calcTip()
  • 选择比例时:this.tipPercent = p; this.calcTip()
  • 页面初始化时也会被调用一次(因为 @State 有初始值)

比例选择器

Row({ space: 8 }) {
  ForEach([10, 15, 18, 20, 25], (p: number) => {
    Text(p + '%').fontSize(12)
      .fontWeight(this.tipPercent === p ? FontWeight.Bold : FontWeight.Regular)
      .fontColor(this.tipPercent === p ? '#FFFFFF' : T2)
      .padding({ left: 14, right: 14, top: 6, bottom: 6 })
      .borderRadius(14)
      .backgroundColor(this.tipPercent === p ? A : '#F5F5F7')
      .onClick(() => { this.tipPercent = p; this.calcTip() })
  })
}

五个预设比例用 Row 横向排列,选中态用橙色背景 + 白色文字,未选中态用灰色背景 + 灰色文字。和旅行短语页面的语言切换用的是同一套视觉模式——选中态高亮,未选中态低调。

比例的间距用 Row({ space: 8 }) 统一控制,不需要给每个 Text 单独设 margin。

结果展示

Row() {
  Text('小费金额').fontSize(14).fontColor(T1).layoutWeight(1)
  Text('¥' + this.tipResult).fontSize(24).fontWeight(FontWeight.Bold).fontColor(A)
}
.width('100%').padding(12).backgroundColor('#FFF5F0').borderRadius(10)

结果用大号橙色字体显示在浅橙色背景上,视觉上和输入区域形成对比——输入区是白底灰框,结果区是橙底橙字。这种"输入低调、输出高调"的视觉层次让用户一眼就能看到计算结果。

世界时间:系统时钟 + UTC 偏移

城市数据

@Builder TimeCard(flag: string, city: string, offset: number, country: string) {
  Row() {
    Text(flag).fontSize(20).width(40).height(40).borderRadius(10)
      .backgroundColor('#F5F5F7').textAlign(TextAlign.Center)
    Column() {
      Text(city).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)
      Text(country + ' · UTC' + (offset >= 0 ? '+' : '') + offset.toString())
        .fontSize(11).fontColor(T3)
    }.alignItems(HorizontalAlign.Start).margin({ left: 12 }).layoutWeight(1)
    Column() {
      Text(this.getTime(offset)).fontSize(16).fontWeight(FontWeight.Bold).fontColor(A)
      Text('当地时间').fontSize(10).fontColor(T3)
    }.alignItems(HorizontalAlign.End)
  }
}

在这里插入图片描述

每个城市卡片分三部分:左侧国旗 emoji(40×40 圆角容器),中间城市名和时区信息,右侧当前时间。国旗 emoji 用 textAlign(TextAlign.Center) 在容器内居中。

时区显示格式是 UTC+8UTC-5 等。(offset >= 0 ? '+' : '') 处理正负号——正偏移加 + 号,负偏移自带 - 号。

时间计算

private getTime(offset: number): string {
  void this.timeStamp
  const now: Date = new Date()
  const utc: number = now.getTime() + now.getTimezoneOffset() * 60000
  const local: Date = new Date(utc + offset * 3600000)
  const h: string = local.getHours().toString()
  const m: string = local.getMinutes().toString()
  return h + ':' + m
}

这段代码有三个步骤:

  1. 获取当前 UTC 时间now.getTimezoneOffset() 返回本地时区和 UTC 的分钟差(比如北京时间是 -480 分钟)。加上这个偏移量后得到 UTC 时间戳。
  2. 加上目标城市的偏移offset * 3600000 把小时偏移转换成毫秒(1 小时 = 3600000 毫秒)。
  3. 格式化输出:取 getHours()getMinutes() 拼成 HH:MM 格式。

void this.timeStamp 是一个关键技巧——它不读取 timeStamp 的值,但引用了这个变量。当 timeStamp 变化时(用户点击"刷新"按钮),getTime 方法会被重新调用,因为它依赖了这个变量。如果去掉这行,getTime 只在页面初始化时调用一次,之后时间不会更新。

这个 void 引用是一种"手动强制刷新"的模式——在没有定时器的场景下,通过改变一个辅助变量来触发重新计算。

刷新机制

Text('🔄 刷新').fontSize(12).fontColor(A).fontWeight(FontWeight.Medium)
  .onClick(() => { this.timeStamp++ })

点击"刷新"按钮时,this.timeStamp++ 改变了 @State 变量的值,触发 UI 重新渲染,所有 getTime 调用都会用最新的系统时间重新计算。

这是一个简易的"手动刷新"方案。更好的做法是用 setInterval 每分钟自动刷新,但在原型阶段手动刷新已经够用——用户需要看最新时间时点一下就行。

四个模块的数据模式

模块 数据来源 变化频率 交互方式
常用工具 静态数据 不变 点击跳转
出行清单 本地 @State 用户操作时 勾选
小费计算器 用户输入 + 计算 实时 输入 + 选择
世界时间 系统时钟 每分钟 手动刷新

四个模块代表了四种不同的数据生命周期:

  • 静态数据:常用工具的四个入口永远不会变
  • 用户状态:出行清单的勾选状态是用户自己维护的
  • 实时计算:小费计算器每次输入变化都会重新计算
  • 系统时钟:世界时间依赖系统时间,手动触发刷新

这种"一个页面包含多种数据模式"的设计在工具类 App 里很常见。每个模块独立运作,互不影响,但共享同一个页面的导航和背景。从代码角度看,四个模块的 @State 完全独立——修改出行清单不会影响小费计算器,修改小费比例不会影响世界时间。

Builder 复用

页面用了两个 Builder:

  • ToolCard:常用工具的 2×2 卡片,接收图标、文字、颜色、回调四个参数
  • TimeCard:世界时间的每个城市行,接收国旗、城市、偏移量、国家四个参数

出行清单的每一项没有抽 Builder,因为它在 ForEach 里只出现一次,抽出来的收益不大。小费计算器的比例选择也没有抽 Builder,五个按钮的样式差异(选中 vs 未选中)用条件表达式内联处理更直观。

选哪些 UI 模式抽 Builder,哪些内联处理,标准只有一个:重复出现两次以上的 UI 模式值得抽 Builder。只出现一次的,内联就行。

Logo

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

更多推荐