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

页面结构概览
从截图看,页面从上到下依次是:
- 常用工具:2×2 网格,四个工具卡片,点击跳转到对应页面
- 出行清单:10 项待办列表,可勾选,带进度条
- 小费计算器:输入金额 + 选择比例 + 实时计算结果
- 世界时间: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 是字符串类型,因为 TextInput 的 onChange 回调返回的是 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+8、UTC-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
}
这段代码有三个步骤:
- 获取当前 UTC 时间:
now.getTimezoneOffset()返回本地时区和 UTC 的分钟差(比如北京时间是 -480 分钟)。加上这个偏移量后得到 UTC 时间戳。 - 加上目标城市的偏移:
offset * 3600000把小时偏移转换成毫秒(1 小时 = 3600000 毫秒)。 - 格式化输出:取
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。只出现一次的,内联就行。
更多推荐

所有评论(0)