公司动态

HarmonyOS7 RelativeContainer 相对布局与锚点定位实战

📅 2026/7/14 19:14:46
HarmonyOS7 RelativeContainer 相对布局与锚点定位实战
文章目录前言代码讲解页面入口怎么安排数据模型先稳住状态变量控制页面反馈布局代码不要从样式开始读Builder 和方法承担复用使用方式完整代码关键代码解析interface 不是摆设State 是交互的开关列表和卡片要靠数据驱动事件回调要短一点样式参数别急着抽常量总结前言相对布局适合处理“跟谁对齐”的问题。页面一复杂锚点规则比手动偏移更可靠。我建议把这篇当成一个可以直接改造的 ArkUI 页面模板来看。先跑起来确认页面展示和交互都正常再把里面的模拟数据替换成接口数据最后再调整颜色、间距和业务字段。这样改起来比较稳不容易一边改样式一边把状态逻辑弄乱。这篇文章会按「页面意图、数据结构、状态流转、布局组织、交互细节」这条线来读代码。你不用从第一行样式硬看到最后先抓住主线后面的细节会轻松很多。代码讲解页面入口怎么安排代码里的入口组件是RelativeContainerPositioningPage。如果你把它当成单独页面预览保留Entry就可以如果要塞进现有工程的路由体系通常会去掉Entry再由外层页面或路由模块统一管理。这个习惯很重要。一个项目里入口页面太多预览时容易混后期拆组件也会麻烦。我的做法是案例阶段保留入口接业务时只保留真正需要被路由访问的页面。数据模型先稳住本案例涉及的数据模型主要有RuleCard。这些interface的作用不是为了“看起来规范”而是给 UI 层划边界。比如列表项需要标题、描述、颜色、选中态就应该在模型里说清楚。后续从接口拿数据时可以在请求层做字段映射别让后端字段名直接污染页面代码。如果你准备接真实业务建议把模拟数组替换成接口返回后的 ViewModel。页面只关心自己要渲染什么不关心接口原始字段长什么样。状态变量控制页面反馈本案例里的关键状态包括showDemo。State的职责是让 UI 对用户操作有反馈。比如选中某个卡片、切换分类、展开内容、修改输入值、控制加载中状态这些都应该由状态驱动。状态一变相关 UI 自动刷新这也是 ArkUI 声明式开发最核心的体验。这里有个容易踩的坑不要把所有数据都塞进State。静态配置用普通数组就够了只有用户会改、接口会更新、页面需要重新渲染的数据才适合放进状态里。布局代码不要从样式开始读这个页面里比较关键的组件有Row横向排列、左右分栏、按钮组或卡片行都依赖它来控制水平方向的节奏。Column纵向组织标题、内容、操作区适合搭页面主骨架。RelativeContainer用锚点关系描述组件之间的位置比手写大量 margin 更稳定。Divider负责信息分组不只是画一条线而是帮用户理解层级。Scroll用于长内容容器锚点跳转和返回顶部都离不开滚动控制。Button按钮案例要关注 loading、disabled、主题色和点击反馈。读布局时我更建议先找大容器再看内部如何拆块。比如一个页面通常会先用Column纵向放标题区、内容区、操作区内容区里再用Row、Grid、List或其他容器细分。这样看代码层级会比逐行看.padding()、.fontSize()清楚得多。如果页面显示错位优先检查容器关系而不是马上改像素。很多问题不是某个.margin()不对而是外层容器的宽高、滚动方向、权重分配或对齐方式没想清楚。Builder 和方法承担复用这篇没有额外拆出 Builder页面结构主要集中在build()中。辅助方法不多逻辑主要写在事件回调和渲染表达式里。Builder更适合复用 UI 片段比如卡片、列表行、头部区域、按钮组。普通方法更适合放计算逻辑比如筛选列表、统计数量、切换状态、生成颜色。两者分清楚后页面会干净很多。我不建议把所有东西都塞进build()。刚开始代码少还行需求一多build()会变成几百行后面改一个按钮都要找半天。使用方式把下面的完整代码放到 ArkTS 页面文件里即可运行。文章中的组件名已经是语义化命名不包含编号式案例名称接路由时直接使用RelativeContainerPositioningPage就行。如果你要改成业务页面优先改三处数据模型、状态变量、事件方法。样式可以后调先保证数据流和交互是通的。完整代码// RelativeContainerPositioningPage: 相对布局定位实战 - RelativeContainer 相对布局 // 知识点: RelativeContainer、alignRules、锚点定位、相对排列 interface RuleCard { ruleId: number ruleName: string ruleDesc: string ruleEmoji: string bgColor: string } Entry Component struct RelativeContainerPositioningPage { State showDemo: number 1 private ruleCards: RuleCard[] [ { ruleId: 1, ruleName: alignToStart, ruleDesc: 对齐父容器左边, ruleEmoji: ⬅️, bgColor: #F0F4FF }, { ruleId: 2, ruleName: alignToEnd, ruleDesc: 对齐父容器右边, ruleEmoji: ➡️, bgColor: #FFF4F0 }, { ruleId: 3, ruleName: alignToTop, ruleDesc: 对齐父容器顶部, ruleEmoji: ⬆️, bgColor: #F0FFF4 }, { ruleId: 4, ruleName: alignToBottom, ruleDesc: 对齐父容器底部, ruleEmoji: ⬇️, bgColor: #FFFBF0 }, { ruleId: 5, ruleName: middle, ruleDesc: 水平/垂直居中, ruleEmoji: , bgColor: #FDF0FF }, { ruleId: 6, ruleName: 锚点定位, ruleDesc: 相对其他组件定位, ruleEmoji: ⚓, bgColor: #F0FFFE }, ] build() { Column({ space: 0 }) { // 标题栏 Row({ space: 0 }) { Text(RelativeContainer) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .layoutWeight(1) Text(相对布局) .fontSize(13) .fontColor(#007DFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#EDF4FF) .borderRadius(10) } .width(100%) .padding({ left: 16, right: 16, top: 16, bottom: 12 }) .backgroundColor(#FFFFFF) // 示例切换Tab Row({ space: 0 }) { ForEach([1, 2, 3], (num: number) { Text(示例${num}) .fontSize(14) .fontColor(this.showDemo num ? #007DFF : #888888) .fontWeight(this.showDemo num ? FontWeight.Bold : FontWeight.Normal) .layoutWeight(1) .textAlign(TextAlign.Center) .height(40) .border({ width: { bottom: this.showDemo num ? 2 : 0 }, color: #007DFF }) .onClick(() { this.showDemo num }) }, (num: number) num.toString()) } .width(100%) .backgroundColor(#FFFFFF) .border({ width: { bottom: 1 }, color: #F0F0F0 }) Scroll() { Column({ space: 12 }) { // 示例1基础锚点定位 if (this.showDemo 1) { Column({ space: 12 }) { Text(示例1九方向锚点定位) .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .width(100%) Text(RelativeContainer 中的元素通过 alignRules 设置相对父容器或其他组件的对齐方式) .fontSize(13) .fontColor(#888888) .lineHeight(20) .width(100%) // 相对布局演示区 RelativeContainer() { // 左上 Text(左上) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#3B82F6) .borderRadius(6) .id(tlabel) .alignRules({ top: { anchor: __container__, align: VerticalAlign.Top }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 8, left: 8 }) // 顶部居中 Text(顶部中) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#10B981) .borderRadius(6) .id(tclabel) .alignRules({ top: { anchor: __container__, align: VerticalAlign.Top }, middle: { anchor: __container__, align: HorizontalAlign.Center } }) .margin({ top: 8 }) // 右上 Text(右上) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#F59E0B) .borderRadius(6) .id(trlabel) .alignRules({ top: { anchor: __container__, align: VerticalAlign.Top }, right: { anchor: __container__, align: HorizontalAlign.End } }) .margin({ top: 8, right: 8 }) // 左中 Text(左中) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#8B5CF6) .borderRadius(6) .id(mllabel) .alignRules({ center: { anchor: __container__, align: VerticalAlign.Center }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ left: 8 }) // 正中心 Text(正中心) .fontSize(13) .fontColor(#FFFFFF) .fontWeight(FontWeight.Bold) .padding({ left: 10, right: 10, top: 6, bottom: 6 }) .backgroundColor(#EF4444) .borderRadius(8) .id(clabel) .alignRules({ center: { anchor: __container__, align: VerticalAlign.Center }, middle: { anchor: __container__, align: HorizontalAlign.Center } }) // 右中 Text(右中) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#EC4899) .borderRadius(6) .id(mrlabel) .alignRules({ center: { anchor: __container__, align: VerticalAlign.Center }, right: { anchor: __container__, align: HorizontalAlign.End } }) .margin({ right: 8 }) // 左下 Text(左下) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#0EA5E9) .borderRadius(6) .id(bllabel) .alignRules({ bottom: { anchor: __container__, align: VerticalAlign.Bottom }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ bottom: 8, left: 8 }) // 底部居中 Text(底部中) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#6366F1) .borderRadius(6) .id(bclabel) .alignRules({ bottom: { anchor: __container__, align: VerticalAlign.Bottom }, middle: { anchor: __container__, align: HorizontalAlign.Center } }) .margin({ bottom: 8 }) // 右下 Text(右下) .fontSize(12) .fontColor(#FFFFFF) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .backgroundColor(#14B8A6) .borderRadius(6) .id(brlabel) .alignRules({ bottom: { anchor: __container__, align: VerticalAlign.Bottom }, right: { anchor: __container__, align: HorizontalAlign.End } }) .margin({ bottom: 8, right: 8 }) } .width(100%) .height(200) .backgroundColor(#F8F9FA) .borderRadius(10) .border({ width: 1, color: #E0E0E0, style: BorderStyle.Dashed }) } .width(100%) .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) } // 示例2组件间相对定位 if (this.showDemo 2) { Column({ space: 12 }) { Text(示例2组件间相对定位锚点) .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .width(100%) Text(通过 anchor 指定其他组件的 id实现组件相对于另一组件的精确定位) .fontSize(13) .fontColor(#888888) .lineHeight(20) .width(100%) RelativeContainer() { // 锚点组件A标题 Text( 主标题) .fontSize(16) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .id(mainTitle) .alignRules({ top: { anchor: __container__, align: VerticalAlign.Top }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 16, left: 16 }) // 相对于标题右侧的标签 Text(HOT) .fontSize(10) .fontColor(#FFFFFF) .fontWeight(FontWeight.Bold) .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .backgroundColor(#EF4444) .borderRadius(4) .id(hotTag) .alignRules({ top: { anchor: mainTitle, align: VerticalAlign.Top }, left: { anchor: mainTitle, align: HorizontalAlign.End } }) .margin({ left: 6, top: 2 }) // 相对于标题下方的副标题 Text(这段文字出现在主标题正下方通过锚点相对定位实现) .fontSize(13) .fontColor(#666666) .id(subTitle) .alignRules({ top: { anchor: mainTitle, align: VerticalAlign.Bottom }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 8, left: 16, right: 16 }) // 相对于副标题下方的分割线 Divider() .strokeWidth(1) .color(#F0F0F0) .id(divLine) .alignRules({ top: { anchor: subTitle, align: VerticalAlign.Bottom }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 12 }) .width(100%) // 底部按钮行 Button(查看详情) .fontSize(13) .height(36) .padding({ left: 16, right: 16 }) .backgroundColor(#007DFF) .borderRadius(18) .id(btnDetail) .alignRules({ top: { anchor: divLine, align: VerticalAlign.Bottom }, right: { anchor: __container__, align: HorizontalAlign.End } }) .margin({ top: 12, right: 16, bottom: 16 }) Text(更多信息 ) .fontSize(13) .fontColor(#007DFF) .id(moreLink) .alignRules({ center: { anchor: btnDetail, align: VerticalAlign.Center }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ left: 16 }) } .width(100%) .backgroundColor(#F8F9FA) .borderRadius(10) .border({ width: 1, color: #E0E0E0, style: BorderStyle.Dashed }) } .width(100%) .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) } // 示例3复杂卡片布局 if (this.showDemo 3) { Column({ space: 12 }) { Text(示例3商品详情卡片相对布局实战) .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .width(100%) // 商品卡片 RelativeContainer() { // 商品图片区 Column() .width(100) .height(100) .backgroundColor(#F0F4FF) .borderRadius(10) .id(productImg) .alignRules({ top: { anchor: __container__, align: VerticalAlign.Top }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 16, left: 16 }) Text() .fontSize(48) .id(productEmoji) .alignRules({ center: { anchor: productImg, align: VerticalAlign.Center }, middle: { anchor: productImg, align: HorizontalAlign.Center } }) // 折扣角标 Text(限时) .fontSize(10) .fontColor(#FFFFFF) .fontWeight(FontWeight.Bold) .padding({ left: 5, right: 5, top: 2, bottom: 2 }) .backgroundColor(#EF4444) .borderRadius({ topLeft: 10, topRight: 0, bottomLeft: 0, bottomRight: 6 }) .id(discountTag) .alignRules({ top: { anchor: productImg, align: VerticalAlign.Top }, left: { anchor: productImg, align: HorizontalAlign.Start } }) // 商品标题 Text(华为 Mate 70 旗舰手机) .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .id(productName) .alignRules({ top: { anchor: productImg, align: VerticalAlign.Top }, left: { anchor: productImg, align: HorizontalAlign.End } }) .margin({ top: 0, left: 12 }) // 评分 Text(⭐ 4.9 (12万条评价)) .fontSize(12) .fontColor(#F59E0B) .id(ratingLine) .alignRules({ top: { anchor: productName, align: VerticalAlign.Bottom }, left: { anchor: productImg, align: HorizontalAlign.End } }) .margin({ top: 6, left: 12 }) // 价格 Text(¥5,999) .fontSize(20) .fontColor(#EF4444) .fontWeight(FontWeight.Bold) .id(priceLine) .alignRules({ top: { anchor: ratingLine, align: VerticalAlign.Bottom }, left: { anchor: productImg, align: HorizontalAlign.End } }) .margin({ top: 8, left: 12 }) // 原价 Text(¥7,499) .fontSize(13) .fontColor(#BBBBBB) .decoration({ type: TextDecorationType.LineThrough }) .id(originalPrice) .alignRules({ center: { anchor: priceLine, align: VerticalAlign.Center }, left: { anchor: priceLine, align: HorizontalAlign.End } }) .margin({ left: 8 }) // 加购按钮 Button(加入购物车) .fontSize(13) .height(34) .padding({ left: 14, right: 14 }) .backgroundColor(#007DFF) .borderRadius(17) .id(addCartBtn) .alignRules({ bottom: { anchor: productImg, align: VerticalAlign.Bottom }, right: { anchor: __container__, align: HorizontalAlign.End } }) .margin({ right: 16 }) // 底部分割线 Divider() .strokeWidth(1) .color(#F0F0F0) .id(bottomDiv) .alignRules({ top: { anchor: productImg, align: VerticalAlign.Bottom }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 16 }) .width(100%) // 底部标签 Row({ space: 8 }) { ForEach([7天退换, 正品保障, 闪电发货], (tag: string) { Text(tag) .fontSize(11) .fontColor(#10B981) .padding({ left: 6, right: 6, top: 3, bottom: 3 }) .backgroundColor(#F0FFF4) .borderRadius(4) }, (tag: string) tag) } .id(tagRow) .alignRules({ top: { anchor: bottomDiv, align: VerticalAlign.Bottom }, left: { anchor: __container__, align: HorizontalAlign.Start } }) .margin({ top: 12, left: 16, bottom: 16 }) } .width(100%) .backgroundColor(#F8F9FA) .borderRadius(10) .border({ width: 1, color: #E0E0E0, style: BorderStyle.Dashed }) } .width(100%) .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) } // 知识点卡片 Column({ space: 8 }) { Text( RelativeContainer 要点) .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .width(100%) ForEach(this.ruleCards, (card: RuleCard) { Row({ space: 10 }) { Text(card.ruleEmoji) .fontSize(20) .width(36) .height(36) .textAlign(TextAlign.Center) .backgroundColor(card.bgColor) .borderRadius(8) .flexShrink(0) Column({ space: 2 }) { Text(card.ruleName) .fontSize(13) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) Text(card.ruleDesc) .fontSize(12) .fontColor(#888888) } .layoutWeight(1) .alignItems(HorizontalAlign.Start) } .width(100%) .alignItems(VerticalAlign.Center) }, (card: RuleCard) card.ruleId.toString()) } .width(100%) .padding(16) .backgroundColor(#FFFFFF) .borderRadius(12) } .padding({ left: 12, right: 12, top: 12, bottom: 24 }) } .scrollBar(BarState.Off) .layoutWeight(1) } .width(100%) .height(100%) .backgroundColor(#F5F6FA) } }关键代码解析interface不是摆设很多新手写 ArkUI 页面时会直接在ForEach里使用一堆临时对象。页面小的时候没问题但字段一多就很难知道每个字段到底用于哪里。本案例把数据结构提前声明出来读代码时能很快判断每个 UI 区块依赖哪些字段。后续如果接接口也建议保留这层结构。接口可以变页面模型尽量稳定。这样页面不会因为后端多返回一个字段、少返回一个字段就跟着大改。State是交互的开关本案例的交互反馈都围绕状态展开。点击、切换、输入、刷新这类行为本质上都是修改某个状态值然后让 UI 重新计算显示结果。写这类代码时有一个简单判断如果某个值变化后页面应该立刻变那它大概率应该是状态如果它只是配置项、静态文案、固定颜色就不要放进状态。列表和卡片要靠数据驱动只要页面里出现重复结构就应该优先想到数据数组加ForEach。这样新增一项、删除一项、调整顺序都只动数据不用复制粘贴一段 UI。这个案例的完整代码里已经把主要内容抽成数组或方法。你可以试着多加一条数据看页面是否能自然渲染出来。如果能说明结构是健康的如果加一条就要改很多 UI说明组件拆分还不够。事件回调要短一点.onClick()、onChange()这类回调里可以直接改状态但不建议堆太多逻辑。简单切换可以写在回调里复杂逻辑最好拆成方法。这样做有两个好处一是读页面结构时不会被业务逻辑打断二是后面要加埋点、请求接口、异常处理时有明确的位置可以改。样式参数别急着抽常量这个案例里有不少颜色、间距、圆角、字号。学习阶段直接写在组件链上反而更容易看出效果。等你确定页面风格稳定后再把主题色、通用间距、卡片圆角提到统一常量里。过早抽象会让示例代码变绕。先让页面清楚再谈工程化这是我比较推荐的顺序。总结这个案例真正值得学的不只是某个 ArkUI 组件怎么写而是一个页面如何从数据走到状态再走到布局和交互。你可以按这个顺序改造先替换数据模型再确认状态更新是否正确接着调整布局容器最后打磨样式细节。只要这条主线不乱页面规模变大也不会失控。如果后续继续扩展我会优先把可复用的卡片、列表行、筛选栏或控制区拆成独立组件。这样主页面只保留数据和流程代码会更接近真实项目里的写法。