【共创稿事节】HarmonyOS7 Navigation Auto:手机单栏、平板双栏一套代码
文章目录
前言
做列表-详情类页面时,手机上通常是"点进去看详情、再返回列表"的导航模式,平板上则更习惯"左边列表、右边详情"的分栏模式。如果分别写两套布局,维护成本翻倍,状态同步也是噩梦。
HarmonyOS 的 Navigation 组件提供了 NavigationMode.Auto 模式——宽度够时自动分栏,不够时自动切回单栏导航。你只需要写一套代码,框架帮你判断该用哪种布局。
这篇文章围绕一个商品列表-详情的完整案例,把 NavigationMode.Auto 的使用方式、NavPathStack 路由管理、onNavigationModeChange 模式监听、分栏状态下详情页的数据同步全部拆开讲。每个环节的代码我都会说明"为什么这样写",文末有完整源码,可以直接取用。
效果预览



主要流程
整个流程可以压缩成 4 步:
Navigation组件设mode(Auto),框架根据宽度自动决定单栏还是分栏。NavPathStack管理路由栈,列表页pushPath进入详情,详情页pop返回。onNavigationModeChange回调通知当前模式,分栏时详情区和列表区同时可见。- 分栏模式下,列表点击直接更新右侧详情;单栏模式下,列表点击进入新页面。
后面所有代码,都是围绕这条链路展开的。
NavigationMode.Auto 到底做了什么
Navigation 有三种模式:
| 模式 | 行为 | 适用场景 |
|---|---|---|
Stack | 永远单栏导航,push/pop 切换页面 | 只做手机适配 |
Split | 永远分栏,左侧主内容 + 右侧 NavDestination | 只做平板适配 |
Auto | 宽度 ≥ 520vp 自动分栏,否则单栏 | 手机 + 平板一套代码 |
Auto 模式的核心逻辑:组件挂载时测量自身宽度,超过阈值(默认 520vp)就进入 Split 模式,显示左右两栏;宽度不足时退回 Stack 模式,走传统的 push/pop 导航。运行时窗口尺寸变化(比如折叠屏展开、横竖屏切换),模式也会自动切换。
这个 520vp 的阈值可以通过 navBarWidth 和相关属性调整,但大多数场景用默认值就够了。
Navigation + NavPathStack 基础结构
NavPathStack 的创建
@State navPathStack: NavPathStack = new NavPathStack()
NavPathStack 是 Navigation 的路由管理器,所有页面跳转都通过它完成。它替代了旧版的 router 模块——在 Navigation 体系内,不要用 router.pushUrl(),否则页面不在 Navigation 的管控范围内,分栏模式下会出现布局异常。
Navigation 组件的基本使用
build() {
Navigation(this.navPathStack) {
this.MainPage()
}
.mode(NavigationMode.Auto)
.navDestination(this.DetailPage)
.onNavigationModeChange((mode: NavigationMode) => {
this.isSplitMode = mode === NavigationMode.Split
})
.width('100%')
.height('100%')
}
四个关键配置:
Navigation(this.navPathStack):把路由栈注入 Navigation 组件。后续的pushPath/pop都操作这个栈。mode(NavigationMode.Auto):自动模式,框架决定单栏还是分栏。navDestination(this.DetailPage):注册详情页的NavDestination构建。pushPath跳转时,框架根据 name 找到对应的NavDestination渲染。onNavigationModeChange:模式变化回调。分栏进入时mode === NavigationMode.Split,退出时mode === NavigationMode.Stack。
MainPage:列表区的内容
@Builder
MainPage() {
Column() {
Text('商品列表')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding(16)
GoodsListPage({
goodsList: this.goodsList,
onItemSelected: (item: GoodsInfo) => {
this.handleItemSelected(item)
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
MainPage 是 Navigation 的主内容区,始终显示在左侧(分栏模式)或作为首页(单栏模式)。点击商品后调用 handleItemSelected 处理跳转。
DetailPage:详情区的 NavDestination
@Builder
DetailPage() {
NavDestination() {
if (this.selectedGoods !== undefined) {
GoodsDetailPage({
item: this.selectedGoods as GoodsInfo,
onBack: () => {
this.navPathStack.pop()
}
})
} else {
Column() {
Text('请选择一个商品')
.fontSize(16)
.fontColor('#999999')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
.title('商品详情')
.hideTitleBar(true)
}
NavDestination 是详情页的容器,分栏模式下渲染在右侧,单栏模式下作为新页面覆盖列表页。
两个细节:
hideTitleBar(true):隐藏 NavDestination 自带的标题栏。案例用自定义的"返回 + 标题"布局替代,更灵活。selectedGoods的空态处理:分栏模式下,右侧详情区可能还没有选中商品(刚进入页面时),此时显示"请选择一个商品"的占位文字。
handleItemSelected——分栏和单栏的不同行为
@State isSplitMode: boolean = false
@State selectedGoods: GoodsInfo | undefined = undefined
private handleItemSelected(item: GoodsInfo): void {
this.selectedGoods = item
if (this.isSplitMode) {
this.navPathStack.pushPath({ name: 'GoodsDetail', onPop: () => {
this.selectedGoods = undefined
}})
} else {
this.navPathStack.pushPath({ name: 'GoodsDetail', onPop: () => {
this.selectedGoods = undefined
}})
}
}
这段代码看起来 if/else 两个分支完全一样,是不是多此一举?确实,当前两个分支的逻辑是一样的,都执行 pushPath。但保留 if/else 的结构是有意义的——在实际项目中,分栏和单栏的行为很可能不同。比如:
- 分栏模式下,你可能不想
pushPath,而是直接更新右侧详情(不产生路由历史) - 单栏模式下,你可能想在
onPop时做额外处理(比如恢复列表滚动位置) - 分栏模式下多次点击不同商品,你可能想替换右侧内容而不是 push 多层
当前案例用 pushPath 统一处理,是最简单的实现。但 isSplitMode 标志位已经就位,后续扩展只需在对应分支加逻辑,不需要重构结构。
selectedGoods 的作用
selectedGoods 是列表和详情之间的数据桥梁。列表页点击时设置它,详情页读取它渲染内容。这个变量在分栏模式下尤其关键——右侧详情区需要持续显示当前选中的商品,而不是只在 push 时传一次数据。
onPop 回调
onPop: () => {
this.selectedGoods = undefined
}
从详情页返回时(点击"返回"或系统手势),onPop 触发,清空 selectedGoods。这确保了返回列表后,下次进入详情不会残留上一次的数据。
GoodsListPage 子组件——列表的点击回调
@Component
struct GoodsListPage {
@Prop goodsList: GoodsInfo[] = []
onItemSelected: (item: GoodsInfo) => void = (_item: GoodsInfo) => {}
build() {
List({ space: 8 }) {
ForEach(this.goodsList, (item: GoodsInfo) => {
ListItem() {
Row() {
Column() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text(item.desc)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('>')
.fontSize(16)
.fontColor('#CCCCCC')
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
.onClick(() => {
this.onItemSelected(item)
})
}
}, (item: GoodsInfo) => item.id.toString())
}
.width('100%')
.height('100%')
.padding(12)
}
}
onItemSelected 是一个回调函数属性,子组件不持有状态,只负责"告诉父组件用户点了哪个"。父组件 ParallelHorizon 在创建 GoodsListPage 时注入回调:
GoodsListPage({
goodsList: this.goodsList,
onItemSelected: (item: GoodsInfo) => {
this.handleItemSelected(item)
}
})
这种"子组件回调 → 父组件处理"的模式在 Navigation 场景下比 @Link 更合适——因为路由跳转和数据更新都在父组件完成,子组件只做展示和事件上报。
ForEach 的 key
}, (item: GoodsInfo) => item.id.toString())
用 item.id 做 key,保证列表项和组件实例的稳定对应。如果 key 不稳定(比如用 index),分栏模式下右侧详情可能因为列表重渲染而闪烁。
GoodsDetailPage 子组件——详情的返回回调
@Component
struct GoodsDetailPage {
@Prop item: GoodsInfo = { id: 0, name: '', desc: '' }
onBack: () => void = () => {}
build() {
Column() {
Row() {
Text('< 返回')
.fontSize(16)
.fontColor('#007DFF')
.onClick(() => {
this.onBack()
})
Text(this.item.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.fontColor('#333333')
Text('')
.width(48)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.alignItems(VerticalAlign.Center)
Column() {
Text(this.item.name)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(this.item.desc)
.fontSize(14)
.fontColor('#666666')
.margin({ top: 12 })
Text(`商品ID: ${this.item.id}`)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.layoutWeight(1)
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
}
和 GoodsListPage 一样的回调模式——onBack 由父组件注入,实际执行的是 this.navPathStack.pop()。
标题栏用自定义 Row 实现,右边放一个 Text('').width(48) 占位,让标题文字视觉居中。hideTitleBar(true) 隐藏了 NavDestination 自带标题栏后,这种自定义标题栏更灵活——你可以加返回动画、加收藏按钮、加分享按钮,不受 NavDestination 标题栏的 API 限制。
分栏模式下"返回"按钮的取舍
分栏模式下,右侧详情区通常不需要"返回"按钮——列表就在左边,点击即可切换。但案例保留了它,因为 NavigationMode.Auto 可能在运行时切换到单栏模式(比如窗口缩小),此时必须有返回手段。如果你确定只在平板上用分栏,可以在 isSplitMode 时隐藏返回按钮。
onNavigationModeChange 的实际用途
.onNavigationModeChange((mode: NavigationMode) => {
this.isSplitMode = mode === NavigationMode.Split
})
这个回调在三种情况下触发:
- 首次渲染:Navigation 测量完自身宽度后,决定初始模式
- 窗口尺寸变化:横竖屏切换、折叠屏开合、自由窗口拖拽
- 代码触发的模式切换:如果你手动改了
mode属性
isSplitMode 状态变量被保存下来,供 handleItemSelected 和 UI 层使用。这是唯一能可靠获取当前导航模式的途径——不要试图通过屏幕宽度自己算,因为 Navigation 内部的分栏判断还考虑了 navBarWidth、安全区域等参数,手动计算容易出错。
分栏切单栏时的状态清理
当窗口从宽变窄(分栏 → 单栏),当前右侧详情区会被压入路由栈变成全屏页面,用户可以 pop 回列表。当窗口从窄变宽(单栏 → 分栏),路由栈顶的页面会被拆到右侧,列表回到左侧。
框架自动处理了大部分布局变换,但 selectedGoods 需要你自己管理——如果在分栏模式下选中了商品,然后窗口缩小变回单栏,selectedGoods 仍然有值,这是对的,因为详情页还在显示。pop 回列表后 onPop 清空它。
完整源码
import { router } from '@kit.ArkUI'
interface GoodsInfo {
id: number
name: string
desc: string
}
@Component
struct GoodsListPage {
@Prop goodsList: GoodsInfo[] = []
onItemSelected: (item: GoodsInfo) => void = (_item: GoodsInfo) => {}
build() {
List({ space: 8 }) {
ForEach(this.goodsList, (item: GoodsInfo) => {
ListItem() {
Row() {
Column() {
Text(item.name)
.fontSize(16)
.fontWeight(FontWeight.Medium)
.fontColor('#333333')
Text(item.desc)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 4 })
}
.layoutWeight(1)
.alignItems(HorizontalAlign.Start)
Text('>')
.fontSize(16)
.fontColor('#CCCCCC')
}
.width('100%')
.padding(16)
.backgroundColor(Color.White)
.borderRadius(8)
.onClick(() => {
this.onItemSelected(item)
})
}
}, (item: GoodsInfo) => item.id.toString())
}
.width('100%')
.height('100%')
.padding(12)
}
}
@Component
struct GoodsDetailPage {
@Prop item: GoodsInfo = { id: 0, name: '', desc: '' }
onBack: () => void = () => {}
build() {
Column() {
Row() {
Text('< 返回')
.fontSize(16)
.fontColor('#007DFF')
.onClick(() => {
this.onBack()
})
Text(this.item.name)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.layoutWeight(1)
.textAlign(TextAlign.Center)
.fontColor('#333333')
Text('')
.width(48)
}
.width('100%')
.height(56)
.padding({ left: 16, right: 16 })
.alignItems(VerticalAlign.Center)
Column() {
Text(this.item.name)
.fontSize(24)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
Text(this.item.desc)
.fontSize(14)
.fontColor('#666666')
.margin({ top: 12 })
Text(`商品ID: ${this.item.id}`)
.fontSize(12)
.fontColor('#999999')
.margin({ top: 8 })
}
.width('100%')
.layoutWeight(1)
.padding(24)
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
}
@Entry
@Component
struct ParallelHorizon {
@State navPathStack: NavPathStack = new NavPathStack()
@State goodsList: GoodsInfo[] = []
@State isSplitMode: boolean = false
@State selectedGoods: GoodsInfo | undefined = undefined
aboutToAppear(): void {
for (let i = 0; i < 20; i++) {
this.goodsList.push({ id: i, name: `商品${i}`, desc: `这是商品${i}的详细描述信息` })
}
}
private handleItemSelected(item: GoodsInfo): void {
this.selectedGoods = item
if (this.isSplitMode) {
this.navPathStack.pushPath({ name: 'GoodsDetail', onPop: () => {
this.selectedGoods = undefined
}})
} else {
this.navPathStack.pushPath({ name: 'GoodsDetail', onPop: () => {
this.selectedGoods = undefined
}})
}
}
@Builder
MainPage() {
Column() {
Text('商品列表')
.fontSize(20)
.fontWeight(FontWeight.Bold)
.fontColor('#333333')
.width('100%')
.padding(16)
GoodsListPage({
goodsList: this.goodsList,
onItemSelected: (item: GoodsInfo) => {
this.handleItemSelected(item)
}
})
}
.width('100%')
.height('100%')
.backgroundColor('#F5F5F5')
}
@Builder
DetailPage() {
NavDestination() {
if (this.selectedGoods !== undefined) {
GoodsDetailPage({
item: this.selectedGoods as GoodsInfo,
onBack: () => {
this.navPathStack.pop()
}
})
} else {
Column() {
Text('请选择一个商品')
.fontSize(16)
.fontColor('#999999')
}
.width('100%')
.height('100%')
.justifyContent(FlexAlign.Center)
}
}
.title('商品详情')
.hideTitleBar(true)
}
build() {
Navigation(this.navPathStack) {
this.MainPage()
}
.mode(NavigationMode.Auto)
.navDestination(this.DetailPage)
.onNavigationModeChange((mode: NavigationMode) => {
this.isSplitMode = mode === NavigationMode.Split
})
.width('100%')
.height('100%')
}
}
总结
NavigationMode.Auto 的核心价值是"一套代码、两种布局",但它不会自动处理所有细节。你需要关注的四件事:
NavPathStack替代router:在 Navigation 体系内,所有路由操作都走navPathStack.pushPath()/pop(),不要混用router,否则分栏模式下页面不在正确位置。onNavigationModeChange监听模式:这是唯一可靠的获取当前模式的方式。保存isSplitMode状态,供业务逻辑判断。selectedGoods做数据桥梁:分栏模式下列表和详情同时可见,需要一个共享状态来同步选中数据。onPop回调负责清理。navDestination注册详情页:pushPath的name要和@Builder里的NavDestination对应,框架按 name 匹配渲染。
实际项目中,分栏模式往往需要"替换右侧内容而非 push 新页面"的行为,这时只需在 isSplitMode 分支里用 navPathStack.replacePathByName() 替代 pushPath() 即可。
更多推荐


所有评论(0)