旅行探索首页的 UI 布局确定之后,接下来要解决的是交互问题——用户点击城市卡片怎么跳到详情页,点击底部导航栏怎么切换页面,点击工具按钮怎么打开对应功能。这些交互的核心是 router.pushUrl 路由跳转和 onClick 事件绑定,实现起来不复杂,但有几个容易踩坑的地方。
完整效果
在这里插入图片描述
在这里插入图片描述

交互点梳理

首页上可点击的元素一共有三类:

  1. 城市卡片:点击后跳转到城市详情页,需要传递城市 ID
  2. 工具按钮:四个快捷入口,分别跳转到行程规划、美食指南、汇率换算、旅行短语
  3. 底部导航栏:五个 Tab,非当前页跳转,当前页无操作

三类交互的实现方式都是 onClick + router.pushUrl,但各自的参数传递和边界处理有所不同。

城市卡片的参数化跳转

城市网格的每张卡片都绑定了点击事件:

.onClick(() => {
  router.pushUrl({
    url: 'pages/CityDetail',
    params: { 'cityId': city.id } as Record<string, Object>
  })
})

在这里插入图片描述

这里有两个细节值得说一下。

参数类型断言{ 'cityId': city.id } as Record<string, Object> 里的 as Record<string, Object> 是必须的。router.pushUrlparams 参数类型是 Record<string, Object>,而直接写 { 'cityId': city.id } 会被 ArkTS 推断为更具体的类型(比如 { cityId: number }),类型检查会报错。加 as Record<string, Object> 显式声明类型,编译才能通过。

这个写法在 ArkTS 路由里非常常见,几乎每次传参都会用到。如果忘了加,IDE 会报类似"类型不兼容"的错误,新手容易卡在这里。

只传 ID,不传对象city 对象本身包含了名称、国家、描述、景点、美食等大量数据。如果把整个对象传过去,代码会变成 params: { 'city': city } as Record<string, Object>,但这样做的问题是——ArkTS 的路由参数只支持基本类型(string、number、boolean)和它们的数组,不支持自定义对象。传对象会导致运行时数据丢失或序列化错误。

所以正确的做法是只传 ID,详情页根据 ID 从数据源重新查询。这是一种"参数最小化"原则——路由参数越简单,出问题的概率越低。

详情页的参数接收

CityDetail 页面在 aboutToAppear 里接收参数:

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 }
    }
  }
}

router.getParams() 返回的是 object | undefined,需要先断言为 Record<string, Object> 才能用键名访问。然后逐个字段取出并做类型断言——p['cityId'] as number

这个查找逻辑用的是 for 循环而不是 find(),原因和之前一样——ArkTS 里数组方法的类型推断不够精确,find() 返回的类型有时会被推断为 T | undefined,后续使用时 IDE 会报"可能为空"的警告。手动循环 + break 虽然啰嗦,但类型安全。

找到匹配的城市后赋值给 @State city,UI 自动更新。如果参数里没有 cityId 或者找不到对应城市,city 保持 undefined,页面会显示空白——这是一个需要处理的边界情况。

工具按钮的直接跳转

四个工具按钮的跳转逻辑比城市卡片简单,因为不需要传参数:

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' })
  })
}

在这里插入图片描述

每个 ToolBtn 接收一个 action 回调,点击时执行对应的路由跳转。这种"Builder 只负责 UI,回调函数负责逻辑"的模式,让 Builder 保持了通用性——如果以后要加第五个工具按钮,只需要在 Row 里多加一行 this.ToolBtn(...) 就行,不需要改 Builder 的代码。

ToolBtn Builder 的回调参数

@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)
}

action 参数的类型是 () => void——一个不接收参数、没有返回值的函数。.onClick(action) 把这个回调绑定到点击事件上。Builder 本身不知道点击后会发生什么,它只负责"用户点了之后调用这个函数"。

这种设计让 ToolBtn 可以复用于任何需要"图标 + 文字 + 点击"的场景。运动记录的快捷按钮、社区的入口按钮,只要换一下图标、文字和回调就行。

底部导航栏的条件路由

底部导航栏的跳转逻辑和工具按钮类似,但多了一个条件判断——当前 Tab 不跳转:

@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' })
  })
}

在这里插入图片描述

注意 idx === 0(探索 Tab)的情况——onClick 里没有对应的 if 分支。当用户点击"探索"时,什么都不会发生。这是合理的:用户已经在首页了,再点"探索"没有意义。

但如果要做得更完善,应该在 idx === 0 时让页面滚动到顶部:

if (idx === 0) {
  // 滚动到顶部的逻辑
}

目前首页的 Scroll 没有绑定 ScrollController,所以暂时无法用代码控制滚动位置。如果以后需要这个功能,可以给 Scroll 绑一个 controller,然后在 onClick 里调用 controller.scrollToTop()

图标的三元表达式嵌套

Text(label === '探索' ? '🌍' : (label === '行程' ? '🧳' :
  (label === '美食' ? '🍜' : (label === '工具' ? '🔧' : '👤'))))

这段代码用嵌套的三元表达式根据 label 选择对应的 emoji 图标。写法虽然紧凑,但可读性不太好——嵌套了四层,读起来需要从外往里逐层解析。

更清晰的做法是用映射表:

private getIcon(label: string): string {
  const icons: Record<string, string> = {
    '探索': '🌍', '行程': '🧳', '美食': '🍜', '工具': '🔧', '我的': '👤'
  }
  return icons[label] || '🏠'
}

但在当前规模下(只有 5 个 Tab),嵌套三元表达式也能接受。代码量少的时候,简洁比可读性更重要;代码量多了再重构也不迟。

颜色透明度的计算

ToolBtn 和 NavBtn 的背景色都用了一个 + '10' 的后缀:

.backgroundColor(color + '10')

这里的 '10' 是十六进制的透明度值。在 ARGB 颜色模型里,#FF6B3510 表示 #FF6B35 这个颜色加上 0x10(十进制 16)的透明度。0x10 / 0xFF = 6.3%,也就是说背景色是主题色的 6% 不透明度——几乎看不见,但能让按钮区域和周围有一点微妙的区分。

这个透明度计算方式在整个项目里统一使用。如果以后要调整按钮的背景深浅,只需要改这个后缀值就行——'20' 是 12%,'30' 是 18%,以此类推。

路由跳转的错误处理

目前所有的 router.pushUrl 调用都没有错误处理。如果目标页面不存在(比如 module.json5 里忘了注册),运行时会崩溃而不是优雅降级。

一个更健壮的做法是加上回调:

router.pushUrl({ url: 'pages/CityDetail', params: { 'cityId': city.id } as Record<string, Object> },
  (err) => {
    if (err) { console.error('路由跳转失败: ' + JSON.stringify(err)) }
  })

pushUrl 的第二个参数是一个回调函数,接收错误信息。在原型阶段可以不加这个回调(方便快速调试),但上线前应该补上。

同样的问题也存在于城市详情页的参数接收——如果 cityId 无效或 CITIES 数组为空,this.city 会保持 undefined,页面会显示空白。应该加一个兜底逻辑:

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 }
    }
  }
  if (!this.city) {
    // 兜底:显示默认城市或返回上一页
    router.back()
  }
}

页面跳转的数据流

整个首页的跳转数据流可以用一张图概括:

首页 (Index)
├── 点击城市卡片 → CityDetail (params: cityId)
├── 点击行程规划 → TripPlanner
├── 点击美食指南 → CuisinePage
├── 点击汇率换算 → CurrencyPage
├── 点击旅行短语 → PhraseBook
└── 点击底部"我的" → ProfilePage

六个跳转目标,只有一个需要传参(CityDetail),其他五个都是直接跳转。这种"大多数简单、少数复杂"的分布是典型的 App 首页模式——首页是分发中心,把用户引导到各个功能页面,不需要在首页做太多复杂交互。

数据流向是单向的:首页只负责"把用户送出去",不负责"把数据收回来"。如果以后需要从详情页返回首页时刷新数据(比如用户在详情页收藏了一个城市,返回首页后要更新收藏状态),需要用 router.backresult 参数或者全局状态管理来实现。但在当前版本里,这个需求还不存在。

交互设计的一致性

三类交互虽然场景不同,但在实现上保持了一致:

  • 都用 onClick 绑定事件
  • 都用 router.pushUrl 做页面跳转
  • 跳转参数都用 as Record<string, Object> 类型断言
  • Builder 都只负责 UI,业务逻辑通过回调传入

这种一致性不是偶然的——它来自于对 ArkTS 路由机制的理解和对项目代码风格的统一。当所有跳转都用同一种写法时,排查问题变得简单:路由跳不通?先检查 module.json5 有没有注册页面;参数没收到?先检查 as Record<string, Object> 有没有加。这些"约定"比"技术"更重要。

Logo

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

更多推荐