HarmonyOS 6.0 分栏布局与响应式折叠
分栏布局——左边列表右边详情,这是邮件、备忘录、文件管理器的标配。HarmonyOS NEXT 里实现分栏不靠第三方组件,Row + 媒体查询 + 动画就能搞定,而且在折叠屏和平板上还能自动适配宽度。这篇把分栏布局与响应式折叠的完整方案讲清楚。
分栏布局的基本结构

分栏的核心就是 Row 里放两个 Column——左边主列(master)占固定宽度或比例,右边详情列(detail)填满剩余空间。用 layoutWeight 分配宽度比,简单直接。
interface MasterItem {
id: string
title: string
summary: string
}
@Entry
@Component
struct SplitLayoutPage {
@State items: MasterItem[] = [
{ id: '1', title: '邮件一', summary: '关于项目进度的更新...' },
{ id: '2', title: '邮件二', summary: '会议纪要已整理完毕' },
{ id: '3', title: '邮件三', summary: '新版设计稿请查收' },
{ id: '4', title: '邮件四', summary: '周末团建活动通知' }
]
@State selectedId: string = '1'
build() {
Row() {
// 左栏:列表
Column() {
List() {
ForEach(this.items, (item: MasterItem) => {
ListItem() {
Row() {
Column() {
Text(item.title)
.fontSize(16)
.fontWeight(FontWeight.Bold)
Text(item.summary)
.fontSize(13)
.fontColor('#666666')
.maxLines(1)
.textOverflow({ overflow: TextOverflow.Ellipsis })
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
}
.width('100%')
.padding(16)
.backgroundColor(this.selectedId === item.id ? '#e3f2fd' : Color.Transparent)
.borderRadius(8)
.onClick(() => {
this.selectedId = item.id
})
}
}, (item: MasterItem) => item.id)
}
.width('100%')
.layoutWeight(1)
.divider({ strokeWidth: 1, color: '#f0f0f0' })
}
.width('40%')
.height('100%')
.padding(12)
.backgroundColor('#fafafa')
// 右栏:详情
Column() {
this.DetailContent()
}
.layoutWeight(1)
.height('100%')
.padding(20)
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
}
@Builder
DetailContent() {
let selectedItem: MasterItem = this.items[0]
for (let i = 0; i < this.items.length; i++) {
if (this.items[i].id === this.selectedId) {
selectedItem = this.items[i]
}
}
Column() {
Text(selectedItem.title)
.fontSize(22)
.fontWeight(FontWeight.Bold)
Text(selectedItem.summary)
.fontSize(15)
.fontColor('#666666')
.margin({ top: 12 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
注意: 左栏用固定百分比 width('40%'),右栏用 layoutWeight(1) 填满剩余空间。这是分栏布局最基础的分配方式。
mediaQuery 断点检测
分栏布局在手机竖屏上体验很差——左右各挤一半空间,内容看不全。所以要根据屏幕宽度动态切换布局:窄屏用单栏(列表点击进详情),宽屏用双栏。mediaQuery 就是干这个的。
interface BreakpointInfo {
current: string // sm / md / lg
isSplitMode: boolean // 是否分栏
masterRatio: number // 左栏占比
}
@Entry
@Component
struct ResponsiveSplitPage {
@State breakpoint: BreakpointInfo = { current: 'sm', isSplitMode: false, masterRatio: 0.4 }
@State selectedId: string = '1'
@State showDetail: boolean = false
private smListener: number = -1
private mdListener: number = -1
private lgListener: number = -1
aboutToAppear() {
// 注册断点监听
this.smListener = mediaQuery.matchMediaSync('(0vp<=width<520vp)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
if (data.matches) {
this.breakpoint = { current: 'sm', isSplitMode: false, masterRatio: 0 }
}
})
this.mdListener = mediaQuery.matchMediaSync('(520vp<=width<840vp)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
if (data.matches) {
this.breakpoint = { current: 'md', isSplitMode: true, masterRatio: 0.38 }
}
})
this.lgListener = mediaQuery.matchMediaSync('(840vp<=width)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
if (data.matches) {
this.breakpoint = { current: 'lg', isSplitMode: true, masterRatio: 0.32 }
}
})
}
aboutToDisappear() {
// 注销监听,避免内存泄漏
let smMatcher = mediaQuery.matchMediaSync('(0vp<=width<520vp)')
smMatcher.off('change')
let mdMatcher = mediaQuery.matchMediaSync('(520vp<=width<840vp)')
mdMatcher.off('change')
let lgMatcher = mediaQuery.matchMediaSync('(840vp<=width)')
lgMatcher.off('change')
}
build() {
if (this.breakpoint.isSplitMode) {
// 宽屏:分栏布局
Row() {
Column() {
this.MasterList()
}
.width((this.breakpoint.masterRatio * 100) + '%')
.height('100%')
Column() {
this.DetailContent()
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
} else {
// 窄屏:单栏切换
if (this.showDetail) {
Column() {
this.DetailContent()
Button('返回列表')
.margin({ top: 16 })
.onClick(() => {
this.showDetail = false
})
}
.width('100%')
.height('100%')
.padding(20)
} else {
Column() {
this.MasterList()
}
.width('100%')
.height('100%')
}
}
}
@Builder
MasterList() {
List() {
ForEach([1, 2, 3, 4, 5], (item: number) => {
ListItem() {
Row() {
Text('项目 ' + item)
.fontSize(16)
}
.width('100%')
.padding(16)
.onClick(() => {
this.selectedId = item.toString()
if (!this.breakpoint.isSplitMode) {
this.showDetail = true
}
})
}
}, (item: number) => item.toString())
}
.width('100%')
.layoutWeight(1)
}
@Builder
DetailContent() {
Column() {
Text('详情内容')
.fontSize(22)
.fontWeight(FontWeight.Bold)
Text('选中项: ' + this.selectedId)
.fontSize(15)
.margin({ top: 12 })
}
.width('100%')
.alignItems(HorizontalAlign.Start)
}
}
关键区别: sm 断点(小于 520vp)切单栏,md 断点(520-840vp)切分栏且左栏 38%,lg 断点(大于 840vp)切分栏且左栏 32%——大屏上左栏比例小一些,给详情留更多空间。
Navigation Split 模式
ArkUI 的 Navigation 组件内置了 Split 模式——自动根据屏幕宽度切换单栏和分栏,不用手动监听断点。适合标准的主从结构页面。
interface NavItem {
id: string
title: string
content: string
}
@Entry
@Component
struct NavSplitPage {
@State items: NavItem[] = [
{ id: '1', title: '收件箱', content: '你有 3 封未读邮件' },
{ id: '2', title: '已发送', content: '最近发送了 5 封邮件' },
{ id: '3', title: '草稿箱', content: '有 2 篇草稿未完成' }
]
@PathStack({ isAutoClear: false }) pathStack: PathStack = new PathStack()
build() {
Navigation(this.pathStack) {
Row() {
Column() {
List() {
ForEach(this.items, (item: NavItem) => {
ListItem() {
Text(item.title)
.fontSize(16)
.padding(16)
.width('100%')
}
.onClick(() => {
this.pathStack.pushPath({ name: 'Detail', param: item })
})
}, (item: NavItem) => item.id)
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
.mode(NavigationMode.Split)
.navDestination(this.buildNavDestination)
.title('邮件')
.width('100%')
.height('100%')
}
@Builder
buildNavDestination(name: string, param: Object) {
if (name === 'Detail') {
let item = param as NavItem
Column() {
Text(item.title)
.fontSize(22)
.fontWeight(FontWeight.Bold)
Text(item.content)
.fontSize(15)
.fontColor('#666666')
.margin({ top: 12 })
}
.width('100%')
.padding(24)
.alignItems(HorizontalAlign.Start)
}
}
}
注意: NavigationMode.Split 在宽屏自动分栏,窄屏自动切单栏推栈。比手动实现省很多代码,但定制性不如手动 Row 方案强。
折叠与展开动画
分栏到单栏的切换不能硬切——需要平滑的宽度动画,左栏收起或展开时视觉上不突兀。用 animateTo 包裹宽度变化即可。
@Entry
@Component
struct AnimatedSplitPage {
@State isSplitMode: boolean = true
@State masterWidth: number = 320
@State detailVisible: boolean = true
@State selectedId: string = '1'
toggleMode() {
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
if (this.isSplitMode) {
// 收起左栏
this.masterWidth = 0
this.detailVisible = true
} else {
// 展开左栏
this.masterWidth = 320
this.detailVisible = true
}
this.isSplitMode = !this.isSplitMode
})
}
build() {
Stack() {
Row() {
// 左栏
if (this.masterWidth > 0) {
Column() {
List() {
ForEach([1, 2, 3, 4, 5], (item: number) => {
ListItem() {
Text('项目 ' + item)
.fontSize(16)
.padding(16)
.width('100%')
}
.onClick(() => {
this.selectedId = item.toString()
})
}, (item: number) => item.toString())
}
.width('100%')
.layoutWeight(1)
}
.width(this.masterWidth)
.height('100%')
.backgroundColor('#fafafa')
.clip(true)
// 分隔线
Column()
.width(1)
.height('100%')
.backgroundColor('#e0e0e0')
}
// 右栏
Column() {
Row() {
Button(this.isSplitMode ? '收起' : '展开')
.onClick(() => this.toggleMode())
Text(' 详情: ' + this.selectedId)
.fontSize(18)
.layoutWeight(1)
}
.width('100%')
.padding(16)
Text('选中项目的详细内容')
.fontSize(16)
.fontColor('#666666')
.padding({ left: 16 })
}
.layoutWeight(1)
.height('100%')
.backgroundColor(Color.White)
}
.width('100%')
.height('100%')
}
.width('100%')
.height('100%')
}
}
关键区别: 左栏宽度从 320 动画到 0 而非直接隐藏,配合 .clip(true) 让内容不溢出,视觉上是滑入滑出的效果。
响应式列数调整
分栏不只左右两栏——Grid 组件也可以根据屏幕宽度动态调整列数。比如手机 2 列,平板 4 列,折叠屏展开后 6 列。用媒体查询驱动列数变化。
interface GridItem {
id: string
title: string
color: string
}
@Entry
@Component
struct ResponsiveGridPage {
@State columnsCount: number = 2
@State gridItems: GridItem[] = [
{ id: '1', title: '文档', color: '#FF6B6B' },
{ id: '2', title: '图片', color: '#4ECDC4' },
{ id: '3', title: '视频', color: '#45B7D1' },
{ id: '4', title: '音乐', color: '#96CEB4' },
{ id: '5', title: '下载', color: '#FFEAA7' },
{ id: '6', title: '收藏', color: '#DDA0DD' }
]
private smListener: number = -1
private mdListener: number = -1
private lgListener: number = -1
aboutToAppear() {
this.smListener = mediaQuery.matchMediaSync('(0vp<=width<520vp)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
if (data.matches) {
animateTo({ duration: 200 }, () => {
this.columnsCount = 2
})
}
})
this.mdListener = mediaQuery.matchMediaSync('(520vp<=width<840vp)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
if (data.matches) {
animateTo({ duration: 200 }, () => {
this.columnsCount = 4
})
}
})
this.lgListener = mediaQuery.matchMediaSync('(840vp<=width)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
if (data.matches) {
animateTo({ duration: 200 }, () => {
this.columnsCount = 6
})
}
})
}
aboutToDisappear() {
let sm = mediaQuery.matchMediaSync('(0vp<=width<520vp)')
sm.off('change')
let md = mediaQuery.matchMediaSync('(520vp<=width<840vp)')
md.off('change')
let lg = mediaQuery.matchMediaSync('(840vp<=width)')
lg.off('change')
}
build() {
Column() {
Text('当前列数: ' + this.columnsCount)
.fontSize(18)
.fontWeight(FontWeight.Bold)
.margin({ bottom: 16 })
Grid() {
ForEach(this.gridItems, (item: GridItem) => {
GridItem() {
Column() {
Text(item.title)
.fontSize(16)
.fontColor(Color.White)
}
.width('100%')
.height(100)
.backgroundColor(item.color)
.borderRadius(12)
.justifyContent(HorizontalAlign.Center)
}
}, (item: GridItem) => item.id)
}
.columnsTemplate('1fr '.repeat(this.columnsCount).trim())
.rowsGap(12)
.columnsGap(12)
.width('100%')
}
.width('100%')
.height('100%')
.padding(20)
}
}
注意: columnsTemplate 用 '1fr 1fr 1fr' 这种格式,通过 repeat 动态生成列模板。列数变化时 Grid 自动重新排列子项,配合 animateTo 有过渡效果。
平板与手机布局适配
平板和手机的布局差异不仅是分栏不分栏——还包括左栏样式、详情页返回逻辑、标题栏展示等。用一个统一的配置对象驱动所有差异。
interface LayoutConfig {
mode: string // single / split
masterWidth: number // 左栏宽度
showBackButton: boolean // 详情页是否显示返回
showSideBorder: boolean // 是否显示分栏分隔线
itemHeight: number // 列表项高度
titleSize: number // 标题字号
}
@Entry
@Component
struct DeviceAdaptPage {
@State config: LayoutConfig = {
mode: 'single', masterWidth: 0, showBackButton: true,
showSideBorder: false, itemHeight: 72, titleSize: 20
}
@State selectedId: string = '1'
@State showDetail: boolean = false
aboutToAppear() {
let screenWidth = 360
// 实际项目中用 display.getDefaultDisplaySync() 获取
if (screenWidth >= 600) {
this.config = {
mode: 'split', masterWidth: 320, showBackButton: false,
showSideBorder: true, itemHeight: 80, titleSize: 22
}
}
}
@Builder
MasterPanel() {
Column() {
Text('邮件列表')
.fontSize(this.config.titleSize)
.fontWeight(FontWeight.Bold)
.padding({ left: 16, top: 16, bottom: 12 })
List() {
ForEach([1, 2, 3, 4, 5, 6, 7, 8], (item: number) => {
ListItem() {
Row() {
Column() {
Text('邮件标题 ' + item)
.fontSize(15)
.fontWeight(FontWeight.Medium)
Text('摘要信息预览...')
.fontSize(13)
.fontColor('#999999')
.margin({ top: 4 })
}
.alignItems(HorizontalAlign.Start)
.layoutWeight(1)
}
.width('100%')
.height(this.config.itemHeight)
.padding({ left: 16, right: 16 })
.backgroundColor(this.selectedId === item.toString() ? '#e3f2fd' : Color.Transparent)
.onClick(() => {
this.selectedId = item.toString()
if (this.config.mode === 'single') {
this.showDetail = true
}
})
}
}, (item: number) => item.toString())
}
.width('100%')
.layoutWeight(1)
.divider({ strokeWidth: 0.5, color: '#eeeeee' })
}
.width('100%')
.height('100%')
}
@Builder
DetailPanel() {
Column() {
if (this.config.showBackButton) {
Row() {
Text('< 返回')
.fontSize(16)
.fontColor('#007DFF')
.onClick(() => {
this.showDetail = false
})
}
.width('100%')
.padding({ left: 16, top: 16, bottom: 12 })
}
Text('详情: 项目 ' + this.selectedId)
.fontSize(this.config.titleSize)
.fontWeight(FontWeight.Bold)
.padding(16)
Text('这里是详细内容,根据选中项动态展示。')
.fontSize(15)
.fontColor('#666666')
.padding({ left: 16, right: 16 })
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Start)
}
build() {
if (this.config.mode === 'split') {
Row() {
Column() {
this.MasterPanel()
}
.width(this.config.masterWidth)
.height('100%')
if (this.config.showSideBorder) {
Column()
.width(1)
.height('100%')
.backgroundColor('#e0e0e0')
}
Column() {
this.DetailPanel()
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
} else {
if (this.showDetail) {
Column() {
this.DetailPanel()
}
.width('100%')
.height('100%')
} else {
Column() {
this.MasterPanel()
}
.width('100%')
.height('100%')
}
}
}
}
关键区别: 手机上点击列表项进入详情页(showDetail = true),有返回按钮;平板上左右同时展示,无需返回。这些差异全部由 LayoutConfig 驱动,组件代码不用写 if/else 判断设备类型。
折叠屏专项适配
折叠屏的特殊之处在于——运行时屏幕宽度会变化(折叠和展开),布局必须实时响应。除了 mediaQuery 监听,还要注意状态保持——展开后之前选中的详情不能丢。
@Entry
@Component
struct FoldableAdaptPage {
@State isSplitMode: boolean = false
@State selectedId: string = '1'
@State showDetail: boolean = false
@State masterWidth: number = 0
private foldListener: number = -1
aboutToAppear() {
// 监听折叠屏状态变化
this.foldListener = mediaQuery.matchMediaSync('(520vp<=width)')
.on('change', (data: mediaQuery.MediaQueryResult) => {
animateTo({ duration: 300, curve: Curve.EaseInOut }, () => {
if (data.matches) {
// 展开态:切分栏,保留选中状态
this.isSplitMode = true
this.masterWidth = 320
this.showDetail = false
} else {
// 折叠态:切单栏
this.isSplitMode = false
this.masterWidth = 0
// 如果有选中项,自动进入详情
if (this.selectedId !== '') {
this.showDetail = true
}
}
})
})
}
aboutToDisappear() {
let matcher = mediaQuery.matchMediaSync('(520vp<=width)')
matcher.off('change')
}
build() {
if (this.isSplitMode) {
Row() {
// 左栏
Column() {
List() {
ForEach([1, 2, 3, 4, 5], (item: number) => {
ListItem() {
Text('项目 ' + item)
.fontSize(16)
.padding(16)
.width('100%')
.backgroundColor(this.selectedId === item.toString() ? '#e3f2fd' : Color.Transparent)
}
.onClick(() => {
this.selectedId = item.toString()
})
}, (item: number) => item.toString())
}
.width('100%')
.layoutWeight(1)
}
.width(this.masterWidth)
.height('100%')
.clip(true)
Column()
.width(1)
.height('100%')
.backgroundColor('#e0e0e0')
// 右栏
Column() {
Text('详情: ' + this.selectedId)
.fontSize(20)
.padding(20)
}
.layoutWeight(1)
.height('100%')
}
.width('100%')
.height('100%')
} else {
if (this.showDetail) {
Column() {
Text('< 返回')
.fontSize(16)
.fontColor('#007DFF')
.padding(16)
.onClick(() => { this.showDetail = false })
Text('详情: ' + this.selectedId)
.fontSize(20)
.padding({ left: 16 })
}
.width('100%')
.height('100%')
.alignItems(HorizontalAlign.Start)
} else {
Column() {
List() {
ForEach([1, 2, 3, 4, 5], (item: number) => {
ListItem() {
Text('项目 ' + item)
.fontSize(16)
.padding(16)
.width('100%')
}
.onClick(() => {
this.selectedId = item.toString()
this.showDetail = true
})
}, (item: number) => item.toString())
}
.width('100%')
.layoutWeight(1)
}
.width('100%')
.height('100%')
}
}
}
}
注意: 折叠态切展开态时,如果用户正看着某条详情,selectedId 要保留,展开后右栏直接显示该详情,无缝衔接。反过来展开切折叠时,自动进入详情视图,不丢失上下文。
踩坑清单
| 问题 | 原因 | 解决 |
|---|---|---|
| 窄屏分栏内容挤压 | 固定百分比宽度在小屏不够分 | 小于 520vp 切单栏模式 |
| mediaQuery 监听未注销 | aboutToDisappear 没有调 off | 页面销毁时务必 off 所有监听 |
| Navigation Split 模式空白 | 未配置 navDestination | 必须实现 navDestination Builder |
| 切换动画闪烁 | 直接修改 visible 无过渡 | 用 animateTo 包裹宽度变化 |
| Grid 列数切换跳动 | columnsTemplate 瞬变无动画 | animateTo 包裹 columnsCount 赋值 |
| 折叠屏展开后选中丢失 | 布局切换时重置了 selectedId | 保留状态,不要在断点回调中清空 |
| 左栏收起时内容溢出 | 宽度变小但内容没 clip | 给左栏加 .clip(true) |
| 详情页返回后列表位置变了 | 重建列表丢失滚动位置 | 用 scroller 保存滚动偏移 |
| 分栏分隔线消失 | 用 if 控制但条件不对 | 用 LayoutConfig 中的 showSideBorder 驱动 |
| LayoutConfig 修改不刷新 | 直接改对象属性未触发响应式 | 整体替换 config 对象引用 |
更多推荐

所有评论(0)