旅行探索首页的 UI 布局确定之后,接下来要解决的是交互问题——用户点击城市卡片怎么跳到详情页,点击底部导航栏怎么切换页面,点击工具按钮怎么打开对应功能。这些交互的核心是router.pushUrl路由跳转和onClick事件绑定,实现起来不复杂,但有几个容易踩坑的地方。
完整效果
交互点梳理
首页上可点击的元素一共有三类:
- 城市卡片:点击后跳转到城市详情页,需要传递城市 ID
- 工具按钮:四个快捷入口,分别跳转到行程规划、美食指南、汇率换算、旅行短语
- 底部导航栏:五个 Tab,非当前页跳转,当前页无操作
三类交互的实现方式都是onClick+router.pushUrl,但各自的参数传递和边界处理有所不同。
城市卡片的参数化跳转
城市网格的每张卡片都绑定了点击事件:
.onClick(()=>{router.pushUrl({url:'pages/CityDetail',params:{'cityId':city.id}asRecord<string,Object>})})这里有两个细节值得说一下。
参数类型断言:{ 'cityId': city.id } as Record<string, Object>里的as Record<string, Object>是必须的。router.pushUrl的params参数类型是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{constp=router.getParams()asRecord<string,Object>if(p&&p['cityId']){constid:number=p['cityId']asnumberfor(leti: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 的回调参数
@BuilderToolBtn(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 不跳转:
@BuilderNavBtn(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'})elseif(idx===2)router.pushUrl({url:'pages/CuisinePage'})elseif(idx===3)router.pushUrl({url:'pages/CurrencyPage'})elseif(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 图标。写法虽然紧凑,但可读性不太好——嵌套了四层,读起来需要从外往里逐层解析。
更清晰的做法是用映射表:
privategetIcon(label:string):string{consticons:Record<string,string>={'探索':'🌍','行程':'🧳','美食':'🍜','工具':'🔧','我的':'👤'}returnicons[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}asRecord<string,Object>},(err)=>{if(err){console.error('路由跳转失败: '+JSON.stringify(err))}})pushUrl的第二个参数是一个回调函数,接收错误信息。在原型阶段可以不加这个回调(方便快速调试),但上线前应该补上。
同样的问题也存在于城市详情页的参数接收——如果cityId无效或CITIES数组为空,this.city会保持undefined,页面会显示空白。应该加一个兜底逻辑:
aboutToAppear():void{constp=router.getParams()asRecord<string,Object>if(p&&p['cityId']){constid:number=p['cityId']asnumberfor(leti: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.back的result参数或者全局状态管理来实现。但在当前版本里,这个需求还不存在。
交互设计的一致性
三类交互虽然场景不同,但在实现上保持了一致:
- 都用
onClick绑定事件 - 都用
router.pushUrl做页面跳转 - 跳转参数都用
as Record<string, Object>类型断言 - Builder 都只负责 UI,业务逻辑通过回调传入
这种一致性不是偶然的——它来自于对 ArkTS 路由机制的理解和对项目代码风格的统一。当所有跳转都用同一种写法时,排查问题变得简单:路由跳不通?先检查module.json5有没有注册页面;参数没收到?先检查as Record<string, Object>有没有加。这些"约定"比"技术"更重要。