尧图网络科技YAOTU DIGITAL 获取报价
获取报价
首页 / 资讯中心 / 文章详情

HarmonyOS ArkUI课程表双向滚动与嵌套滚动实现

发布时间:2026/9/24 19:17:19

资讯中心
01
ARTICLE

HarmonyOS ArkUI课程表双向滚动与嵌套滚动实现

HarmonyOS ArkUI课程表双向滚动与嵌套滚动实现
做过课程表类App的都知道这玩意儿看着简单实际上交互细节一堆。尤其是“双向滚动”——横向要按周切换纵向要按节次看全天安排两个方向还得共用表头和时间轴就像Excel里冻结首行首列一样。我在HarmonyOS 6上用ArkUI重新实现了一个课程表模块把横纵滚动的联动、表头对齐、课程卡片绝对定位这些核心逻辑梳理了一遍今天就把这套方案完整拆开分享出来正在做鸿蒙应用或者准备接触ArkUI嵌套滚动的朋友应该能少走不少弯路。1. 课程表双向滚动到底难在哪需求拆解与方案选型1.1 用户操作场景拆解横滑看周课表纵滑看全天时间线先还原一下真实使用场景。用户在课程表页面里最常见的操作有两种一是上下滑动想在时间轴上找到某个时段有没有课二是左右滑动想快速切到下周看看课程安排。看起来是两个方向的手势但底层其实是一张二维表横轴是星期一到星期日纵轴是第1节到第12节课程卡片就散落在这张表的不同格子里。难点在于我们希望在横向滚动时顶部的“周一、周二、周三”表头跟着一起滚在纵向滚动时左侧的“第1节、第2节”时间轴也跟着一起滚。如果单独用一个大列表表头会被滚走如果把表头固定住又得手动同步滚动偏移量。所以双向滚动的核心不是“能滚”而是“滚的时候各区域对齐”。我最初想得很简单要不要用Grid网格但仔细一想发现课程维度是“横坐标固定、纵坐标固定”而Grid是按顺序排列的流式布局课程跨节次、跨天数的摆放会很别扭。于是我把方案对比做了一下具体情况如下表。方案表头联动方式课程跨格摆放维护成本Grid/九宫格需要单独固定表头手动同步很别扭需要合并行列中嵌套Scroll利用滚动容器天然联动绝对定位非常直观低Canvas自绘完全自己处理自己算坐标高对比下来嵌套Scroll这个方案最贴近“二维表格滚动”的本质代码量也最少。1.2 三种实现路径对比为什么最终选择嵌套Scroll先说Grid方案。ArkUI的Grid组件在做网格列表时确实很好用但它强调的是流式排列每个item在网格中按顺序占位。课程表的问题是——课程卡片不是从第1格顺序排到第12格的比如周二第3节到第5节有一门大课它在横轴上的宽度是一格但在纵轴上的高度是三节课的高度。如果你强行用Grid就得让一个item跨行占位ArkUI虽然支持rowStart、columnStart这类属性但一旦跨行跨列还要考虑空节次的占位格子、表头固定区域写起来非常绕。Canvas自绘则是另一个极端。所有网格线、课程卡片、文字、滚动同步都自己实现性能天花板高但开发成本也高而且失去声明式布局的优势后续想加个点击弹窗之类的交互会特别痛苦。最后选了嵌套Scroll外层一个纵向Scroll内层一个横向Scroll表头放在横向Scroll内部时间轴放在横向Scroll外部。这样横轴方向的滚动让表头和课程格一起动纵轴方向的滚动让时间轴和课程格一起动完全不需要写同步代码。1.3 核心思想让滚动内容天然绑定而不是手动算偏移这个方案的灵魂在于“谁和谁绑在同一个滚动容器里”。横向滚动的时候要响应的是“周几”这一列信息和课程格子那就把星期表头和课程网格放在同一个横向Scroll里纵向滚动的时候要响应的是“第几节”这一行信息和课程格子那时间轴就得和课程网格放在同一个纵向Scroll里。课程网格是两个方向滚动的交集所以嵌套的结构是外层纵向Scroll包含了时间轴和横向Scroll横向Scroll里又包含了表头和课程网格。这样一来周日表头跟着横向滚动走时间轴跟着纵向滚动走两种滚动天然解耦完全不需要拿到滚动偏移量再手动move这是少写几百行代码的关键。2. 课程数据模型与表格坐标换算2.1 课程信息的结构设计不仅存数据还要存“怎么摆”在写UI之前先把数据结构定清楚。ArkTS对类型要求比较严格我在项目里定义了一个课程接口字段包括课程名称、教师、地点、星期几、开始节次、持续节数还包括一个颜色字段方便不同课程显示不同卡片色。interface Course { id: number; name: string; teacher: string; location: string; dayOfWeek: number; // 1~7周一~周日 startSection: number; // 开始节次从1开始 sectionCount: number; // 持续节数比如高数连上两节就是2 courseColor: string; // 卡片背景色 }除了这个原始课程数据我另外做了一层“布局数据”。原因是课程卡片在渲染时不仅需要原始字段还需要提前算好的x坐标、y坐标、宽度、高度。把计算好的结果存在一个LayoutCourse类型里UI层直接消费能避免每次build时重复计算。interface LayoutCourse extends Course { x: number; y: number; width: number; height: number; }这里有一个ArkTS的细节要提醒ArkTS默认不支持用对象字面量直接赋值给interface类型必须先用class或者显式声明类型。如果你在真实项目中遇到“Object literal must correspond to some explicitly declared class”这个报错基本都是这个问题定义一个类就行。2.2 从课程数据到界面位置的换算规则课程表布局要想对齐必须统一两个基础尺寸列宽和行高。我设计的是每列宽度120vp每行高度88vp。列宽对应一天的课程列行高对应一个节次的时间跨度。换算逻辑其实很简单可以用下面两个公式水平偏移 (dayOfWeek - 1) * 列宽垂直偏移 (startSection - 1) * 行高卡片高度 sectionCount * 行高 - 间距这里减掉间距是为了让卡片之间有呼吸感。我在卡片上下左右都留了4vp的间隙视觉上比卡片一个挨一个铺满好看很多。时间轴、表头、课程卡片三者的尺寸基准必须全局统一。我定义了一个尺寸配置类所有地方都不准写死数字。class TableMetrics { static readonly COL_WIDTH: number 120; static readonly ROW_HEIGHT: number 88; static readonly CARD_GAP: number 4; static readonly TIME_AXIS_WIDTH: number 76; static readonly WEEK_HEADER_HEIGHT: number 56; }把所有可调整的尺寸都收拢到一个类里是后期好调样式的基础。不然到测试阶段产品说“行高再高一点”你可得满文件找哪里写了数字80、88、90。2.3 空节次、跨周课程的边界处理实际开发中不是每个课程都老老实实待在周一到周五。我遇到两个情况需要额外处理。一是跨周课程。比如单周有课、双周没课这类课直接存成两条数据不优雅我的做法是在Course里加一个可选字段weeks表明该课程生效的周次范围在动态生成布局数据时只把当前周范围内的课程放进去。当然如果是单周双周各一套那就当成两门完全不同的课来存数据量也不大。二是晚上和中午的跨节次课程。有些学校下午第一大节是第5节到第6节第二大节是第7节到第8节中间还有课外活动时间这些并不会影响坐标计算只要把开始节次和持续节数填对公式能自动算出正确高度。最后还处理了一种情况有些课程卡片如果纵向高度太小比如只有1节88vp文字堆了课程名、教师、地点三行会显得挤。我实际处理时在裁剪线以下做了固定处理超过3行就截断显示“...”保证卡片不撑破。3. 主体布局代码表头、时间轴、课程网格的复合滚动结构3.1 整体布局骨架外层纵Scroll套内层横Scroll布局结构是整个案例的核心我把骨架代码贴出来边看边解释。Entry Component struct CourseTablePage { State layoutCourses: LayoutCourse[] []; build() { Column() { // 隐藏顶部标题栏、状态栏等聚焦课表区域 this.buildCourseTable() } .width(100%) .height(100%) .backgroundColor(#F4F5F7) } Builder buildCourseTable() { // 外层纵向滚动时间轴 横向滚动区域一起上下滚 Scroll() { Row() { // 左上角固定格子一个空的占位方块 Column() .width(TableMetrics.TIME_AXIS_WIDTH) .height(TableMetrics.WEEK_HEADER_HEIGHT) .backgroundColor(Color.White) .border({ width: 0.5, color: #E0E0E0 }) // 左侧时间轴显示第1节、第2节... Column() { ForEach(this.timeSlots(), (slot: number) { Column() { Text(第${slot}节) .fontSize(12) .fontColor(#666666) } .width(TableMetrics.COL_WIDTH) .height(TableMetrics.ROW_HEIGHT) .justifyContent(FlexAlign.Center) }) } .width(TableMetrics.TIME_AXIS_WIDTH) // 内层横向滚动表头 课程网格一起左右滚 Scroll() { Column() { // 星期表头 Row() { ForEach(this.weekDays(), (day: string) { Text(day) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(#333333) .width(TableMetrics.COL_WIDTH) .height(TableMetrics.WEEK_HEADER_HEIGHT) .textAlign(TextAlign.Center) }) } // 课程网格区域用Stack承载绝对定位的卡片 Stack() { // 网格背景线可选 // 课程卡片 ForEach(this.layoutCourses, (course: LayoutCourse) { this.buildCourseCard(course) }, (course: LayoutCourse) course.id.toString()) } .width(TableMetrics.COL_WIDTH * 7) .height(TableMetrics.ROW_HEIGHT * 12) .alignContent(Alignment.TopStart) } } .scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.None) } } .scrollable(ScrollDirection.Vertical) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.None) } }这个结构里外层Scroll是垂直滚动内层Scroll是水平滚动。当用户上下滑时外层Scroll消费手势左侧时间轴和右侧课程网格一起向上滚当用户左右滑时内层Scroll消费手势顶部星期表头和课程网格一起向左滚。左上角的空白格则完全不受滚动影响正好形成一个固定的角落。3.2 表头和时间轴到底怎么“固定”的很多第一次看这个结构的人会问表头和时间轴不是没固定住吗对表头和时间轴并没有真正“钉死”在屏幕边缘而是始终跟随着各自方向的滚动内容同步移动。关键在于星期表头在横向Scroll内部所以横向滚动时它跟着课程网格一起滚始终保持在同一屏幕位置对应的星期列上方时间轴在横向Scroll外部、纵向Scroll内部所以纵向滚动时它贴着课程网格同步滚。你要的效果是不管怎么横滚纵滚表头和时间轴永远指着当前正在显示的那一列和那一行。从这个角度看它们其实就是“动态固定”的不需要额外同步代码。我把边界情况也验证了一下横滚到最右侧周日列表头最右侧的“周日”依然顶在课程网格最右侧上方对齐没问题。纵滚到最底部第12节时间轴的“第12节”也正好对应网格最底部那一行。3.3 课程卡片的绝对定位绘制课程卡片是绝对定位的。直接放在Stack组件里左上角为原点用position设置坐标。Builder buildCourseCard(course: LayoutCourse) { Column() { Text(course.name) .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(#FFFFFF) .maxLines(2) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(course.teacher) .fontSize(11) .fontColor(rgba(255,255,255,0.85)) .maxLines(1) Text(course.location) .fontSize(11) .fontColor(rgba(255,255,255,0.85)) .maxLines(1) } .padding(6) .width(course.width) .height(course.height) .borderRadius(8) .backgroundColor(course.courseColor) .position({ x: course.x, y: course.y }) .alignItems(HorizontalAlign.Start) .justifyContent(FlexAlign.SpaceBetween) }这里有几个值得注意的地方。第一position的坐标原点是父组件Stack的左上角所以必须确保Stack的alignContent是TopStart不然后续所有卡片位置都会偏移。第二Stack的宽高必须明确设成整个网格区域的尺寸即7列宽乘12行高不然内容宽高计算会出问题。第三卡片背景色我选择了饱和度较高的颜色配上白色文字对比度更好字体清晰可见。3.4 网格线到底怎么画别傻傻画几十条线表格的网格线如果每条都用Divider去画代码量会非常臃肿。我的做法是网格区域底层放一层浅色背景Column再用for循环按行生成一条条细线列线则由每行Column的borderRight统一控制。简单说网格线不是重点重点是不影响卡片的绝对定位。实际项目里我连网格线都去掉了只在网格区域背景上叠加了淡淡的斑马纹效果按奇偶行交替颜色。视觉上更清爽代码也更少。ForEach(this.rowBackgrounds(), (index: number) { Row() .width(100%) .height(TableMetrics.ROW_HEIGHT) .backgroundColor(index % 2 0 ? #FFFFFF : #FAFAFC) })斑马纹的Column会在课程卡片下层既起到分区作用又不会干扰阅读。4. 滚动联动、手势冲突与性能体验4.1 手势方向判定嵌套滚动的默认行为和处理很多人担心嵌套Scroll会不会手势冲突——上下滑的时候内层横向Scroll会不会抢手势实际在ArkUI中不同方向的Scroll各自消费属于自己的轴位移垂直方向的Scroll只消费vertical方向的手势变化水平方向的Scroll只消费horizontal方向的手势变化。我实际测试发现用户手指横向滑动时纵向Scroll基本不会抢用户纵向滑动时内层横向Scroll也不会拦截。系统的手势仲裁做得比较聪明。但有个细节要注意外层纵向Scroll的edgeEffect最好设成None内层横向Scroll的edgeEffect也设成None不然在两个滚动方向快到边界时会出现类似“橡皮筋”的弹动效果同时两层都弹视觉上很乱。做一个课程表这种规整的工具型界面弹动效果宁可不要。.scrollable(ScrollDirection.Horizontal) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.None)4.2 课程卡片点击与滚动的手势优先级处理课程表不能光看用户肯定会点击课程卡片查看详情或者编辑。我在卡片上绑定了点击手势但需要注意手势和滚动手势同时存在时系统首先会判断用户是点击还是滑动。只要点击后手指没有大幅移动tap手势就能正常触发。实际操作中真正的坑是卡片太小、点击区域不够容易误触别的卡片。我在每张卡片上加了统一的padding但整个卡片面积还是小。后来直接把整个Stack区域加了一个背景点击事件实现了“点击空白位置创建新课程”的交互这样点击卡片触发详情、点击空白处触发新建各司其职。4.3 LazyForEach与大数据量下的渲染优化课程表的数据量其实不大一个学期就算有20门课也就是20张卡片ForEach完全够用。但如果你做的是“整学期课表”把每周的课程副本都展开那数据量会翻几十倍此时就应该考虑用LazyForEach来按需渲染。LazyForEach要求数据源实现IDataSource接口提供getData、getCount等方法。课程表场景下以“周次课程ID”作为数据源key这样切周时只需要替换数据源不需要手动去刷新所有卡片。后面我跟产品对需求时发现实际在线课程表还会涉及到“课程调停补课”这种临时变更如果不用key管理每次更新数据源都会全量重建卡片肉眼可见的卡顿。所以在架构上我提前把课程列表放入了一个可观察的数组通过替换整个数据源触发刷新渲染效率高很多。4.4 当前时间高亮线的实时刷新课程表里最出效果的功能就是“当前时间线”——一条横穿表格的红色虚线标出现在上到第几节课了。这个功能实现起来也不复杂计算当前时间在当天节次里的折算比例算成y坐标绝对定位一条Divider到网格区域。难点在于实时性。我需要每隔一段时间刷新一次当前时间。ArkUI里常用的做法是用setInterval定时器每分钟更新一次时间状态变量触发build更新高亮线的位置。aboutToAppear(): void { this.timer setInterval(() { this.currentTime new Date(); }, 60 * 1000); } aboutToDisappear(): void { clearInterval(this.timer); }注意定时器必须在aboutToDisappear里清掉否则页面销毁后定时器还在跑会造成内存泄漏。这在开发工具类页面时很容易被忽略我之前好几个页面都栽在这上面。5. 落地中踩过的坑和最终方案细节5.1 左上角固定格子的“缝”问题布局里左上角那个空白占位格看起来不起眼实际却是我调了最久的尺寸。原因是如果它的宽高和时间轴、表头不严丝合缝就会出现一条细缝尤其在深色模式下非常明显。我最终把左上角格子的宽高严格写成TABLE_AXIS_WIDTH和WEEK_HEADER_HEIGHT跟另外两块区域的尺寸保持一致彻底解决了这个问题。还有如果外面有半像素边框不同分辨率屏幕上容易渲染出模糊线我用0.5vp边框加上无背景的方案测试了几个主流屏幕比例都没问题。5.2 卡片溢出与圆角被裁切的问题课程卡片用的是8vp圆角刚开始在有明确背景色的网格区域上测试发现卡片靠近滚动边界时圆角外侧偶尔会露出脏颜色。这是因为Stack组件默认不会裁剪溢出子组件的内容——卡片定位到了Stack边缘外视觉上就穿帮了。解决办法是在Stack上加clip(true)强制裁剪溢出部分。这个属性在卡片居中、不越界时看起来无影响但一旦有越界卡片它就是生死线。5.3 ArkTS类型约束下的代码改造从这个案例能切身感受到把TS代码迁到ArkTS最大的成本不是写业务逻辑而是过类型检查。我在最开始用解构赋值、对象字面量时疯狂报错后来把所有数据都改成class或interface加显式new代码就清爽了。比如布局数据的生成不能用reduce和map过于复杂的链式调用我改成了普通for循环function buildLayoutCourses(courses: Course[]): LayoutCourse[] { const result: LayoutCourse[] []; for (let i 0; i courses.length; i) { const item courses[i]; const layout new LayoutCourse(); layout.id item.id; layout.name item.name; layout.teacher item.teacher; layout.location item.location; layout.dayOfWeek item.dayOfWeek; layout.startSection item.startSection; layout.sectionCount item.sectionCount; layout.courseColor item.courseColor; layout.x (item.dayOfWeek - 1) * TableMetrics.COL_WIDTH TableMetrics.CARD_GAP; layout.y (item.startSection - 1) * TableMetrics.ROW_HEIGHT TableMetrics.CARD_GAP; layout.width TableMetrics.COL_WIDTH - TableMetrics.CARD_GAP * 2; layout.height item.sectionCount * TableMetrics.ROW_HEIGHT - TableMetrics.CARD_GAP * 2; result.push(layout); } return result; }在ArkTS里写代码逻辑不如TS灵活但胜在类型明确、可读性强。现在再看这些代码觉得反而比花哨的链式调用更容易维护。5.4 模拟器和真机的滚动手感差异最后务必提一下开发课程表这种嵌套滚动页面一定要拿真机测滚动模拟器测不出手感。模拟器里横向滚动和纵向滚动都偏“飘”很容易让你误判是否该做阻尼或惯性处理。我在MatePad和P系列手机上分别测了真机上的ArkUI嵌套Scroll阻尼适中惯性跟手没有出现横向和纵向互相干扰的情况。但真机上我发现一个模拟器发现不了的问题纵向外层Scroll在快速滑动后停住时内层横向Scroll会有一瞬间的“回弹点头”。排查下来是因为外层滚动结束的回弹动画和内层内容惯性运动叠加了。最终通过把内层横向Scroll的edgeEffect设成None解决了回弹动画消失后整体手感反而更干脆。这个案例做下来我最深的感受是双向滚动课程表的难点不在“滚动”而在“对齐”。所有坐标、尺寸、层级对齐了剩下的滚动其实是ArkUI自带的原生能力。如果你在开发类似课表、甘特图、日历周视图这种双向滚动的界面强烈推荐试试这个嵌套Scroll方案代码量能少一半交互还自然。最后再顺手分享一个小技巧给课程卡片加一个低频的阴影过渡动画会让整个表格看起来高级很多但一定要控制动画时长在150ms以内不然表头跟随滚动时会有明显的掉帧感。
02
RELATED NEWS

相关资讯

更多网站建设与数字化升级内容

03
WHY YAOTU

想打造同款高转化官网?

懂行业、懂生意,从建站到增长一站式陪跑

场景化定制

不做模板站,围绕你的业务场景量身设计,小众不撞款。

营销型架构

以转化目标组织内容与路径,让官网真正带来询盘。

全周期服务

设计、开发、运营、运维一体,上线只是开始。

免费获取你的建站方案

留下需求,专属顾问 24 小时内为你输出方案建议。