首页架构之前拆过一次,当时重点在布局结构和模块拆分。这次的改动不大,但每个改动都指向一个具体的问题:底部导航的高亮逻辑太复杂、搜索图标点击没校验、消息Tab的图标选错了。三个小改动,解决三个实际体验问题。
完整效果
在这里插入图片描述

底部导航:去掉 tab 状态变量

上一版的问题

上一版的底部导航用一个@State tab变量控制高亮:

@State tab: number = 0

@Builder HomeBtn() {
  Column({ space: 2 }) {
    SymbolGlyph(this.tab === 0 ? $r('sys.symbol.house_fill') : $r('sys.symbol.house'))
      .fontSize(22).fontColor([this.tab === 0 ? A : '#C0BFC6'])
    Text('首页').fontSize(9).fontColor(this.tab === 0 ? A : '#C0BFC6')
  }.onClick(() => { this.tab = 0 })
}

每个Tab都要判断this.tab === idx,决定用哪个图标、什么颜色。五个Tab就要写五次判断逻辑。

新版的做法

新版移除了@State tab,每个Tab直接写死高亮状态:

@Builder HomeBtn() {
  Column({ space: 2 }) {
    SymbolGlyph($r('sys.symbol.house_fill')).fontSize(22).fontColor([A])
    Text('首页').fontSize(9).fontColor(A)
  }
}

@Builder CategoryBtn() {
  Column({ space: 2 }) {
    SymbolGlyph($r('sys.symbol.scope')).fontSize(22).fontColor(['#C0BFC6'])
    Text('分类').fontSize(9).fontColor('#C0BFC6')
  }.onClick(() => { router.pushUrl({ url: 'pages/ProductList', ... }) })
}

在这里插入图片描述

HomeBtn永远是高亮状态(红色+fill图标),其他Btn永远是未激活状态(灰色+普通图标)。点击其他Tab时直接跳转页面,而不是切换tab状态。

为什么这样做

这个项目的首页是独立页面,不是单页面多Tab切换。用户点击"分类"会跳转到ProductList页面,点击"购物车"会跳转到CartPage页面,点击"我的"会跳转到ProfilePage页面。每个Tab对应一个独立的路由页面,不存在"在同一个页面内切换Tab"的场景。

上一版用@State tab控制高亮,隐含了一个假设:“用户可能在同一个页面内切换Tab”。但实际的路由架构是页面级跳转,这个状态变量就多余了。

去掉@State tab的好处:

  • 减少状态管理:不需要维护一个随时会过时的状态变量
  • 避免高亮错乱:用户从ProfilePage返回首页时,tab状态可能还停留在4(“我的”),首页的HomeBtn不会高亮
  • 代码更简单:每个Builder只需要写自己的样式,不需要判断条件

什么时候需要 tab 状态

如果首页是单页面多Tab架构(类似微信底部导航),用户点击Tab不跳转页面,只是切换同一页面内的内容区域——这时就需要@State tab来控制哪个Tab高亮。

判断标准很简单:点击Tab后,是跳转到新页面(router.pushUrl),还是在当前页面切换内容?前者不需要tab状态,后者需要。

搜索图标:空值守卫

上一版的问题

上一版的搜索图标点击逻辑:

.onClick(() => {
  router.pushUrl({ url: 'pages/ProductList', params: { 'search': this.searchText } })
})

没有校验this.searchText是否为空。用户没输入任何内容,点击搜索图标也会跳转到ProductList页面,带着一个空字符串的search参数。

跳转到空搜索页面的后果:

  • ProductList页面需要处理空搜索的情况(显示全部商品?显示提示?)
  • 用户看到空搜索结果会困惑:“我什么都没搜,为什么跳到这里?”
  • 多一次无意义的页面跳转,浪费路由栈空间

新版的解法

.onClick(() => {
  if (this.searchText.trim().length > 0) {
    router.pushUrl({ url: 'pages/ProductList', params: { 'search': this.searchText } })
  }
})

在这里插入图片描述

加了一个简单的守卫:this.searchText.trim().length > 0。trim去掉首尾空格后,如果长度大于0才跳转。空字符串或纯空格不会触发跳转。

为什么用 trim

直接判断this.searchText.length > 0不够——用户可能输入了几个空格,length大于0但实际内容是空的。trim()去掉首尾空格后再判断,更严谨。

trim()不会去掉中间的空格。如果用户输入"a b",trim后是"a b",length是3,会正常跳转。这是合理的行为——中间有空格说明用户确实想搜索"a b"。

搜索栏的 onSubmit 不需要改

搜索栏的TextInput有onSubmit事件,用户按回车时触发:

.onSubmit(() => { router.pushUrl({ url: 'pages/ProductList', params: { 'search': this.searchText } }) })

这里没有加空值守卫。原因是:TextInput的onSubmit在用户主动按回车时才触发,而搜索图标的onClick在用户点击时触发。用户按回车说明他已经输入了内容(否则按回车没意义),所以不需要校验。但用户可能"不小心"点击了搜索图标(比如想点击标题但点偏了),所以图标需要校验。

消息Tab的图标修正

上一版的问题

上一版的消息Tab用的是放大镜图标:

@Builder MsgBtn() {
  Column({ space: 2 }) {
    SymbolGlyph($r('sys.symbol.magnifyingglass')).fontSize(22).fontColor(['#C0BFC6'])
    Text('消息').fontSize(9).fontColor('#C0BFC6')
  }
}

放大镜是搜索的通用符号,用在"消息"Tab上会造成认知冲突——用户看到放大镜会下意识觉得"这是搜索",但文字写的是"消息"。图标和文字的语义不一致,增加用户的认知负担。

新版的修正

@Builder MsgBtn() {
  Column({ space: 2 }) {
    SymbolGlyph($r('sys.symbol.heart')).fontSize(22).fontColor(['#C0BFC6'])
    Text('消息').fontSize(9).fontColor('#C0BFC6')
  }
}

在这里插入图片描述

改成了心形图标(heart)。心形在社交类App中通常代表"收藏"或"喜欢",用在"消息"Tab上也不完全准确,但至少不会和搜索混淆。

图标选型的取舍

理想的"消息"图标应该是铃铛(bell)或对话气泡(bubble)。但鸿蒙系统SymbolGlyph的图标库不一定有这些图标,开发者只能从可用的图标中选最接近的。

sys.symbol.heart虽然不完美,但有三个优点:

  1. 不和搜索混淆
  2. 视觉上是实心的(fill),和其他Tab的图标风格一致
  3. 用户看到心形不会产生"这是搜索"的误解

在"完美但不可用"和"不完美但可用"之间,选后者。这是移动端开发的常见妥协——系统图标库有限,只能在现有资源中找最优解。

三个改动的共同逻辑

这三个改动看似独立,但背后有一个共同的思路:减少用户的认知负担

  • 底部导航去掉tab状态 → 避免"首页明明亮着但高亮不对"的困惑
  • 搜索图标加空值守卫 → 避免"我什么都没搜但跳到了搜索页"的困惑
  • 消息Tab换图标 → 避免"放大镜不是搜索吗怎么是消息"的困惑

每个改动都不大,但都解决了一个让用户"愣一下"的问题。好的UI不是没有bug,是用户不会因为UI而产生困惑。

踩坑记录

SymbolGlyph的fontColor需要用数组。 fontColor([A])而不是fontColor(A)。SymbolGlyph的fontColor接受ResourceColor[]类型,必须传数组。如果传单个颜色值,编译不会报错,但运行时图标可能不显示颜色。这是鸿蒙API的一个隐蔽坑——类型签名不够严格,错误不会在编译期暴露。

router.pushUrl的params类型断言。 params: { 'search': this.searchText } as Record<string,Object>需要as断言。ArkUI的路由API对params类型有严格要求,直接传对象会有类型错误。Record<string,Object>是最宽松的类型,能接受任何键值对。实际项目中可以定义具体的params接口,但原型阶段用Record更灵活。

** Blank().height(80) 的高度计算。** 底部留白80px = 导航栏高度64px + 安全间距16px。如果以后导航栏高度变了(比如改成72px),这个留白也需要同步调整。建议用常量管理:const NAV_HEIGHT = 64,留白高度用NAV_HEIGHT + 16计算。


Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐