ArkUI 栅格布局(对照 Android):用 GridRow/GridCol 把“多列 + 断点适配”写成可维护的规则

你写完 Row/ColumnStackFlexRelativeContainer 之后,下一类很常见的页面结构就是“栅格”:

  • 首页功能入口宫格
  • 分类页/频道页的卡片墙
  • 大屏上的“右侧多列内容区”

这类页面的痛点通常也很一致:同一套数据,手机两列、平板三列、横屏四列;布局应该跟着窗口宽度变,而不是靠一堆 if/else 手写 width 去凑。

这一篇先做一个“入口宫格”:手机上每行 2 个入口,中等宽度每行 2 个,大屏每行 3 个;遇到 Banner 或提示条时,可以让它跨满整行。这里主要用 GridRow / GridCol 讲断点、列数、spanoffset,不展开列表虚拟化、瀑布流、导航和动效。

先把“栅格”算清楚

栅格布局不是直接说“卡片宽 160vp”,而是先把容器横向切成 N 列,再定每个卡片占其中几列:

N = 12 列(lg)
|1|2|3|4|5|6|7|8|9|10|11|12|

一个卡片 span=4(占 4 列)
[  card  ][  card  ][  card  ]

当屏幕变窄,你通常不会改“卡片长什么样”,而是改两件事:

  • 这一行切成多少列(columns
  • 卡片之间的间距(gutter

所以栅格需要考虑的通常不是“宽高”,而是这套规则:断点决定总列数,卡片用 span 决定自己占几列。

在下面的示例里,规则可以先写成这样:

断点 columns 普通入口 span 实际效果
sm 4 2 每行 2 个入口
md 8 4 每行 2 个入口,卡片更宽
lg 12 4 每行 3 个入口

这张表比“某个卡片宽多少”更重要。设计改成大屏 4 列时,你只需要调整 lg 下的 span,而不是逐个改卡片宽度。

ArkUI:先把入口宫格做出来(GridRow/GridCol)

下面这个例子只做三件事:

  • columns:三套列数(例子用 4 / 8 / 12)
  • gutter:统一间距(x/y)
  • span/offset:每个卡片“占几列/偏几列”
interface GridEntry {
  title: string
  desc: string
  symbol: string
  color: ResourceColor
}

@Entry
@Component
struct EntryGridDemo {
  private entries: GridEntry[] = [
    { title: '扫一扫', desc: '扫码 / 识别', symbol: '扫', color: '#2563EB' },
    { title: '出行', desc: '公交 / 地铁', symbol: '行', color: '#16A34A' },
    { title: '外卖', desc: '附近服务', symbol: '餐', color: '#EA580C' },
    { title: '缴费', desc: '水电燃气', symbol: '缴', color: '#7C3AED' },
    { title: '更多', desc: '全部入口', symbol: '···', color: '#475569' },
  ]

  build() {
    Column() {
      GridRow({
        columns: { sm: 4, md: 8, lg: 12 },
        gutter: { x: 12, y: 12 },
        direction: GridRowDirection.Row,
        breakpoints: {
          value: ['320vp', '600vp', '840vp'],
          reference: BreakpointsReference.WindowSize
        }
      }) {
        ForEach(this.entries, (item: GridEntry) => {
          GridCol({
            span: { sm: 2, md: 4, lg: 4 },
            offset: { sm: 0, md: 0, lg: 0 },
          }) {
            Row() {
              Text(item.symbol)
                .fontSize(16)
                .fontColor(Color.White)
                .textAlign(TextAlign.Center)
                .width(36)
                .height(36)
                .borderRadius(18)
                .backgroundColor(item.color)

              Column() {
                Text(item.title)
                  .fontSize(14)
                  .fontWeight(FontWeight.Medium)
                Text(item.desc)
                  .fontSize(11)
                  .fontColor('#64748B')
                  .margin({ top: 3 })
              }
              .alignItems(HorizontalAlign.Start)
              .margin({ left: 10 })
            }
            .width('100%')
            .padding(12)
            .borderRadius(12)
            .backgroundColor($r('sys.color.background_secondary'))
            .alignItems(VerticalAlign.Center)
          }
        }, (item: GridEntry) => item.title)
      }
      .padding(16)
      .onBreakpointChange((breakpoint: string) => {
        console.info(`grid breakpoint: ${breakpoint}`)
      })
    }
    .width('100%')
  }
}

效果:

Pasted image 20260602001941
列宽会随着屏幕大小动态自动适配,这就是自适应的作用。当新增屏幕时,不需要调整代码。 读这段代码时,优先看这四个点:

  1. breakpoints.reference 决定按谁的宽度切断点。这里用 WindowSize,适合多窗口或窗口拉伸场景。
  2. columns 决定当前断点下“这一行切成几列”。
  3. span 决定“这个卡片占几列”。例子里 sm: 2md: 4lg: 4,所以分别是 2 列、2 列、3 列。
  4. offset 决定“这个卡片向右空出几列”(常用于居中块/右对齐块)。
  5. gutter 决定“卡片之间的距离”。

这里没有让业务入口图标依赖应用入口标识。应用入口图标不是内容资源;教学示例里可以先用文字标识和颜色块表达结构,真实项目再替换成业务图标资源。

两个小坑也顺便记住:

  • span/offset 尽量用整数,不要用小数(官方推荐规则里也明确不建议用小数)。
  • 不要让所有 GridColspan 都等于当前 columns。那等价于所有子组件永远占满一整行,栅格系统没有发挥作用,还会增加组件树复杂度。

一个常见变体:某张卡片在大屏占满整行

只要把 span 改成 { sm: 4, md: 8, lg: 12 },它就会在不同断点下自动“占满整行”。这个写法适合 Banner、风险提示、运营位这类本来就应该横跨整行的内容:

GridCol({ span: { sm: 4, md: 8, lg: 12 } }) {
  // Banner / 提示条 / 运营位
}

Android:同一个入口宫格,在 View/Compose 里怎么落地

1) View 体系(RecyclerView + GridLayoutManager)

你的核心变量是 spanCount(一行切成几份),和 ArkUI 的 columns 是一回事。

val spanCount = when (windowSizeClass.widthSizeClass) {
  WindowWidthSizeClass.Compact -> 2
  WindowWidthSizeClass.Medium -> 3
  else -> 4
}

val layoutManager = GridLayoutManager(context, spanCount)
layoutManager.spanSizeLookup = object : GridLayoutManager.SpanSizeLookup() {
  override fun getSpanSize(position: Int): Int = 1 // 某些位置想占满整行就返回 spanCount
}
recyclerView.layoutManager = layoutManager

间距一般用 ItemDecoration 统一处理(对应 ArkUI 的 gutter),不要把 margin 散落在每个 item 里。

2) Compose(LazyVerticalGrid)

Compose 更常见的写法是“固定列”或“自适应列”:

LazyVerticalGrid(
  columns = GridCells.Adaptive(minSize = 160.dp),
  contentPadding = PaddingValues(16.dp),
  horizontalArrangement = Arrangement.spacedBy(12.dp),
  verticalArrangement = Arrangement.spacedBy(12.dp),
) {
  items(entries) { entry ->
    EntryCard(entry)
  }
}

这段代码表达的是:我只给“卡片最小宽度”和“间距”,列数由系统推导。 如果你需要“某个 item 占满整行”,一般会用 GridItemSpan(或把它放到单独的区块):

item(span = { GridItemSpan(maxLineSpan) }) { Banner() }

对照 ArkUI 时,重点不是逐行翻译 LazyVerticalGrid,而是把“列数推导结果”显式化:sm/md/lg 各是多少列、常规卡片 span=多少、哪些卡片需要满行/偏移。

对照表:GridRow/GridCol ↔ Android 的三种栅格思路

你脑子里的问题 Android(View/XML)常用做法 Android(Compose)常用做法 ArkUI(ArkTS)常用做法 学习转换点
“整行被切成几列?” GridLayoutManager(spanCount) GridCells.Fixed(n) / Adaptive(minSize) GridRow({ columns: { sm, md, lg } }) ArkUI 更偏“断点配置表”
“某个卡片占几列?” SpanSizeLookup GridItemSpan GridCol({ span: ... }) 本质都是 span
“间距在哪里管?” ItemDecoration Arrangement.spacedBy gutter 间距尽量集中管理
“断点怎么做?” sw<N>dp / WindowSizeClass WindowSizeClass breakpoints + { sm, md, lg } 断点只影响配置,不影响结构

检查清单:把栅格从“能排”写到“可维护”

  • 先确定“内容密度”的目标:手机 2 列/3 列?平板 3 列/4 列?
  • 把“断点 → 列数”做成一张表:Android(WindowSizeClass)与 ArkUI(columns: { sm, md, lg })都只读这张表
  • 把“卡片占几列”统一用 span 表达:不要在卡片内部用 width 去硬凑
  • 间距优先放在栅格层(gutter / Arrangement.spacedBy),卡片内部只管 padding
  • 有“居中块/右对齐块”时,优先用 offset(ArkUI)或 SpanSizeLookup/分组(Android)表达意图,不要靠空白占位 hack

参考素材(少量高质量)

// Kai@CodeHubble // 观测坐标:Android-HarmonyOS/2026-05-26

上一篇