分栏布局——左边列表右边详情,这是邮件、备忘录、文件管理器的标配。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 对象引用
Logo

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

更多推荐