文章目录
- 前言
- 代码讲解
- 页面入口怎么安排
- 数据模型先稳住
- 状态变量控制页面反馈
- 布局代码不要从样式开始读
- Builder 和方法承担复用
- 使用方式
- 完整代码
- 关键代码解析
- `interface` 不是摆设
- `@State` 是交互的开关
- 列表和卡片要靠数据驱动
- 事件回调要短一点
- 样式参数别急着抽常量
- 总结
前言
分割线不是装饰品。用得好,页面层级会清楚很多;用得乱,反而显得碎。
我建议把这篇当成一个可以直接改造的 ArkUI 页面模板来看。先跑起来,确认页面展示和交互都正常;再把里面的模拟数据替换成接口数据;最后再调整颜色、间距和业务字段。这样改起来比较稳,不容易一边改样式一边把状态逻辑弄乱。
这篇文章会按「页面意图、数据结构、状态流转、布局组织、交互细节」这条线来读代码。你不用从第一行样式硬看到最后,先抓住主线,后面的细节会轻松很多。
代码讲解
页面入口怎么安排
代码里的入口组件是DividerUsageScenariosPage。如果你把它当成单独页面预览,保留@Entry就可以;如果要塞进现有工程的路由体系,通常会去掉@Entry,再由外层页面或路由模块统一管理。
这个习惯很重要。一个项目里入口页面太多,预览时容易混,后期拆组件也会麻烦。我的做法是:案例阶段保留入口,接业务时只保留真正需要被路由访问的页面。
数据模型先稳住
本案例涉及的数据模型主要有:DividerDemo、ContactItem、MenuItem2。
这些interface的作用不是为了“看起来规范”,而是给 UI 层划边界。比如列表项需要标题、描述、颜色、选中态,就应该在模型里说清楚。后续从接口拿数据时,可以在请求层做字段映射,别让后端字段名直接污染页面代码。
如果你准备接真实业务,建议把模拟数组替换成接口返回后的 ViewModel。页面只关心自己要渲染什么,不关心接口原始字段长什么样。
状态变量控制页面反馈
本案例里的关键状态包括:activeCategory。
@State的职责是让 UI 对用户操作有反馈。比如选中某个卡片、切换分类、展开内容、修改输入值、控制加载中状态,这些都应该由状态驱动。状态一变,相关 UI 自动刷新,这也是 ArkUI 声明式开发最核心的体验。
这里有个容易踩的坑:不要把所有数据都塞进@State。静态配置用普通数组就够了,只有用户会改、接口会更新、页面需要重新渲染的数据,才适合放进状态里。
布局代码不要从样式开始读
这个页面里比较关键的组件有:
Row:横向排列、左右分栏、按钮组或卡片行都依赖它来控制水平方向的节奏。Column:纵向组织标题、内容、操作区,适合搭页面主骨架。Divider:负责信息分组,不只是画一条线,而是帮用户理解层级。Scroll:用于长内容容器,锚点跳转和返回顶部都离不开滚动控制。
读布局时,我更建议先找大容器,再看内部如何拆块。比如一个页面通常会先用Column纵向放标题区、内容区、操作区;内容区里再用Row、Grid、List或其他容器细分。这样看代码,层级会比逐行看.padding()、.fontSize()清楚得多。
如果页面显示错位,优先检查容器关系,而不是马上改像素。很多问题不是某个.margin()不对,而是外层容器的宽高、滚动方向、权重分配或对齐方式没想清楚。
Builder 和方法承担复用
这篇没有额外拆出 Builder,页面结构主要集中在build()中。
辅助方法主要有:getGroupContacts。
@Builder更适合复用 UI 片段,比如卡片、列表行、头部区域、按钮组。普通方法更适合放计算逻辑,比如筛选列表、统计数量、切换状态、生成颜色。两者分清楚后,页面会干净很多。
我不建议把所有东西都塞进build()。刚开始代码少还行,需求一多,build()会变成几百行,后面改一个按钮都要找半天。
使用方式
把下面的完整代码放到 ArkTS 页面文件里即可运行。文章中的组件名已经是语义化命名,不包含编号式案例名称,接路由时直接使用DividerUsageScenariosPage就行。
如果你要改成业务页面,优先改三处:数据模型、状态变量、事件方法。样式可以后调,先保证数据流和交互是通的。
完整代码
// DividerUsageScenariosPage: 分割线组件应用 - Divider 各种场景 // 知识点: Divider 水平/垂直、strokeWidth、color、lineCap、margin 分组间隔 interface DividerDemo { demoId: number demoTitle: string demoDesc: string } interface ContactItem { contactId: number contactName: string contactPhone: string contactEmoji: string groupName: string } interface MenuItem2 { menuItemId: number menuLabel: string menuEmoji: string menuValue: string } @Entry @Component struct DividerUsageScenariosPage { @State activeCategory: string = 'A' private dividerDemos: DividerDemo[] = [ { demoId: 1, demoTitle: '基础水平分割线', demoDesc: 'strokeWidth + color 控制粗细和颜色' }, { demoId: 2, demoTitle: '带左边距分割线', demoDesc: 'margin/padding 实现缩进效果(列表常用)' }, { demoId: 3, demoTitle: '虚线样式', demoDesc: 'lineCap 控制端点形状' }, { demoId: 4, demoTitle: '垂直分割线', demoDesc: 'vertical(true) 在 Row 中使用' }, ] private contactList: ContactItem[] = [ { contactId: 1, contactName: '阿尔法', contactPhone: '138****1234', contactEmoji: '😊', groupName: 'A' }, { contactId: 2, contactName: '阿豪', contactPhone: '139****5678', contactEmoji: '😄', groupName: 'A' }, { contactId: 3, contactName: '爱丽丝', contactPhone: '136****2345', contactEmoji: '😍', groupName: 'A' }, { contactId: 4, contactName: '柏杰', contactPhone: '135****3456', contactEmoji: '🙂', groupName: 'B' }, { contactId: 5, contactName: '白雪', contactPhone: '137****4567', contactEmoji: '😎', groupName: 'B' }, { contactId: 6, contactName: '陈明', contactPhone: '132****5678', contactEmoji: '😏', groupName: 'C' }, { contactId: 7, contactName: '陈雪', contactPhone: '158****6789', contactEmoji: '😁', groupName: 'C' }, { contactId: 8, contactName: '陈子轩', contactPhone: '183****7890', contactEmoji: '🤩', groupName: 'C' }, ] private menuItems: MenuItem2[] = [ { menuItemId: 1, menuLabel: '账号与安全', menuEmoji: '🔒', menuValue: '' }, { menuItemId: 2, menuLabel: '隐私保护', menuEmoji: '🛡️', menuValue: '' }, { menuItemId: 3, menuLabel: '消息通知', menuEmoji: '🔔', menuValue: '已开启' }, { menuItemId: 4, menuLabel: '深色模式', menuEmoji: '🌙', menuValue: '跟随系统' }, { menuItemId: 5, menuLabel: '字体大小', menuEmoji: '🔤', menuValue: '标准' }, { menuItemId: 6, menuLabel: '语言', menuEmoji: '🌐', menuValue: '简体中文' }, { menuItemId: 7, menuLabel: '帮助与反馈', menuEmoji: '💬', menuValue: '' }, { menuItemId: 8, menuLabel: '关于应用', menuEmoji: 'ℹ️', menuValue: 'v2.1.0' }, ] getGroupContacts(group: string): ContactItem[] { return this.contactList.filter(c => c.groupName === group) } build() { Column({ space: 0 }) { // 标题栏 Row({ space: 0 }) { Text('Divider 分割线组件') .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') } .width('100%') .padding({ left: 16, right: 16, top: 16, bottom: 12 }) .backgroundColor('#FFFFFF') Scroll() { Column({ space: 12 }) { // 一、基础分割线样式展示 Column({ space: 0 }) { Text('一、分割线样式') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .width('100%') .padding({ left: 16, top: 16, bottom: 12 }) // 细线(默认) Text('默认细线(strokeWidth: 1)') .fontSize(13) .fontColor('#888888') .padding({ left: 16, bottom: 6 }) Divider().strokeWidth(1).color('#E0E0E0').margin({ left: 16, right: 16 }) Text('分隔内容A').fontSize(13).fontColor('#333333').padding({ left: 16, top: 8, bottom: 8 }) // 中等线 Text('中等粗线(strokeWidth: 2, color: #3B82F6)') .fontSize(13) .fontColor('#888888') .padding({ left: 16, bottom: 6, top: 4 }) Divider().strokeWidth(2).color('#3B82F6').margin({ left: 16, right: 16 }) Text('分隔内容B').fontSize(13).fontColor('#333333').padding({ left: 16, top: 8, bottom: 8 }) // 粗线分组 Text('粗线分组分隔(strokeWidth: 6, color: #F5F6FA)') .fontSize(13) .fontColor('#888888') .padding({ left: 16, bottom: 0, top: 4 }) Divider().strokeWidth(6).color('#F5F6FA') Text('下一组内容').fontSize(13).fontColor('#333333').padding({ left: 16, top: 8, bottom: 8 }) Divider().strokeWidth(1).color('#F0F0F0').margin({ bottom: 12 }) } .width('100%') .backgroundColor('#FFFFFF') .borderRadius(12) // 二、带缩进的列表分割线(常见于设置页) Column({ space: 0 }) { Text('二、设置列表(缩进分割线)') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .padding({ left: 16, top: 16, bottom: 4 }) ForEach(this.menuItems, (item: MenuItem2, idx: number) => { Column({ space: 0 }) { // 分组间隔(第4项前加粗分割线) if (idx === 3) { Divider().strokeWidth(6).color('#F5F6FA') } Row({ space: 0 }) { Text(item.menuEmoji) .fontSize(20) .width(36) .textAlign(TextAlign.Center) .flexShrink(0) Text(item.menuLabel) .fontSize(14) .fontColor('#1A1A1A') .layoutWeight(1) .padding({ left: 10 }) if (item.menuValue.length > 0) { Text(item.menuValue) .fontSize(13) .fontColor('#BBBBBB') .margin({ right: 4 }) } Text('>') .fontSize(16) .fontColor('#CCCCCC') } .width('100%') .height(52) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) // 缩进分割线(最后一项不显示,分组最后一项不显示) if (idx !== this.menuItems.length - 1 && idx !== 2) { Divider() .strokeWidth(0.5) .color('#F0F0F0') .margin({ left: 62 }) } } }, (item: MenuItem2) => item.menuItemId.toString()) Row({ space: 0 }).height(12) } .width('100%') .backgroundColor('#FFFFFF') .borderRadius(12) // 三、通讯录分组(带组名分割线) Column({ space: 0 }) { Text('三、通讯录分组(带标题的分割线)') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .padding({ left: 16, top: 16, bottom: 4 }) ForEach(['A', 'B', 'C'], (group: string) => { Column({ space: 0 }) { // 组名行(使用垂直分割线) Row({ space: 8 }) { Text(group) .fontSize(13) .fontWeight(FontWeight.Bold) .fontColor('#007DFF') .width(18) .textAlign(TextAlign.Center) Divider().vertical(false).strokeWidth(1).color('#E8EEFF').layoutWeight(1) } .width('100%') .padding({ left: 16, right: 16, top: 6, bottom: 6 }) .alignItems(VerticalAlign.Center) // 联系人 ForEach(this.getGroupContacts(group), (contact: ContactItem, idx: number) => { Column({ space: 0 }) { Row({ space: 12 }) { Text(contact.contactEmoji) .fontSize(24) .width(42) .height(42) .textAlign(TextAlign.Center) .backgroundColor('#F0F4FF') .borderRadius(21) .flexShrink(0) Column({ space: 2 }) { Text(contact.contactName) .fontSize(14) .fontColor('#1A1A1A') .fontWeight(FontWeight.Medium) Text(contact.contactPhone) .fontSize(12) .fontColor('#AAAAAA') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) Text('📞') .fontSize(18) } .width('100%') .height(60) .padding({ left: 16, right: 16 }) .alignItems(VerticalAlign.Center) if (idx < this.getGroupContacts(group).length - 1) { Divider() .strokeWidth(0.5) .color('#F0F0F0') .margin({ left: 70 }) } } }, (contact: ContactItem) => contact.contactId.toString()) } }, (group: string) => group) Row({ space: 0 }).height(12) } .width('100%') .backgroundColor('#FFFFFF') .borderRadius(12) // 四、垂直分割线 - 统计数据 Column({ space: 12 }) { Text('四、垂直分割线(Row 中的数据分隔)') .fontSize(15) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') .width('100%') Row({ space: 0 }) { Column({ space: 4 }) { Text('128') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') Text('关注') .fontSize(12) .fontColor('#888888') } .layoutWeight(1) .alignItems(HorizontalAlign.Center) Divider().vertical(true).strokeWidth(1).color('#E0E0E0').height(36) Column({ space: 4 }) { Text('3.6K') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') Text('粉丝') .fontSize(12) .fontColor('#888888') } .layoutWeight(1) .alignItems(HorizontalAlign.Center) Divider().vertical(true).strokeWidth(1).color('#E0E0E0').height(36) Column({ space: 4 }) { Text('48') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') Text('文章') .fontSize(12) .fontColor('#888888') } .layoutWeight(1) .alignItems(HorizontalAlign.Center) Divider().vertical(true).strokeWidth(1).color('#E0E0E0').height(36) Column({ space: 4 }) { Text('12.8K') .fontSize(22) .fontWeight(FontWeight.Bold) .fontColor('#1A1A1A') Text('获赞') .fontSize(12) .fontColor('#888888') } .layoutWeight(1) .alignItems(HorizontalAlign.Center) } .width('100%') .height(72) .alignItems(VerticalAlign.Center) .backgroundColor('#FAFBFF') .borderRadius(10) } .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 组件怎么写,而是一个页面如何从数据走到状态,再走到布局和交互。
你可以按这个顺序改造:先替换数据模型,再确认状态更新是否正确,接着调整布局容器,最后打磨样式细节。只要这条主线不乱,页面规模变大也不会失控。
如果后续继续扩展,我会优先把可复用的卡片、列表行、筛选栏或控制区拆成独立组件。这样主页面只保留数据和流程,代码会更接近真实项目里的写法。