旅行探索项目。同样是 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 引用的方式。但在当前规模下,嵌套是最直观的选择。

Logo

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

更多推荐