前言

做列表-详情类页面时,手机上通常是"点进去看详情、再返回列表"的导航模式,平板上则更习惯"左边列表、右边详情"的分栏模式。如果分别写两套布局,维护成本翻倍,状态同步也是噩梦。

HarmonyOS 的 Navigation 组件提供了 NavigationMode.Auto 模式——宽度够时自动分栏,不够时自动切回单栏导航。你只需要写一套代码,框架帮你判断该用哪种布局。

这篇文章围绕一个商品列表-详情的完整案例,把 NavigationMode.Auto 的使用方式、NavPathStack 路由管理、onNavigationModeChange 模式监听、分栏状态下详情页的数据同步全部拆开讲。每个环节的代码我都会说明"为什么这样写",文末有完整源码,可以直接取用。

效果预览

A hand-drawn doodle illustration on pure white pap

主要流程

整个流程可以压缩成 4 步:

  1. Navigation 组件设 mode(Auto),框架根据宽度自动决定单栏还是分栏。
  2. NavPathStack 管理路由栈,列表页 pushPath 进入详情,详情页 pop 返回。
  3. onNavigationModeChange 回调通知当前模式,分栏时详情区和列表区同时可见。
  4. 分栏模式下,列表点击直接更新右侧详情;单栏模式下,列表点击进入新页面。

后面所有代码,都是围绕这条链路展开的。

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

四个关键配置:

  1. Navigation(this.navPathStack):把路由栈注入 Navigation 组件。后续的 pushPath / pop 都操作这个栈。
  2. mode(NavigationMode.Auto):自动模式,框架决定单栏还是分栏。
  3. navDestination(this.DetailPage):注册详情页的 NavDestination 构建。pushPath 跳转时,框架根据 name 找到对应的 NavDestination 渲染。
  4. 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')
}

MainPageNavigation 的主内容区,始终显示在左侧(分栏模式)或作为首页(单栏模式)。点击商品后调用 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 是详情页的容器,分栏模式下渲染在右侧,单栏模式下作为新页面覆盖列表页。

两个细节:

  1. hideTitleBar(true):隐藏 NavDestination 自带的标题栏。案例用自定义的"返回 + 标题"布局替代,更灵活。
  2. 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
})

这个回调在三种情况下触发:

  1. 首次渲染:Navigation 测量完自身宽度后,决定初始模式
  2. 窗口尺寸变化:横竖屏切换、折叠屏开合、自由窗口拖拽
  3. 代码触发的模式切换:如果你手动改了 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 的核心价值是"一套代码、两种布局",但它不会自动处理所有细节。你需要关注的四件事:

  1. NavPathStack 替代 router:在 Navigation 体系内,所有路由操作都走 navPathStack.pushPath() / pop(),不要混用 router,否则分栏模式下页面不在正确位置。
  2. onNavigationModeChange 监听模式:这是唯一可靠的获取当前模式的方式。保存 isSplitMode 状态,供业务逻辑判断。
  3. selectedGoods 做数据桥梁:分栏模式下列表和详情同时可见,需要一个共享状态来同步选中数据。onPop 回调负责清理。
  4. navDestination 注册详情页pushPathname 要和 @Builder 里的 NavDestination 对应,框架按 name 匹配渲染。

实际项目中,分栏模式往往需要"替换右侧内容而非 push 新页面"的行为,这时只需在 isSplitMode 分支里用 navPathStack.replacePathByName() 替代 pushPath() 即可。

Logo

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

更多推荐