
文章目录前言代码讲解页面入口怎么安排数据模型先稳住状态变量控制页面反馈布局代码不要从样式开始读Builder 和方法承担复用使用方式完整代码关键代码解析interface 不是摆设State 是交互的开关列表和卡片要靠数据驱动事件回调要短一点样式参数别急着抽常量总结前言长文章页的体验不在内容多少而在章节定位、滚动反馈和返回顶部是否顺手。我建议把这篇当成一个可以直接改造的 ArkUI 页面模板来看。先跑起来确认页面展示和交互都正常再把里面的模拟数据替换成接口数据最后再调整颜色、间距和业务字段。这样改起来比较稳不容易一边改样式一边把状态逻辑弄乱。这篇文章会按「页面意图、数据结构、状态流转、布局组织、交互细节」这条线来读代码。你不用从第一行样式硬看到最后先抓住主线后面的细节会轻松很多。代码讲解页面入口怎么安排代码里的入口组件是ScrollableArticleGuidePage。如果你把它当成单独页面预览保留Entry就可以如果要塞进现有工程的路由体系通常会去掉Entry再由外层页面或路由模块统一管理。这个习惯很重要。一个项目里入口页面太多预览时容易混后期拆组件也会麻烦。我的做法是案例阶段保留入口接业务时只保留真正需要被路由访问的页面。数据模型先稳住本案例涉及的数据模型主要有SectionInfo、ArticleBlock。这些interface的作用不是为了“看起来规范”而是给 UI 层划边界。比如列表项需要标题、描述、颜色、选中态就应该在模型里说清楚。后续从接口拿数据时可以在请求层做字段映射别让后端字段名直接污染页面代码。如果你准备接真实业务建议把模拟数组替换成接口返回后的 ViewModel。页面只关心自己要渲染什么不关心接口原始字段长什么样。状态变量控制页面反馈本案例里的关键状态包括scrollTop、showBackTop、activeSection。State的职责是让 UI 对用户操作有反馈。比如选中某个卡片、切换分类、展开内容、修改输入值、控制加载中状态这些都应该由状态驱动。状态一变相关 UI 自动刷新这也是 ArkUI 声明式开发最核心的体验。这里有个容易踩的坑不要把所有数据都塞进State。静态配置用普通数组就够了只有用户会改、接口会更新、页面需要重新渲染的数据才适合放进状态里。布局代码不要从样式开始读这个页面里比较关键的组件有Row横向排列、左右分栏、按钮组或卡片行都依赖它来控制水平方向的节奏。Column纵向组织标题、内容、操作区适合搭页面主骨架。Stack负责图层叠放常见于角标、遮罩、封面文字压图。Divider负责信息分组不只是画一条线而是帮用户理解层级。List适合长列表和分组列表重点是数据过滤、滚动和状态同步。Grid适合固定宫格、桌面图标、内容卡片等二维排布。读布局时我更建议先找大容器再看内部如何拆块。比如一个页面通常会先用Column纵向放标题区、内容区、操作区内容区里再用Row、Grid、List或其他容器细分。这样看代码层级会比逐行看.padding()、.fontSize()清楚得多。如果页面显示错位优先检查容器关系而不是马上改像素。很多问题不是某个.margin()不对而是外层容器的宽高、滚动方向、权重分配或对齐方式没想清楚。Builder 和方法承担复用这篇没有额外拆出 Builder页面结构主要集中在build()中。辅助方法不多逻辑主要写在事件回调和渲染表达式里。Builder更适合复用 UI 片段比如卡片、列表行、头部区域、按钮组。普通方法更适合放计算逻辑比如筛选列表、统计数量、切换状态、生成颜色。两者分清楚后页面会干净很多。我不建议把所有东西都塞进build()。刚开始代码少还行需求一多build()会变成几百行后面改一个按钮都要找半天。使用方式把下面的完整代码放到 ArkTS 页面文件里即可运行。文章中的组件名已经是语义化命名不包含编号式案例名称接路由时直接使用ScrollableArticleGuidePage就行。如果你要改成业务页面优先改三处数据模型、状态变量、事件方法。样式可以后调先保证数据流和交互是通的。完整代码// ScrollableArticleGuidePage: 滚动容器综合 - Scroll 嵌套与滚动控制 // 知识点: Scroll、scrollable方向、scrollBar、onScroll、scrollTo、嵌套滚动 interface SectionInfo { sectionId: number sectionTitle: string sectionEmoji: string anchorY: number } interface ArticleBlock { blockId: number blockType: string blockContent: string blockBg: string } Entry Component struct ScrollableArticleGuidePage { State scrollTop: number 0 State showBackTop: boolean false State activeSection: number 0 private scrollController: Scroller new Scroller() private sections: SectionInfo[] [ { sectionId: 0, sectionTitle: 简介, sectionEmoji: , anchorY: 0 }, { sectionId: 1, sectionTitle: 核心特性, sectionEmoji: ⚡, anchorY: 400 }, { sectionId: 2, sectionTitle: 使用方法, sectionEmoji: , anchorY: 800 }, { sectionId: 3, sectionTitle: 注意事项, sectionEmoji: ⚠️, anchorY: 1200 }, ] private articleBlocks: ArticleBlock[] [ { blockId: 1, blockType: heading, blockContent: Scroll 组件简介, blockBg: #FFFFFF }, { blockId: 2, blockType: paragraph, blockContent: Scroll 组件是 HarmonyOS ArkUI 中提供滚动能力的基础容器。当子组件的布局尺寸超过 Scroll 父组件的视图尺寸时内容可以滚动显示。, blockBg: #FFFFFF }, { blockId: 3, blockType: paragraph, blockContent: 默认情况下Scroll 支持垂直方向滚动。可以通过 scrollable 属性修改为水平方向Horizontal或双向滚动Free。, blockBg: #FFFFFF }, { blockId: 4, blockType: code, blockContent: Scroll(controller) {\n Column() { ... }\n}\n.scrollable(ScrollDirection.Vertical)\n.scrollBar(BarState.Auto), blockBg: #1E1E2E }, { blockId: 5, blockType: divider, blockContent: , blockBg: #FFFFFF }, { blockId: 6, blockType: heading, blockContent: ⚡ 核心特性, blockBg: #FFFFFF }, { blockId: 7, blockType: tip, blockContent: ✅ 支持设置滚动方向Vertical / Horizontal / Free, blockBg: #F0FFF4 }, { blockId: 8, blockType: tip, blockContent: ✅ 滚动条样式Auto自动显示/ On / Off, blockBg: #F0FFF4 }, { blockId: 9, blockType: tip, blockContent: ✅ 边缘效果Spring弹簧/ Fade渐隐/ None, blockBg: #F0FFF4 }, { blockId: 10, blockType: tip, blockContent: ✅ onScroll 回调实时获取滚动偏移量, blockBg: #F0FFF4 }, { blockId: 11, blockType: tip, blockContent: ✅ 编程式控制scrollTo、scrollEdge、scrollPage, blockBg: #F0FFF4 }, { blockId: 12, blockType: divider, blockContent: , blockBg: #FFFFFF }, { blockId: 13, blockType: heading, blockContent: 使用方法, blockBg: #FFFFFF }, { blockId: 14, blockType: paragraph, blockContent: 通过 Scroller 控制器对象可以主动控制 Scroll 组件的滚动行为例如滚动到指定位置、滚动到顶部/底部等。, blockBg: #FFFFFF }, { blockId: 15, blockType: code, blockContent: private ctrl: Scroller new Scroller()\n\n// 滚动到顶部\nthis.ctrl.scrollEdge(Edge.Top)\n\n// 滚动到指定坐标\nthis.ctrl.scrollTo({ xOffset: 0, yOffset: 500 }), blockBg: #1E1E2E }, { blockId: 16, blockType: paragraph, blockContent: onScroll 回调可获取当前滚动偏移量适合实现标题栏背景渐变、返回顶部按钮显示等交互效果。, blockBg: #FFFFFF }, { blockId: 17, blockType: divider, blockContent: , blockBg: #FFFFFF }, { blockId: 18, blockType: heading, blockContent: ⚠️ 注意事项, blockBg: #FFFFFF }, { blockId: 19, blockType: warning, blockContent: ⚠️ Scroll 内部只能有一个直接子组件通常配合 Column / Row 使用, blockBg: #FFFBF0 }, { blockId: 20, blockType: warning, blockContent: ⚠️ 嵌套滚动时需配置 nestedScroll 属性避免滚动冲突, blockBg: #FFFBF0 }, { blockId: 21, blockType: warning, blockContent: ⚠️ Scroll 内子组件不要设置 height(100%) 否则无法滚动, blockBg: #FFFBF0 }, { blockId: 22, blockType: warning, blockContent: ⚠️ 列表类组件List/Grid有内置滚动能力不建议再套 Scroll, blockBg: #FFFBF0 }, ] build() { Stack({ alignContent: Alignment.BottomEnd }) { Column({ space: 0 }) { // 顶部标题栏随滚动改变透明度 Row({ space: 0 }) { Text(← ) .fontSize(18) .fontColor(#1A1A1A) Text(Scroll 滚动容器) .fontSize(17) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .layoutWeight(1) Text(⋯) .fontSize(20) .fontColor(#1A1A1A) } .width(100%) .height(52) .padding({ left: 16, right: 16 }) .backgroundColor(rgba(255,255,255,${Math.min(1, this.scrollTop / 100)})) .border({ width: { bottom: this.scrollTop 20 ? 1 : 0 }, color: #F0F0F0 }) // 章节锚点横向导航 Scroll() { Row({ space: 8 }) { ForEach(this.sections, (sec: SectionInfo) { Row({ space: 4 }) { Text(sec.sectionEmoji) .fontSize(13) Text(sec.sectionTitle) .fontSize(13) .fontColor(this.activeSection sec.sectionId ? #FFFFFF : #555555) } .padding({ left: 12, right: 12, top: 6, bottom: 6 }) .backgroundColor(this.activeSection sec.sectionId ? #007DFF : #F0F0F0) .borderRadius(16) .onClick(() { this.activeSection sec.sectionId this.scrollController.scrollTo({ xOffset: 0, yOffset: sec.anchorY, animation: { duration: 400, curve: Curve.EaseOut } }) }) }, (sec: SectionInfo) sec.sectionId.toString()) } .padding({ left: 16, right: 16 }) } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .width(100%) .backgroundColor(#FFFFFF) .padding({ top: 8, bottom: 8 }) .border({ width: { bottom: 1 }, color: #F0F0F0 }) // 主内容区 Scroll Scroll(this.scrollController) { Column({ space: 0 }) { ForEach(this.articleBlocks, (block: ArticleBlock) { if (block.blockType heading) { Text(block.blockContent) .fontSize(18) .fontWeight(FontWeight.Bold) .fontColor(#1A1A1A) .padding({ left: 16, right: 16, top: 20, bottom: 10 }) .width(100%) } else if (block.blockType paragraph) { Text(block.blockContent) .fontSize(14) .fontColor(#444444) .lineHeight(22) .padding({ left: 16, right: 16, top: 4, bottom: 8 }) .width(100%) } else if (block.blockType code) { Text(block.blockContent) .fontSize(13) .fontColor(#A8FF78) .fontFamily(monospace) .padding({ left: 16, right: 16, top: 14, bottom: 14 }) .width(100%) .backgroundColor(#1E1E2E) .margin({ left: 12, right: 12, bottom: 8 }) .borderRadius(8) .lineHeight(22) } else if (block.blockType tip) { Text(block.blockContent) .fontSize(13) .fontColor(#1A7A4A) .padding({ left: 14, right: 14, top: 10, bottom: 10 }) .width(100%) .backgroundColor(#F0FFF4) .margin({ left: 12, right: 12, bottom: 4 }) .borderRadius(8) .border({ width: { left: 3 }, color: #10B981 }) } else if (block.blockType warning) { Text(block.blockContent) .fontSize(13) .fontColor(#7A4A00) .padding({ left: 14, right: 14, top: 10, bottom: 10 }) .width(100%) .backgroundColor(#FFFBF0) .margin({ left: 12, right: 12, bottom: 4 }) .borderRadius(8) .border({ width: { left: 3 }, color: #F59E0B }) } else if (block.blockType divider) { Divider() .strokeWidth(6) .color(#F5F6FA) .margin({ top: 12, bottom: 4 }) } }, (block: ArticleBlock) block.blockId.toString()) Row({ space: 0 }).height(32) } .width(100%) } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Auto) .edgeEffect(EdgeEffect.Spring) .onScroll((xOffset: number, yOffset: number) { this.scrollTop this.scrollController.currentOffset().yOffset this.showBackTop this.scrollTop 300 // 更新活跃章节 if (this.scrollTop 400) { this.activeSection 0 } else if (this.scrollTop 800) { this.activeSection 1 } else if (this.scrollTop 1200) { this.activeSection 2 } else { this.activeSection 3 } }) .layoutWeight(1) .backgroundColor(#FFFFFF) } .width(100%) .height(100%) // 返回顶部按钮浮动 if (this.showBackTop) { Column({ space: 2 }) { Text(⬆️) .fontSize(18) Text(顶部) .fontSize(10) .fontColor(#007DFF) } .width(50) .height(50) .backgroundColor(#FFFFFF) .borderRadius(25) .shadow({ radius: 8, color: #20000000, offsetY: 2 }) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .margin({ right: 16, bottom: 24 }) .onClick(() { this.scrollController.scrollEdge(Edge.Top) }) } } .width(100%) .height(100%) .backgroundColor(#FFFFFF) } }关键代码解析interface不是摆设很多新手写 ArkUI 页面时会直接在ForEach里使用一堆临时对象。页面小的时候没问题但字段一多就很难知道每个字段到底用于哪里。本案例把数据结构提前声明出来读代码时能很快判断每个 UI 区块依赖哪些字段。后续如果接接口也建议保留这层结构。接口可以变页面模型尽量稳定。这样页面不会因为后端多返回一个字段、少返回一个字段就跟着大改。State是交互的开关本案例的交互反馈都围绕状态展开。点击、切换、输入、刷新这类行为本质上都是修改某个状态值然后让 UI 重新计算显示结果。写这类代码时有一个简单判断如果某个值变化后页面应该立刻变那它大概率应该是状态如果它只是配置项、静态文案、固定颜色就不要放进状态。列表和卡片要靠数据驱动只要页面里出现重复结构就应该优先想到数据数组加ForEach。这样新增一项、删除一项、调整顺序都只动数据不用复制粘贴一段 UI。这个案例的完整代码里已经把主要内容抽成数组或方法。你可以试着多加一条数据看页面是否能自然渲染出来。如果能说明结构是健康的如果加一条就要改很多 UI说明组件拆分还不够。事件回调要短一点.onClick()、onChange()这类回调里可以直接改状态但不建议堆太多逻辑。简单切换可以写在回调里复杂逻辑最好拆成方法。这样做有两个好处一是读页面结构时不会被业务逻辑打断二是后面要加埋点、请求接口、异常处理时有明确的位置可以改。样式参数别急着抽常量这个案例里有不少颜色、间距、圆角、字号。学习阶段直接写在组件链上反而更容易看出效果。等你确定页面风格稳定后再把主题色、通用间距、卡片圆角提到统一常量里。过早抽象会让示例代码变绕。先让页面清楚再谈工程化这是我比较推荐的顺序。总结这个案例真正值得学的不只是某个 ArkUI 组件怎么写而是一个页面如何从数据走到状态再走到布局和交互。你可以按这个顺序改造先替换数据模型再确认状态更新是否正确接着调整布局容器最后打磨样式细节。只要这条主线不乱页面规模变大也不会失控。如果后续继续扩展我会优先把可复用的卡片、列表行、筛选栏或控制区拆成独立组件。这样主页面只保留数据和流程代码会更接近真实项目里的写法。