鸿蒙 HarmonyOS 6|ArkUI(02):线性布局到网格与滚动,五大容器实战
做 ArkUI 页面,容器就是地基。Row、Column、Stack、Grid、Scroll(配合 Scroller)这五个家伙经常出镜:顶部工具条、信息流、卡片墙、海报叠层、工具面板,基本都能靠它们撑起来。
UI 容器并不神秘,它们负责的就是四个老问题:内容沿哪个方向排、怎么对齐、尺寸怎么占位、滚动谁来接手。
Row 让子项横着排,Column 让子项竖着排;“主轴”和“交叉轴”的词看似陌生,其实就是“沿着排列方向”和“垂直于排列方向”。
对齐属性也分这两类:主轴上的分布交给 justify(不同版本命名略有差异),交叉轴交给 alignItems;如果某个子项要单独“破队形”,用 alignSelf。还有一个常被忽略的点是权重:layoutWeight 会把主轴剩余空间按比例分掉,谁权重大谁多吃一点,这在做均分布局时很顺手。
尺寸与约束则是“第二层安全带”:width/height/size 决定占位,padding/margin 管内外边距,最小/最大值的约束防止组件在不同屏幕尺寸下“炸形”。写页面时,一般先把容器定方向,再根据内容补对齐,最后用尺寸与约束把边界收拢,这样结构会更稳。如果你遇到“对齐不起作用”的错觉,十有八九是把方向想反了,或者属性写在了不该管事的那层。
一、Row 与 Column:先把骨架立起来
先做个最常见的工具条:左边是标题区,右边是按钮组。外层用 Row 把两块横着摆好;左边再用 Column 放标题和副标题,层级就自然了;右边的按钮组自己用 Row 管间距。为了让标题区吃掉多余空间,给它一个 layoutWeight(1);为了避免按钮挤成一团,给右侧一个最小宽度。
// Index.ets
@Entry
@Component
export struct Index {build() {Column() {// 工具条:左侧标题 + 右侧按钮组Row({ space: 12 }) {// 左侧标题列Column({ space: 2 }) {Text('项目面板').fontSize(18).fontWeight(FontWeight.Medium)Text('最近更新 · 3 个任务进行中').fontSize(12).opacity(0.7)}.layoutWeight(1) // 左侧占据剩余空间// 右侧按钮组Row({ space: 8 }) {Button('新建')Button('筛选')Button('更多')}}.width('100%').padding({ left: 16, right: 16, top: 12, bottom: 12 })// 占位内容Column({ space: 8 }) {Text('这里是内容区域示意').opacity(0.6)Text('Row/Column 的间距与权重已就位').opacity(0.6)}.padding(16)}.width('100%').height('100%')}
}
这里有两个小提醒。
第一,分清“谁是主轴谁是交叉轴”,才能判断把对齐写在 Row/Column 的哪一个层;
第二,别忘了检查子项是否单独设置了 align(或 alignSelf),它会覆盖父级对齐。相关规则可以在通用定位与对齐的英文条目里查到。
二、Stack:叠放关系一次写对,就不必算坐标
碰到“底图、遮罩、角标按钮”这种叠层,Stack 很省力。
它默认后面的子项盖在前面的上面,alignContent 决定整组内容对齐到哪里(九宫格那套)。所以只要保证顺序是“先底图,再遮罩,最后按钮”,基本不需要手算绝对定位。
// Index.ets
@Entry
@Component
export struct Index {build() {Column({ space: 12 }) {// 海报卡:底图 + 底部半透明遮罩 + 右下角按钮Stack({ alignContent: Alignment.BottomEnd }) {Image($r('app.media.foreground')).objectFit(ImageFit.Cover).width('100%').height(200).borderRadius(12)// 底部遮罩,可随时加标题/副标题Column().height(64).width('100%').backgroundColor('#66000000').borderRadius({ bottomLeft: 12, bottomRight: 12 })Button('立即查看').margin({ right: 12, bottom: 12 })}.padding({ left: 12, right: 12, top: 12 })// 占位内容Text('Stack 叠层示意,alignContent=BottomEnd').opacity(0.65).padding(12)}.width('100%').height('100%')}
}
为什么不把遮罩“烤进”图片?因为叠一层 Column 之后,你随时能在遮罩上加标题、副标题或操作区;视觉样式、交互热区和可访问性都更好维护。
三、Grid:规则网格的秩序感
需要规则的多列卡片时,用 Grid 最省事:列、行、间距都能直接在组件上声明。
columnsTemplate/rowsTemplate 负责网格轨道,columnsGap/rowsGap 负责间距。
两列卡片常见写法就是 columnsTemplate(‘1fr 1fr’),你也可以在断点处切模板,控制三列或四列的布局。
// Index.ets // 1) 明确声明条目类型(类或接口都行;这里用类,符合官方示例)
class CardItem {title: string = ''desc: string = ''
}@Entry
@Component
export struct Index {// 2) 数组显式标注为 CardItem[],元素按 CardItem 结构书写private items: CardItem[] = [{ title: '卡片 A', desc: '描述 A' },{ title: '卡片 B', desc: '描述 B' },{ title: '卡片 C', desc: '描述 C' },{ title: '卡片 D', desc: '描述 D' },]build() {Column() {Text('两列卡片').fontSize(18).fontWeight(FontWeight.Medium).padding(12)Grid() {// 3) ForEach 显式标注回调入参类型,避免 any/unknown 推断ForEach(this.items,(item: CardItem) => {GridItem() {Column({ space: 6 }) {Text(item.title).fontSize(16).fontWeight(FontWeight.Medium)Text(item.desc).fontSize(12).opacity(0.6)}.padding(12).backgroundColor('#FFFF00').borderRadius(12)}},(item: CardItem) => item.title // key,确保唯一即可)}.columnsTemplate('1fr 1fr') // 两列均分(fr 轨道语法受支持).columnsGap(12).rowsGap(12).padding(12)}.width('100%').height('100%')}
}
网格和滚动的关系,很多人会搞混。
长列表场景更适合用 List,尤其是需要懒加载、按跨轴分栏(lanes)的时候; List 的 lanes 和 alignListItem,用来做“多列列表”十分合适。你也可以配合 ScrollBar 来提升滚动可见性。
四、Scroll 与 Scroller:信息流要跑顺,事件要挂对
当内容不是规则网格、而是流式信息时,用 Scroll 承载滚动区域,再用 Scroller 做编程式控制,比如回到顶部、定位到特定偏移。
// Index.ets
function makeRange(count: number, start: number = 1): number[] {const arr: number[] = []for (let i: number = 0; i < count; i++) {arr.push(start + i)}return arr
}@Entry
@Component
export struct Index {@State data: number[] = makeRange(20, 1) // 初始 1..20private feedScroller: Scroller = new Scroller()@State private loading: boolean = falseprivate loadMore(): void {if (this.loading) {return}this.loading = true// ArkTS 支持的计时调用(示例用途)setTimeout((): void => {const base: number = this.data.lengthconst more: number[] = makeRange(10, base + 1) // 追加 10 条this.data = this.data.concat(more)this.loading = false}, 500)}build() {Column() {// 顶部操作区Row({ space: 8 }) {Button('回到顶部').onClick((): void => {this.feedScroller.scrollTo({ xOffset: 0, yOffset: 0 })})Button('刷新').onClick((): void => {this.data = makeRange(20, 1)this.feedScroller.scrollTo({ xOffset: 0, yOffset: 0 })})}.padding({ left: 12, right: 12, top: 12, bottom: 6 })// 滚动主体(事件挂在实际滚动容器 Scroll 上)Scroll(this.feedScroller) {Column({ space: 10 }) {ForEach(this.data,(n: number): void => {Column({ space: 6 }) {Text(`条目 #${n}`).fontSize(16).fontWeight(FontWeight.Medium)Text('示例内容').opacity(0.65)}.padding(12).backgroundColor('#FFFF00').borderRadius(10)},(n: number): string => `item-${n}` // key:显式返回 string)if (this.loading) {Row() {LoadingProgress().width(20).height(20)Text('正在加载更多…').margin({ left: 8 })}.justifyContent(FlexAlign.Center).padding(12)}}.padding({ left: 12, right: 12, bottom: 12 })}.onScroll((): void => {}).onScrollEdge((edge: Edge): void => {if (edge === Edge.Bottom) {this.loadMore()}})}.width('100%').height('100%')}
}
如果有父子两层都能滚的场景,不要让它们互相抢手。英文 API 建议的做法是:父容器用 onScrollFrameBegin 拦一下,根据实际情况用 scrollBy 分配滚动量;子容器把 edgeEffect 关成 None,避免边缘回弹把事件“吃掉”。文档里的例子写得很直白,可以直接套用。
五、工具面板三连:不一定非得上 Grid,Row 也能排得很干净
三块功能卡横着排,Row 加上 layoutWeight 就能均分横向空间;若要在大屏或折叠屏上更灵活地换列,再切换到 Grid 三列也来得及。
// Index.ets
@Entry
@Component
export struct Index {@Builder card(title: string, desc: string) {Column({ space: 6 }) {Text(title).fontSize(16).fontWeight(FontWeight.Medium)Text(desc).fontSize(12).opacity(0.65)Button('进入').margin({ top: 8 })}.padding(12).backgroundColor('#FFFF00').borderRadius(12)}build() {Column() {Text('工具面板三连').fontSize(18).fontWeight(FontWeight.Medium).padding(12)Row({ space: 12 }) {// 关键改动:给每块加一个真实组件容器(Column),再挂 layoutWeightColumn() { this.card('收藏', '管理常用') }.layoutWeight(1)Column() { this.card('下载', '离线可用') }.layoutWeight(1)Column() { this.card('分享', '一键分发') }.layoutWeight(1)}.padding(12)}.width('100%').height('100%')}
}
七、常见坑与排查顺序
第一类是“对齐好像不生效”。先确认 Row/Column 的方向判断是否正确;再看对齐写在了哪一层;最后排查子项是否用 align/alignSelf 覆盖了父级。
第二类是“触底没触发”。请确认事件挂在了真正滚动的容器上(List、Grid、Scroll 等),不要挂在外层 Column 或 Stack。
第三类是“嵌套滚动打架”。父层用 onScrollFrameBegin,配合 scrollBy 分配滚动量;子层把 edgeEffect 设为 None,这个组合是官方推荐方案。
第四类是“均分不均”。layoutWeight 是“按比例分剩余空间”,不是“写了就平均”。要参与分配的子项最好都明确给 weight,避免出现某个子项没吃到空间的错觉。
总结
容器这套工具更像“摆凳子”的手艺:先把方向摆对,再把对齐放稳,尺寸与约束收好边,叠层交给 Stack,规则卡片交给 Grid,信息流交给 Scroll/Scroller。写完页面,如果你能一句话回答“谁在滚、事件挂哪一层、对齐是按哪个轴”,这个页面基本就稳了。
剩下的动画、状态同步、导航拆分,都是在这个稳定的骨架上顺势加法。