鸿蒙 HarmonyOS 6 | Pura X Max 鸿蒙原生适配 06:GridRow 做卡片自适应布局 原创
头像 小雨同学 2026-05-16 15:48:17    发布
1 浏览 0 点赞 0 收藏

前言

Pura X Max 的首页、工作台、功能入口、统计面板,很容易遇到同一个问题:卡片数量不少,但不同窗口宽度下应该展示几列并不一样。

外屏空间有限,一列最稳。展开态内屏空间更大,如果还保持一列,页面会显得松散;如果直接写死三列,中等窗口又会被挤得很难看。卡片列表适合交给栅格系统处理,让它根据窗口宽度自动决定每张卡片占多少列。

Pura X Max 外屏为 5.4 英寸,内屏为 7.7 英寸,外屏分辨率为 1848 × 1264,内屏分辨率为 2584 × 1828,系统版本为 HarmonyOS 6.1。这个尺寸变化非常适合用栅格来处理卡片自适应。

ArkUI 的 GridRowGridCol 正是为这类响应式栅格准备的。GridRow 作为栅格容器,直接子组件需要使用 GridCol,而 GridCol 可以通过 span 控制不同断点下占用的列数。

卡片列表更适合用栅格管理

卡片列表和普通长列表不一样。

普通长列表强调顺序阅读,一条接一条往下看。卡片列表更强调信息聚合,比如首页统计卡、工作台入口、快捷功能、项目概览。它们之间相对独立,用户更关心一屏能看到多少内容。

这种页面如果手动判断宽度,再分别写一列、两列、三列,代码会很快变散。

比如:

if (width < 600) {  // 一列} else if (width < 900) {  // 两列} else {  // 三列}

这样写当然能工作,但每个卡片容器都要自己处理列数,后续改间距、改断点、改卡片宽度时,维护成本会越来越高。

GridRow 的思路更适合这类页面。先把整体栅格分成 12 列,再让每个 GridCol 在不同断点下占用不同列数。

小屏下,一张卡片占 12 列,一行 1 张。

中屏下,一张卡片占 6 列,一行 2 张。

大屏下,一张卡片占 4 列,一行 3 张。

写起来就是:

GridCol({ span: { xs: 12, sm: 6, md: 4 } }) {  // 卡片内容}

这个写法比手动写三套布局干净。卡片本身只关心内容,列数交给栅格系统。

用 GridRow 控制 1 列、2 列、3 列

栅格布局里有三个关键点。

第一个是总列数。

columns: 12

12 列比较常见,适合做 1、2、3、4 等多种分割。对于工作台卡片来说,1、2、3 列都能比较自然地落到 12 列体系里。

第二个是间距。

gutter: { x: 12, y: 12 }

x 控制横向间距,y 控制纵向间距。卡片列表最怕贴得太紧,尤其展开态下,如果卡片之间没有合适留白,页面会显得很挤。

第三个是断点。

breakpoints: {  value: ['520vp', '820vp'],  reference: BreakpointsReference.ComponentSize}

这里使用 ComponentSize,目的是让栅格根据当前组件容器宽度切换断点。Pura X Max 的真实开合可以验证外屏和展开态,页面里额外放了几个演示宽度按钮,用来观察中间状态。真实项目里可以把演示按钮去掉,让容器直接跟随窗口宽度。

这组断点会形成三档:

低于 520vp,进入 xs。

520vp 到 820vp,进入 sm。

大于等于 820vp,进入 md。

配合 span: { xs: 12, sm: 6, md: 4 },页面就会得到 1 列、2 列、3 列三种效果。

把工作台卡片跑起来

下面这个页面模拟了一个整理类应用的工作台,有统计卡片、功能入口和当前选中卡片。页面可以放到 entry/src/main/ets/pages/Index.ets 运行。

为了在 Pura X Max 模拟器里更稳定地观察 1 列、2 列、3 列,页面顶部加了自动、1 列、2 列、3 列几个按钮。选择自动时,栅格跟随真实窗口宽度;选择其他按钮时,只改变内容容器宽度,用来观察不同断点下的卡片排列。

interface WorkbenchCard {  id: number;  title: string;  value: string;  unit: string;  desc: string;  status: string;}​@Entry@Componentstruct Index {  @State private pageWidth: number = 0;  @State private previewWidth: number = 0;  @State private selectedId: number = 1;​  private readonly cards: WorkbenchCard[] = [    {      id: 1,      title: '待处理材料',      value: '12',      unit: '条',      desc: '来自拍照整理、相册导入和文本记录',      status: '需要处理'    },    {      id: 2,      title: '今日提醒',      value: '5',      unit: '项',      desc: '从通知、会议和预约信息中提取',      status: '即将开始'    },    {      id: 3,      title: '已整理记录',      value: '48',      unit: '条',      desc: '本周已经完成结构化整理的内容',      status: '稳定'    },    {      id: 4,      title: '待确认摘要',      value: '7',      unit: '条',      desc: '需要人工确认识别结果和行动项',      status: '待确认'    },    {      id: 5,      title: '图片识别',      value: '23',      unit: '张',      desc: '来自原图预览和 OCR 处理流程',      status: '处理中'    },    {      id: 6,      title: '会议记录',      value: '9',      unit: '场',      desc: '包含会议纪要、行动项和关联项目',      status: '已同步'    },    {      id: 7,      title: '收藏内容',      value: '16',      unit: '条',      desc: '长期保留的关键资料和常用信息',      status: '稳定'    },    {      id: 8,      title: '异常识别',      value: '3',      unit: '条',      desc: '需要重新确认图片清晰度和文字结构',      status: '待确认'    },    {      id: 9,      title: '本周新增',      value: '31',      unit: '条',      desc: '本周新增的材料、通知和会议记录',      status: '增长中'    }  ];​  private getEffectiveWidth(): number {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return this.pageWidth;  }​  private getModeText(): string {    const width = this.getEffectiveWidth();​    if (width >= 820) {      return 'md · 三列卡片';    }​    if (width >= 520) {      return 'sm · 双列卡片';    }​    return 'xs · 单列卡片';  }​  private getModeDesc(): string {    const width = this.getEffectiveWidth();​    if (width >= 820) {      return '当前容器进入三列状态,适合 Pura X Max 展开态、平板和更宽的工作台页面。';    }​    if (width >= 520) {      return '当前容器进入双列状态,适合中等宽度窗口和部分分屏场景。';    }​    return '当前容器进入单列状态,适合外屏、窄窗口和小尺寸悬浮窗。';  }​  private getContentWidth(): Length {    if (this.previewWidth > 0) {      return this.previewWidth;    }​    return '100%';  }​  private getPagePadding(): number {    const width = this.getEffectiveWidth();​    if (width >= 820) {      return 24;    }​    if (width >= 520) {      return 20;    }​    return 16;  }​  private getTitleSize(): number {    const width = this.getEffectiveWidth();​    if (width >= 820) {      return 28;    }​    if (width >= 520) {      return 25;    }​    return 22;  }​  private getSelectedCard(): WorkbenchCard {    const found = this.cards.find((item: WorkbenchCard) => item.id === this.selectedId);    return found ? found : this.cards[0];  }​  private getStatusColor(status: string): string {    if (status === '需要处理' || status === '待确认') {      return '#B25E00';    }​    if (status === '处理中' || status === '即将开始' || status === '增长中') {      return '#7C3AED';    }​    return '#276749';  }​  private getStatusBgColor(status: string): string {    if (status === '需要处理' || status === '待确认') {      return '#FFF4E5';    }​    if (status === '处理中' || status === '即将开始' || status === '增长中') {      return '#F1EAFE';    }​    return '#E7F5EE';  }​  private setPreview(width: number) {    this.previewWidth = width;  }​  @Builder  private HeaderPanel() {    Column({ space: 10 }) {      Row() {        Column({ space: 4 }) {          Text('GridRow 卡片自适应布局')            .fontSize(this.getTitleSize())            .fontWeight(FontWeight.Bold)            .fontColor('#111827')​          Text(this.getModeText())            .fontSize(14)            .fontColor('#2F8F83')        }        .layoutWeight(1)​        Text('窗口 ' + Math.round(this.pageWidth).toString() + 'vp')          .fontSize(12)          .fontColor('#374151')          .padding({ left: 10, right: 10, top: 6, bottom: 6 })          .backgroundColor('#FFFFFF')          .borderRadius(999)      }      .width('100%')​      Text('栅格容器宽度:' + Math.round(this.getEffectiveWidth()).toString() + 'vp。' + this.getModeDesc())        .fontSize(14)        .fontColor('#6B7280')        .lineHeight(21)​      Row({ space: 8 }) {        this.PreviewButton('自动', 0)        this.PreviewButton('1 列', 420)        this.PreviewButton('2 列', 680)        this.PreviewButton('3 列', 960)      }      .width('100%')    }    .width('100%')  }​  @Builder  private PreviewButton(text: string, width: number) {    Text(text)      .fontSize(12)      .fontColor(this.previewWidth === width ? '#FFFFFF' : '#2F8F83')      .textAlign(TextAlign.Center)      .padding({ left: 10, right: 10, top: 7, bottom: 7 })      .backgroundColor(this.previewWidth === width ? '#2F8F83' : '#E6F4F1')      .borderRadius(999)      .onClick(() => {        this.setPreview(width);      })  }​  @Builder  private StatusPill(status: string) {    Text(status)      .fontSize(12)      .fontColor(this.getStatusColor(status))      .padding({ left: 8, right: 8, top: 4, bottom: 4 })      .backgroundColor(this.getStatusBgColor(status))      .borderRadius(999)  }​  @Builder  private WorkbenchCardView(item: WorkbenchCard) {    Column({ space: 12 }) {      Row() {        this.StatusPill(item.status)​        Blank()​        if (this.selectedId === item.id) {          Text('当前')            .fontSize(12)            .fontColor('#2F8F83')        }      }      .width('100%')​      Column({ space: 4 }) {        Text(item.title)          .fontSize(16)          .fontWeight(FontWeight.Medium)          .fontColor('#111827')          .maxLines(1)          .textOverflow({ overflow: TextOverflow.Ellipsis })​        Row({ space: 6 }) {          Text(item.value)            .fontSize(32)            .fontWeight(FontWeight.Bold)            .fontColor('#111827')​          Text(item.unit)            .fontSize(14)            .fontColor('#6B7280')            .margin({ top: 12 })        }        .alignItems(VerticalAlign.Top)      }      .width('100%')      .alignItems(HorizontalAlign.Start)​      Text(item.desc)        .fontSize(13)        .fontColor('#6B7280')        .lineHeight(19)        .maxLines(2)        .textOverflow({ overflow: TextOverflow.Ellipsis })    }    .width('100%')    .padding(16)    .backgroundColor(this.selectedId === item.id ? '#EEF7F5' : '#FFFFFF')    .borderRadius(20)    .border({      width: this.selectedId === item.id ? 1.5 : 1,      color: this.selectedId === item.id ? '#2F8F83' : '#E5E7EB'    })    .shadow({      radius: this.selectedId === item.id ? 12 : 8,      color: '#12000000',      offsetX: 0,      offsetY: 4    })    .onClick(() => {      this.selectedId = item.id;    })  }​  @Builder  private GridCardArea() {    Scroll() {      GridRow({        columns: 12,        gutter: { x: 12, y: 12 },        breakpoints: {          value: ['520vp', '820vp'],          reference: BreakpointsReference.ComponentSize        }      }) {        ForEach(this.cards, (item: WorkbenchCard) => {          GridCol({ span: { xs: 12, sm: 6, md: 4 } }) {            this.WorkbenchCardView(item)          }        }, (item: WorkbenchCard) => item.id.toString())      }      .width('100%')      .padding({ bottom: 18 })    }    .layoutWeight(1)    .width('100%')    .edgeEffect(EdgeEffect.Spring)  }​  @Builder  private SelectedPanel() {    Column({ space: 10 }) {      Row() {        Text('当前选中')          .fontSize(16)          .fontWeight(FontWeight.Medium)          .fontColor('#111827')​        Blank()​        this.StatusPill(this.getSelectedCard().status)      }      .width('100%')​      Text(this.getSelectedCard().title)        .fontSize(18)        .fontWeight(FontWeight.Bold)        .fontColor('#111827')        .maxLines(1)        .textOverflow({ overflow: TextOverflow.Ellipsis })​      Text(this.getSelectedCard().desc)        .fontSize(14)        .fontColor('#4B5563')        .lineHeight(21)        .maxLines(2)        .textOverflow({ overflow: TextOverflow.Ellipsis })​      Row({ space: 10 }) {        Button('处理')          .fontSize(14)          .fontColor('#FFFFFF')          .height(40)          .layoutWeight(1)          .backgroundColor('#2F8F83')          .borderRadius(20)​        Button('详情')          .fontSize(14)          .fontColor('#2F8F83')          .height(40)          .layoutWeight(1)          .backgroundColor('#E6F4F1')          .borderRadius(20)      }      .width('100%')    }    .width('100%')    .padding(16)    .backgroundColor('#FFFFFF')    .borderRadius(22)    .shadow({      radius: 10,      color: '#10000000',      offsetX: 0,      offsetY: 4    })  }​  build() {    Column() {      Column({ space: 16 }) {        this.HeaderPanel()        this.SelectedPanel()        this.GridCardArea()      }      .width(this.getContentWidth())      .height('100%')      .padding({        left: this.getPagePadding(),        right: this.getPagePadding(),        top: 18,        bottom: 16      })    }    .width('100%')    .height('100%')    .alignItems(HorizontalAlign.Center)    .backgroundColor('#F6F7F9')    .onAreaChange((_: Area, newValue: Area) => {      const width = Number(newValue.width);      if (!Number.isNaN(width) && width > 0) {        this.pageWidth = width;      }    })  }}

关键实现点和运行结果

页面运行后,选择“自动”时,栅格会跟随真实窗口宽度。Pura X Max 外屏通常会落到单列或较窄状态,展开态会更容易进入三列状态。中间宽度不一定能通过普通开合稳定触发,所以页面顶部提供了 1 列、2 列、3 列按钮,用来观察栅格在不同容器宽度下的变化。

点击 1 列,内容容器限制为 420vp。所有工作台卡片从上到下排列,比较接近外屏或窄窗口。这个状态下,一屏能看到的卡片数量少一些,但阅读和点击都稳定。

点击 2 列,内容容器限制为 680vp。卡片变成两列,适合中等窗口。这个状态常见于分屏、平板窄窗口或自由窗口,不一定是 Pura X Max 普通开合里的固定形态。

点击 3 列,内容容器限制为 960vp。卡片变成三列,更接近 Pura X Max 展开态或大屏工作台。统计卡片一屏能展示更多内容,用户可以更快扫到整体状态。

关键逻辑集中在 GridRowGridCol

GridRow({  columns: 12,  gutter: { x: 12, y: 12 },  breakpoints: {    value: ['520vp', '820vp'],    reference: BreakpointsReference.ComponentSize  }}) {  GridCol({ span: { xs: 12, sm: 6, md: 4 } }) {    this.WorkbenchCardView(item)  }}

columns: 12 定义整体栅格列数。span: { xs: 12, sm: 6, md: 4 } 决定每张卡片在不同断点下占多少列。xs 状态下每张卡片占 12 列,所以一行一张;sm 状态下每张卡片占 6 列,所以一行两张;md 状态下每张卡片占 4 列,所以一行三张。

reference: BreakpointsReference.ComponentSize 让断点跟随栅格容器宽度变化。这个设置很适合组件化页面。比如工作台卡片区放在页面左侧、右侧、弹层或不同窗口里时,栅格可以根据自身容器宽度重新排列,而不是只看整个窗口宽度。

真实项目里,演示按钮可以去掉,保留 GridRow 自己的响应式能力。首页统计卡、功能入口、快捷操作、看板卡片,都适合用这种方式处理。

需要注意的是,GridRow 更适合高度接近、结构相似的卡片。如果卡片高度差异很大,比如有的卡片只有一行文字,有的卡片包含长文本和图片,普通栅格容易出现行高不一致的问题。这类场景更适合单独评估 WaterFlow 或者重新控制卡片内容高度。

总结

Pura X Max 上的卡片列表,不适合只用固定宽度或固定列数处理。

GridRowGridCol 可以把列数变化交给栅格系统。小屏一列,中屏两列,大屏三列,页面结构会随着容器宽度自然变化。对首页、工作台、功能入口和统计卡片来说,这种方式比手写多套布局更稳,也更容易迁移到分屏、自由窗口、平板和 2in1 场景。

真正落到项目里,重点不在于追求列数越多越好,而是保证每张卡片在当前宽度下仍然可读、可点、可比较。外屏先保证稳定,展开态再提高信息密度,卡片布局才能真正适配 Pura X Max 的窗口变化。


©本站发布的所有内容,包括但不限于文字、图片、音频、视频、图表、标志、标识、广告、商标、商号、域名、软件、程序等,除特别标明外,均来源于网络或用户投稿,版权归原作者或原出处所有。我们致力于保护原作者版权,若涉及版权问题,请及时联系我们进行处理。
分类
HarmonyOS
头像

小雨同学

产品总监、独立开发者社群主理人、资深全栈工程师,HarmonyOS应用开发者高级认证,PMP认证,CSDN博客专家,鸿蒙极客,Trae Fellow,阿里云社区专家博主、51CTO 博客专家、OpenTiny 优秀布道师、科大讯飞荣誉讲师。

7

帖子

0

提问

24

粉丝

关注
热门推荐
地址:北京市朝阳区北三环东路三元桥曙光西里甲1号第三置业A座1508室 商务内容合作QQ:2291221 电话:13391790444或(010)62178877
版权所有:电脑商情信息服务集团 北京赢邦策略咨询有限责任公司
声明:本媒体部分图片、文章来源于网络,版权归原作者所有,我司致力于保护作者版权,如有侵权,请与我司联系删除

京ICP备:2022009079号-2

京公网安备:11010502051901号

ICP证:京B2-20230255