HarmonyOS旅行探索——行程规划与美食指南页面实现
首页的四个旅行工具入口中,行程规划和美食指南代表了两种不同的数据处理方式——行程规划是"用户可操作的动态列表",美食指南是"从数据源聚合的只读列表"。从截图和代码来看,两个页面在视觉上都用了白色卡片 + 纵向滚动的结构,但背后的状态管理和数据流完全不同。
完整效果


TripPlanner:行程规划的待办交互
截图分析
从截图看,行程规划页面的结构很清晰:顶部导航栏(返回 + 标题 + 保存),一条进度条显示"已完成 0/5 天",一个输入框带"添加"按钮,下方是五张行程卡片(Day 1 到 Day 5),每张卡片右侧有一个圆形勾选框。整体风格是白色卡片 + 浅灰背景,和 App 的其他页面保持一致。
数据结构
interface PlanItem { day: number; title: string; desc: string; done: boolean }
四个字段定义了每个行程项的信息:天数序号、标题、描述、完成状态。done 是唯一会动态变化的字段——用户勾选时它在 true 和 false 之间切换。
默认数据是五天的行程:
@State days: PlanItem[] = [
{ day: 1, title: '抵达 + 市区探索', desc: '入住酒店,适应时差,傍晚城市漫步', done: false },
{ day: 2, title: '文化之旅', desc: '参观博物馆和历史遗迹', done: false },
{ day: 3, title: '自然风光', desc: '郊外一日游,亲近大自然', done: false },
{ day: 4, title: '美食巡礼', desc: '当地市场 + 特色餐厅', done: false },
{ day: 5, title: '自由活动 + 购物', desc: '买纪念品,享受悠闲时光', done: false },
]

勾选逻辑:引用变更触发刷新
private toggleDay(idx: number): void {
this.days[idx].done = !this.days[idx].done
const copy: PlanItem[] = []
for (let i: number = 0; i < this.days.length; i++) { copy.push(this.days[i]) }
this.days = copy
}
这是整个页面最关键的技术点。ArkTS 的 @State 追踪的是引用变更,不是属性变更。如果只写 this.days[idx].done = !this.days[idx].done,数组的引用没变,@State 认为数据没有更新,UI 不会重新渲染。
解决办法是创建一个新数组,把旧数据复制进去,然后用 this.days = copy 替换整个引用。这一步"复制 + 替换"是 ArkTS 状态管理里最常见的坑——新手很容易只改属性不改引用,然后疑惑"为什么 UI 不刷新"。
for 循环复制而不是用 slice() 或展开运算符 [...this.days],是因为 ArkTS 里数组方法的类型推断有时不够精确,手动循环虽然啰嗦但类型安全。
添加行程
@State newTitle: string = ''
private addDay(): void {
if (this.newTitle.trim().length === 0) return
this.days.push({
day: this.days.length + 1,
title: this.newTitle,
desc: '自定义行程',
done: false
})
this.newTitle = ''
const copy: PlanItem[] = []
for (let i: number = 0; i < this.days.length; i++) { copy.push(this.days[i]) }
this.days = copy
}
添加流程三步走:检查输入非空 → push 新对象到数组 → 复制数组触发刷新。新行程的 day 序号用 this.days.length + 1 计算——原来有 5 天,新添加的就是 Day 6。
newTitle 也是 @State,所以 this.newTitle = '' 会清空输入框。两个 @State 变量在同一方法里被修改,ArkTS 会在方法结束后统一触发一次重新渲染,不会出现两次刷新的问题。
进度条
Row() {
Text('已完成 ' + this.doneCount() + '/' + this.days.length + ' 天')
.fontSize(13).fontColor(T2)
Blank()
Row() {}
.width((this.doneCount() / this.days.length) * 100)
.height(6).borderRadius(3).backgroundColor(A)
}
进度条的宽度是动态计算的:(完成数 / 总数) × 100。5 天里完成了 3 天,宽度就是 3/5 × 100 = 60vp。Blank() 把进度条推到右边,和左侧文字形成左右分布。
doneCount() 方法和运动健康 App 里的计数逻辑一样——用 for 循环遍历数组统计 done === true 的数量。
每日卡片的勾选 UI
Stack({ alignContent: Alignment.Center }) {
Row() {}.width(22).height(22).borderRadius(11)
.backgroundColor(item.done ? A : 'transparent')
.border({ width: 1.5, color: item.done ? A : '#D0CED8' })
if (item.done) {
Text('✓').fontSize(12).fontWeight(FontWeight.Bold).fontColor(Color.White)
}
}
.onClick(() => { this.toggleDay(idx) })
勾选框用 Stack 实现——底层是一个 22×22 的圆形,未选中时是透明背景 + 灰色边框,选中时是橙色填充。上层用 if (item.done) 条件渲染一个白色勾号。Stack({ alignContent: Alignment.Center }) 让勾号自动居中。
卡片左侧的竖线也根据完成状态改变颜色:
Column() {}.width(2).height(20)
.backgroundColor(item.done ? A + '40' : '#F0EEF4')
已完成的竖线用主色的 25% 透明度,未完成的用浅灰色。这个细微的颜色变化配合勾选框和标题的删除线,形成了三重视觉反馈——勾选框告诉你"勾了",删除线告诉你"完成了",竖线颜色告诉你"这一项处理过了"。
CuisinePage:数据聚合的只读列表
美食指南页面是一张长列表,每张卡片左侧是食物 emoji,中间是名称(带城市标签)和描述,右侧是价格。从截图看,食物按城市分组排列——先是东京的寿司、章鱼烧、天妇罗,然后是巴黎的可颂、法式蜗牛、马卡龙,接着是巴厘岛的脏鸭餐。每个城市名称用橙色小标签标注在食物名称旁边。
数据聚合逻辑
@State allCuisines: FoodItem[] = []
aboutToAppear(): void {
const list: FoodItem[] = []
for (let i: number = 0; i < CITIES.length; i++) {
for (let j: number = 0; j < CITIES[i].cuisines.length; j++) {
list.push({
city: CITIES[i].name,
emoji: CITIES[i].cuisines[j].emoji,
name: CITIES[i].cuisines[j].name,
desc: CITIES[i].cuisines[j].desc,
price: CITIES[i].cuisines[j].price
})
}
}
this.allCuisines = list
}

双重 for 循环遍历所有城市的所有美食,把嵌套结构拍平成一维数组。每个 FoodItem 额外加了一个 city 字段——原始 Cuisine 接口里没有这个字段,但列表页面需要显示"这道菜来自哪个城市",所以必须在遍历时手动补上。
这是数据聚合的一个典型模式:原始数据的结构不一定满足展示需求,需要在中间层做一次转换。aboutToAppear 就是做这个转换的时机——在页面渲染之前,把数据处理成 UI 需要的格式。
为什么不直接在 ForEach 里遍历 CITIES 然后嵌套遍历 cuisines?因为那样每张卡片的城市信息需要通过闭包捕获外层循环变量,在 ArkTS 里这种写法的类型推断容易出问题。先聚合再遍历,数据流更清晰。
FoodItem 接口
interface FoodItem {
city: string; emoji: string; name: string; desc: string; price: string
}
五个字段都是 string 类型,包括价格。价格用字符串而不是数字是因为它包含了单位信息——“1000-5000日元”、“€1-3”、“$3-5/片”。如果用数字类型,就只能存数值,单位需要另外处理。
城市标签的设计
Row() {
Text(f.name).fontSize(14).fontWeight(FontWeight.Bold).fontColor(T1)
Text(f.city).fontSize(10).fontColor(A)
.padding({ left: 4, right: 4, top: 1, bottom: 1 })
.backgroundColor('#FFF5F0').borderRadius(3).margin({ left: 6 })
}
城市标签用极小的字号(10px)和浅橙色背景,和食物名称形成明显的主次关系。#FFF5F0 是主色 #FF6B35 的极浅版本(大约 3% 不透明度),视觉上是淡淡的橙色底色,不会抢主信息的焦点。
标签的内边距非常紧凑——左右 4px,上下 1px。这种"小标签"设计在很多 App 里都能看到(比如商品分类标签、状态标签),它的核心原则是:标签是辅助信息,不应该比主信息更显眼。
emoji 图标的底色容器
Text(f.emoji).fontSize(32).width(56).height(56)
.borderRadius(14).backgroundColor('#F5F5F7')
.textAlign(TextAlign.Center)
emoji 没有直接放在卡片上,而是放在一个 56×56 的浅灰色圆角容器里。#F5F5F7 是一个接近白色的浅灰,给 emoji 提供了一个"底座",让它在白色卡片背景上不会显得太突兀。
textAlign(TextAlign.Center) 让 emoji 在容器内居中。emoji 本身的渲染在不同设备上可能有微小的位置偏差,加了居中对齐后能保证视觉一致性。
两个页面的状态管理对比
| 维度 | TripPlanner | CuisinePage |
|---|---|---|
| @State 数量 | 2(days, newTitle) | 1(allCuisines) |
| 数据来源 | 本地硬编码 | TravelData.ets |
| 数据变化时机 | 用户操作时 | 页面加载时一次性 |
| 是否需要复制数组 | 是(每次操作后) | 否(只读) |
| 用户交互 | 勾选、添加 | 无(纯展示) |
行程规划是"读写型"页面——用户可以改变数据,每次改变都需要触发 UI 更新,所以需要处理引用变更。美食指南是"只读型"页面——数据在 aboutToAppear 里加载一次,之后不会再变,所以不需要处理引用变更。
这种区别决定了两个页面的代码复杂度。TripPlanner 的 toggleDay 和 addDay 都需要"修改 + 复制 + 赋值"三步,而 CuisinePage 只需要一个 aboutToAppear 里的数据聚合。页面的复杂度不取决于 UI 有多花哨,而取决于数据会怎么变化。
页面导航的共性
两个页面的导航栏结构完全一致——左边返回按钮,中间标题,右边操作区(行程规划有"保存"按钮,美食指南没有)。返回按钮的实现也是一样的:34×34 圆形,rgba(0,0,0,0.03) 背景,系统左箭头图标,点击调用 router.back()。
这种导航栏的统一性是整个旅行 App 的设计规范。无论页面内容多复杂,导航栏始终保持相同的结构和交互,用户不需要重新学习"怎么返回"。
更多推荐


所有评论(0)