HarmonyOS旅行探索——探索首页架构拆解
旅行探索项目。同样是 HarmonyOS ArkTS 技术栈,同样是首页 + 多页面的结构,但旅行 App 的首页在视觉和数据结构上都有明显的差异——最突出的是那块橙色渐变的 Hero Banner,以及嵌套了景点和美食数据的城市卡片。
完整效果
项目结构概览
先看一下这个 App 的全貌。pages 目录下有 7 个页面文件:
| 页面 | 职责 |
|---|---|
| Index | 探索首页 |
| CityDetail | 城市详情(景点+美食) |
| CuisinePage | 美食指南 |
| CurrencyPage | 汇率换算 |
| PhraseBook | 旅行短语 |
| TripPlanner | 行程规划 |
| ProfilePage | 个人中心 |
services 目录下有一个 TravelData.ets,定义了四个接口和完整的 mock 数据。和运动健康 App 的 WorkoutData 不同,TravelData 的数据结构是嵌套的——每个城市对象里面包含了它的景点数组和美食数组,而不是用 ID 引用再查询。
颜色体系:从运动红到旅行橙
const A: string = '#FF6B35' // 主色·旅行橙
const BG: string = '#F8F7FC' // 背景灰
const CW: string = '#FFFFFF' // 卡片白
const T1: string = '#1E1B2E' // 标题黑
const T2: string = '#888888' // 副文本
const T3: string = '#BBBBBB' // 辅助灰
主色从运动健康的 #FF4757(红)换成了 #FF6B35(橙)。橙色在旅行场景里很常见——它代表热情、活力、温暖,和"探索世界"的主题契合。T1/T2/T3 三级文本颜色保持不变,这是跨项目复用的视觉基础设施。
页面骨架:Stack + Scroll + Column
和运动健康首页完全一样的骨架结构:
Stack({ alignContent: Alignment.Bottom }) {
Scroll() {
Column() {
// 内容区
}
}
// 底部导航栏(固定在 Stack 底部)
}

Stack 把内容层和导航层叠在一起,Scroll 让内容区可以上下滚动,导航栏固定在底部不跟着滚。这个模式在两个 App 里已经验证过了,是一个可靠的首页布局范式。
Header:头像区的渐变圆
Stack() {
Column() {}.width(42).height(42).borderRadius(21)
.linearGradient({ angle: 135, colors: [[A, 0], ['#FF9F43', 1]] })
Text('✈️').fontSize(18)
}
一个 42×42 的圆形,用橙色到金色的 135° 渐变填充,上面叠一个飞机 emoji。这是用户头像的占位——运动健康 App 里用的是跑步 emoji,这里换成了飞机,保持和 App 主题的一致。
Stack 在这里的作用是让 emoji 居中显示在渐变圆上方。Stack({ alignContent: Alignment.Center }) 是默认行为,不需要额外指定。
Hero Banner:视觉重心的设计
Hero Banner 是整个首页视觉冲击力最强的元素。它是一块 150vp 高的渐变卡片,用橙色三段渐变作为背景:
Stack({ alignContent: Alignment.Center }) {
Column() {}.width('100%').height(150).borderRadius(20)
.linearGradient({ angle: 135, colors: [[A, 0], ['#FF8C42', 0.5], ['#FFB07C', 1]] })
Circle({ width: 120, height: 120 }).fill('rgba(255,255,255,0.06)')
.position({ x: '60%', y: -20 })
Row() {
Column() {
Text('🌍').fontSize(44).margin({ bottom: 8 })
Text('探索热门目的地').fontSize(18).fontWeight(FontWeight.Bold).fontColor(Color.White)
Text('6大城市 · 深度旅行指南').fontSize(12)
.fontColor('rgba(255,255,255,0.65)').margin({ top: 4 })
}.alignItems(HorizontalAlign.Start).margin({ left: 18 })
Text('🧳').fontSize(40).margin({ right: 18 })
}.width('100%')
}
.width('100%').height(150).borderRadius(20)
.shadow({ radius: 16, color: A + '25', offsetY: 6 })

逐层分析:
三段渐变背景:[[A, 0], ['#FF8C42', 0.5], ['#FFB07C', 1]] 定义了三个颜色断点——起点是主色橙 #FF6B35,中间是深橙 #FF8C42,终点是浅橙 #FFB07C。角度 135° 表示从左上到右下的对角线渐变。比运动健康首页的两段渐变更细腻,视觉层次更丰富。
装饰性半透明圆:Circle({ width: 120, height: 120 }).fill('rgba(255,255,255,0.06)') 是一个几乎看不见的白色半透明圆,放在右上方(x: '60%', y: -20)。它的作用纯粹是装饰——打破纯色渐变的单调感,让背景有一点微妙的纹理。rgba(255,255,255,0.06) 的透明度只有 6%,在渐变背景上几乎察觉不到,但就是这点细节能让 Banner 看起来不那么"平"。
左右分栏内容:左侧是标题文字(地球 emoji + 主标题 + 副标题),右侧是一个旅行箱 emoji。layoutWeight(1) 让左侧占据所有剩余空间,旅行箱被推到最右边。
投影效果:shadow({ radius: 16, color: A + '25', offsetY: 6 }) 给 Banner 加了一层橙色系的阴影。A + '25' 是主色加上 15% 透明度(#FF6B3525),比纯灰色阴影更协调。
城市网格:数据驱动的卡片
Grid() {
ForEach(CITIES, (city: City) => {
GridItem() {
Column() {
Text(city.emoji).fontSize(40).margin({ bottom: 8 })
Text(city.name).fontSize(15).fontWeight(FontWeight.Bold).fontColor(T1)
Text(city.country).fontSize(11).fontColor(T3)
Row() {
Text('⭐ ' + city.rating.toString()).fontSize(10).fontColor(A)
}.margin({ top: 4 })
}.width('100%').padding(14).backgroundColor(CW).borderRadius(16)
.shadow({ radius: 4, color: 'rgba(0,0,0,0.03)', offsetY: 1 })
.onClick(() => {
router.pushUrl({
url: 'pages/CityDetail',
params: { 'cityId': city.id } as Record<string, Object>
})
})
}
})
}
.columnsTemplate('1fr 1fr').columnsGap(10).rowsGap(10)
.width('100%').padding({ left: 16, right: 16 }).margin({ bottom: 24 })
两列网格,每个城市卡片包含四层信息:emoji 图标、城市名称、国家名称、评分。点击卡片跳转到 CityDetail 页面,通过 params 传递 cityId。
这里有一个值得注意的设计——城市 emoji 图标没有用小号的装饰性 emoji(如运动健康首页快捷按钮的 24px emoji),而是用了 fontSize(40) 的大号图标。这让每张卡片的视觉重心集中在图标上,城市名称和国家是辅助信息。对于旅行类 App 来说,这种"图标优先"的展示方式比"文字优先"更能激发用户的探索欲。
数据传递:cityId 的参数化路由
.onClick(() => {
router.pushUrl({
url: 'pages/CityDetail',
params: { 'cityId': city.id } as Record<string, Object>
})
})
跳转到城市详情页时,不是把整个 city 对象传过去(ArkTS 的路由参数只支持基本类型),而是只传 cityId。详情页在 aboutToAppear 里根据 ID 从 CITIES 数组里查找对应的城市对象:
aboutToAppear(): void {
const p = router.getParams() as Record<string, Object>
if (p && p['cityId']) {
const id: number = p['cityId'] as number
for (let i: number = 0; i < CITIES.length; i++) {
if (CITIES[i].id === id) { this.city = CITIES[i]; break }
}
}
}
这种"传 ID、查数据"的模式比"传整个对象"更可靠。如果以后数据源从本地 mock 换成服务器 API,详情页只需要改数据获取方式,不需要改路由参数的格式。
旅行工具:快捷入口的横向排列
Row({ space: 10 }) {
this.ToolBtn('🧳', '行程规划', '#5DADE2', () => {
router.pushUrl({ url: 'pages/TripPlanner' })
})
this.ToolBtn('🍜', '美食指南', '#E8734A', () => {
router.pushUrl({ url: 'pages/CuisinePage' })
})
this.ToolBtn('💱', '汇率换算', '#00B894', () => {
router.pushUrl({ url: 'pages/CurrencyPage' })
})
this.ToolBtn('🗣️', '旅行短语', '#8A5DFA', () => {
router.pushUrl({ url: 'pages/PhraseBook' })
})
}
四个工具按钮用 Row({ space: 10 }) 横向排列,每个按钮占据等宽空间(layoutWeight(1))。这个布局和运动健康首页的快捷操作按钮完全一样——不同的颜色区分功能,相同的结构保证一致性。
ToolBtn Builder 的实现和运动健康首页的 QuickBtn 也是一样的模式:
@Builder ToolBtn(icon: string, label: string, color: string, action: () => void) {
Column() {
Text(icon).fontSize(28).margin({ bottom: 6 })
Text(label).fontSize(11).fontColor(T1)
}.layoutWeight(1).padding({ top: 14, bottom: 14 })
.backgroundColor(color + '10').borderRadius(14)
.onClick(action)
}

color + '10' 是给背景色加上 6% 的透明度(16 进制的 10 = 16 的十进制 = 6.3%),让按钮背景是极浅的主题色。这个透明度计算方式在两个 App 里都用到了,是一个复用的模式。
底部导航栏
@Builder NavBtn(idx: number, label: string, active: boolean) {
Column({ space: 2 }) {
Text(label === '探索' ? '🌍' : (label === '行程' ? '🧳' :
(label === '美食' ? '🍜' : (label === '工具' ? '🔧' : '👤'))))
.fontSize(20)
Text(label).fontSize(9).fontColor(active ? A : '#C0BFC6')
}.onClick(() => {
if (idx === 1) router.pushUrl({ url: 'pages/TripPlanner' })
else if (idx === 2) router.pushUrl({ url: 'pages/CuisinePage' })
else if (idx === 3) router.pushUrl({ url: 'pages/CurrencyPage' })
else if (idx === 4) router.pushUrl({ url: 'pages/ProfilePage' })
})
}

和运动健康首页的底部导航栏结构完全一样——五个 Tab,活跃态用主色,非活跃态用灰色。区别只在图标和文字内容。
有一个细节:当 idx === 0(探索 Tab)时,onClick 里没有任何操作。这是合理的——用户已经在首页了,再点"探索"不需要跳转。但更好的做法是让点击后 Scroll 滚动到顶部,这样用户在浏览了城市列表后可以快速回到 Hero Banner。目前这个交互还缺失。
数据层:嵌套结构 vs ID 引用
TravelData 的数据设计和运动健康 App 的 WorkoutData 有本质区别:
旅行 App(TravelData):
interface City {
landmarks: Landmark[] // 直接嵌套景点数组
cuisines: Cuisine[] // 直接嵌套美食数组
}
// 查询时直接 city.landmarks 即可
旅行 App 用嵌套是因为城市和景点/美食是明确的一对多关系——浅草寺只属于东京,不可能同时属于巴黎。嵌套结构在读取时更方便,不需要二次查询,而且数据量有限(6 个城市,每个城市几个景点),不存在冗余问题。
选择哪种数据结构取决于业务关系,不是技术偏好。如果以后这个旅行 App 的景点数量增长到几百个,且多个城市可能共享某些景点(比如连锁餐厅),可能需要改成 ID 引用的方式。但在当前规模下,嵌套是最直观的选择。
更多推荐



所有评论(0)