HarmonyOS 5 双向滚动课程表:技术实现与交互设计解析(附:源代码)
在移动应用开发中,复杂数据的可视化展示一直是用户体验的关键环节。本文将围绕鸿蒙OS平台的双向滚动课程表展开,深入解析其技术实现原理、交互设计逻辑以及双向滚动功能的应用场景,为开发者提供从原理到实践的完整技术指南。
鸿蒙OS双向滚动课程表的核心架构
鸿蒙OS的课程表应用采用了双向滚动技术,实现了时间维度与课程维度的高效展示。整个系统由以下几个核心部分构成:
数据模型设计
课程表的基础数据模型定义了课程的基本属性,包括课程名称和背景颜色,这些属性不仅用于展示课程信息,还通过不同颜色区分不同课程类型,提升视觉辨识度:
export class Course {public name: ResourceStr = '-'public backColor: ResourceColor = '#EDC7FF'constructor(name: ResourceStr, backgroundColor: ResourceColor) {this.name = name;this.backColor = backgroundColor;}
}
课程数据数组COURSE_MODEL
预定义了各类课程实例,包括空课程和不同科目的课程,每种课程分配了独特的背景色以增强可视化效果:
export const COURSE_MODEL: Course[] = [new Course($r('app.string.empty'), Color.White),new Course($r('app.string.course8'), '#EDC7FF'), new Course($r('app.string.course5_1'), '#FFE1E8'),// 更多课程定义...new Course($r('app.string.course9'), '#ECDC96')]
双向滚动核心实现
双向滚动功能的实现依赖于鸿蒙OS的滚动控制器机制,通过多个Scroller
实例协同工作,实现水平与垂直方向的滚动同步:
@Entry
@Component
export struct Index {// 滚动控制器定义classScroller = new Scroller();timeScroller = new Scroller();weekdaysScroller = new Scroller();horizontalScroller = new Scroller();verticalScroller = new Scroller();// 课程数据矩阵,实现二维数据展示data: Course[][] = [[COURSE_MODEL[0], COURSE_MODEL[1], COURSE_MODEL[4], COURSE_MODEL[1],COURSE_MODEL[10], COURSE_MODEL[2], COURSE_MODEL[0]],// 更多课程数据...]// 其他组件状态定义...
}
核心布局部分通过嵌套的Scroll
组件实现双向滚动功能,水平滚动控制器与垂直滚动控制器通过事件监听实现联动:
Column() {// 表头部分,包含星期和课时信息Row() {// 课时序号列Column() {Text('节').fontSize(14).fontWeight(400).textAlign(TextAlign.Center)}.width(50).height(42)// 时间列Column() {Text('上课时间').fontSize(14).fontWeight(400).textAlign(TextAlign.Center)}.width(60).height(42)// 星期水平滚动列Column() {Scroll(this.weekdaysScroller) {List() {ForEach(this.classificationNames, (item: Resource, index: number) => {ListItem() {Text(item).fontSize(14).textAlign(TextAlign.Center)}})}}.onScrollFrameBegin((offset: number) => {this.horizontalScroller.scrollBy(offset, 0);return { offsetRemain: offset };})}.width('70%')}// 内容区双向滚动实现Row() {// 左侧课时和时间垂直滚动区Column() {Scroll(this.classScroller) { /* 课时序号垂直滚动 */ }Scroll(this.timeScroller) { /* 时间垂直滚动 */ }}// 右侧课程内容双向滚动区Column() {Scroll(this.horizontalScroller) {Scroll(this.verticalScroller) {// 课程内容矩阵渲染ForEach(this.arr, (_temp: number, index: number) => {Row() {ForEach(this.data[this.arr[_temp]], (item: Course) => {this.itemBuilder(item.name, 100, 120, item.backColor, Color.Black)})}})}.onScrollFrameBegin((offset: number) => {this.classScroller.scrollBy(0, offset);this.timeScroller.scrollBy(0, offset)})}.onScrollFrameBegin((offset: number) => {this.weekdaysScroller.scrollBy(offset, 0);})}}
}
双向滚动功能的技术原理
双向滚动技术在鸿蒙OS中的实现,突破了传统列表只能单向滚动的限制,通过多个滚动控制器的协同工作,实现了二维数据的高效展示。其核心技术要点包括:
滚动控制器的协同机制
鸿蒙OS的Scroller
类提供了精确控制滚动位置的能力,通过scrollBy
和scrollTo
方法可以实现程序化滚动,而onScrollFrameBegin
事件则允许在滚动过程中实时同步多个控制器的位置:
// 水平滚动同步逻辑
.onScrollFrameBegin((offset: number) => {this.horizontalScroller.scrollBy(offset, 0);return { offsetRemain: offset };
})// 垂直滚动同步逻辑
.onScrollFrameBegin((offset: number) => {this.verticalScroller.scrollBy(0, offset);this.timeScroller.scrollBy(0, offset);return { offsetRemain: offset };
})
这种同步机制确保了用户在水平滚动查看不同星期的课程时,左侧的课时和时间列能够保持固定位置,反之亦然,从而实现类似Excel表格的冻结表头效果。
二维数据的渲染优化
课程表采用了二维数组data: Course[][]
存储课程数据,通过双重ForEach
循环渲染课程卡片,实现了行(课时)与列(星期)的矩阵式展示:
ForEach(this.arr, (_temp: number, index: number) => {Row() {ForEach(this.data[this.arr[_temp]], (item: Course) => {this.itemBuilder(item.name, 100, 120, item.backColor, Color.Black)}, (item: Course) => getResourceID().toString())}
}, (_temp: number) => _temp + new Date().toString())
这种渲染方式结合滚动控制器,使得大规模课程数据能够按需加载,避免了一次性渲染大量组件带来的性能问题。
交互体验优化细节
课程表在交互细节上做了多项优化,包括:
- 初始位置自动定位:根据当前星期自动滚动到对应的日期列
.onAppear(() => {if (this.weekDay > 1) {this.weekdaysScroller.scrollTo({ xOffset: (this.weekDay - 1) * 120, yOffset: 0 })this.horizontalScroller.scrollTo({ xOffset: (this.weekDay - 1) * 120, yOffset: 0 })}
})
- 卡片式UI设计:每个课程使用独立卡片展示,通过背景色区分不同课程,提升视觉层次感
@Builder
itemBuilder(msg: ResourceStr, curHeight: Length = 100, curWidth: Length = 120,curBackgroundColor: ResourceColor = Color.White, curFontColor: ResourceColor = Color.Black) {Row() {Column() {Stack() {Column() { }.backgroundColor(curBackgroundColor).width('100%').height('100%').opacity(0.7)Text(msg).fontSize(12).textAlign(TextAlign.Center).fontColor(curFontColor).opacity(0.9).padding(6)}}.padding(6).width(curWidth).height(curHeight).backgroundColor(Color.White)}
}
- 标签页导航:通过底部标签页切换不同功能模块,课程表作为核心功能位于默认标签页
@Builder
tabBuilder(title: string, targetIndex: number, selectedImg: Resource, normalImg: Resource) {Column() {Image(this.currentIndex === targetIndex ? selectedImg : normalImg).size({ width: 25, height: 25 })Text(title).fontColor(this.currentIndex === targetIndex ? '#0A59F7' : '#60000000')}.height('100%').margin({ top: 8 })
}
双向滚动技术的应用场景拓展
双向滚动技术不仅适用于课程表,还能在多种需要二维数据展示的场景中发挥价值,其核心优势在于能够同时展示两个维度的信息,并保持维度之间的关联关系。
企业应用中的数据可视化
在企业管理系统中,双向滚动技术可用于:
- 财务报表分析:横向展示时间维度(月份/季度),纵向展示科目维度(收入/支出),交叉点显示具体数值
- 项目甘特图:横向为时间轴,纵向为任务列表,直观展示项目进度
- 人力资源管理:横向为时间周期,纵向为员工列表,展示考勤、绩效等信息
教育领域的创新应用
除课程表外,教育类应用还可拓展:
- 知识图谱可视化:横向为学科分支,纵向为难度层级,帮助学生梳理知识结构
- 学习进度跟踪:横向为时间维度,纵向为知识点列表,显示掌握程度
- 多维度测试分析:横向为题型,纵向为学生列表,展示答题情况
生活服务类应用场景
在生活服务领域,双向滚动技术可用于:
- 日程管理:横向为日期,纵向为不同类型的日程(工作/生活/家庭)
- 健身数据监测:横向为时间,纵向为健康指标(心率/血压/体重)
- 旅行规划:横向为旅行天数,纵向为行程类型(交通/住宿/景点)
技术实现的挑战与优化策略
双向滚动技术在带来强大展示能力的同时,也面临一些技术挑战:
性能优化关键点
- 虚拟列表渲染:对于大规模数据,使用
LazyForEach
代替ForEach
,实现按需渲染
// 虚拟列表优化示例
LazyForEach(this.arr, (_temp: number, index: number) => {// 渲染逻辑...
}, (_temp: number) => _temp.toString())
- 滚动事件节流:限制滚动事件触发频率,避免频繁重绘
// 滚动事件节流实现
let lastScrollTime = 0;
.onScrollFrameBegin((offset: number) => {const now = new Date().getTime();if (now - lastScrollTime > 100) { // 100ms内只处理一次lastScrollTime = now;// 滚动同步逻辑...}return { offsetRemain: offset };
})
- 组件复用:将课程卡片等重复组件封装为独立
@Builder
方法,提高渲染效率
多设备适配方案
- 响应式布局:根据屏幕宽度动态调整列宽和卡片大小
@State screenWidth: number = getContentRect().width;// 根据屏幕宽度获取合适的列宽
private getColumnWidth(): number {if (this.screenWidth < 600) return 100; // 小屏设备if (this.screenWidth < 1024) return 120; // 平板return 150; // 大屏设备
}
- 手势适配:针对不同设备优化滚动灵敏度和惯性效果
Scroll(this.horizontalScroller) {// 内容...
}
.scrollBar(BarState.Off)
.scrollable(ScrollDirection.Horizontal)
.dragEffect(DragEffect.Fling) // 惯性滚动效果
.friction(0.8) // 滚动摩擦系数
附:代码
import { common } from '@kit.AbilityKit';import { CommonModifier } from '@kit.ArkUI';let preResourceId = 0;export class Course {public name: ResourceStr = '-'public backColor: ResourceColor = '#EDC7FF'constructor(name: ResourceStr, backgroundColor: ResourceColor) {this.name = name;this.backColor = backgroundColor;}
}export const COURSE_MODEL: Course[] = [new Course($r('app.string.empty'), Color.White),new Course($r('app.string.course8'), '#EDC7FF'), new Course($r('app.string.course5_1'), '#FFE1E8'),new Course($r('app.string.course2'), '#E1F9FF'), new Course($r('app.string.course1_1'), '#E5F0E1'),new Course($r('app.string.course7'), '#FFDACC'), new Course($r('app.string.course6'), '#ECFC9A'),new Course($r('app.string.course5'), '#FFE1E8'), new Course($r('app.string.course4_1'), '#FFFCA3'),new Course($r('app.string.course4'), '#FBF0FF'), new Course($r('app.string.course3'), '#FFE4C7'),new Course($r('app.string.course1'), '#FFF0F0'), new Course($r('app.string.course9'), '#ECDC96')]@Entry
@Component
export struct Index {@State classIndex: Array<string> = ['1', '2', '3', '4', '5', '6', '7', '8']@State classTime: Array<string> = ['8:30-9:15', '9:25-10:10', '10:30-11:15', '11:15-12:10','14:10-14:55', '15:05-15:45', '15:55-16:30', '16:45-17:00']weekDay: number = new Date().getDay()classScroller = new Scroller();timeScroller = new Scroller();weekdaysScroller = new Scroller();horizontalScroller = new Scroller();verticalScroller = new Scroller();data: Course[][] = [[COURSE_MODEL[0], COURSE_MODEL[1], COURSE_MODEL[4], COURSE_MODEL[1],COURSE_MODEL[10], COURSE_MODEL[2], COURSE_MODEL[0]],[COURSE_MODEL[3], COURSE_MODEL[1], COURSE_MODEL[4], COURSE_MODEL[1],COURSE_MODEL[10], COURSE_MODEL[2], COURSE_MODEL[0]],[COURSE_MODEL[5], COURSE_MODEL[8], COURSE_MODEL[9], COURSE_MODEL[0],COURSE_MODEL[0], COURSE_MODEL[7], COURSE_MODEL[10]],[COURSE_MODEL[5], COURSE_MODEL[8], COURSE_MODEL[9], COURSE_MODEL[0],COURSE_MODEL[0], COURSE_MODEL[7], COURSE_MODEL[10]],[COURSE_MODEL[11], COURSE_MODEL[1], COURSE_MODEL[2], COURSE_MODEL[0],COURSE_MODEL[0], COURSE_MODEL[0], COURSE_MODEL[0]],[COURSE_MODEL[11], COURSE_MODEL[12], COURSE_MODEL[2], COURSE_MODEL[12],COURSE_MODEL[0], COURSE_MODEL[0], COURSE_MODEL[0]],[COURSE_MODEL[11], COURSE_MODEL[12], COURSE_MODEL[2], COURSE_MODEL[12],COURSE_MODEL[4], COURSE_MODEL[5], COURSE_MODEL[0]],[COURSE_MODEL[0], COURSE_MODEL[0], COURSE_MODEL[6], COURSE_MODEL[1],COURSE_MODEL[4], COURSE_MODEL[5], COURSE_MODEL[6]]]arr: number[] = []classificationNames: Array<Resource> = [$r('app.string.monday'), $r('app.string.tuesday'),$r('app.string.wednesday'),$r('app.string.thursday'), $r('app.string.friday'), $r('app.string.saturday'), $r('app.string.sunday')]@State currentIndex: number = 2;private controller: TabsController = new TabsController();@StorageProp('bottomRectHeight') bottomRectHeight: number = 0;@StorageProp('topRectHeight') topRectHeight: number = 0;@State tabBarModifier: CommonModifier = new CommonModifier();aboutToAppear() {for (let index = 0; index < this.classIndex.length; index++) {this.arr.push(index)}this.tabBarModifier.alignRules({})}@BuilderitemBuilder(msg: ResourceStr, curHeight: Length = 100, curWidth: Length = 120,curBackgroundColor: ResourceColor = Color.White, curFontColor: ResourceColor = Color.Black) {Row() {Column() {Stack() {Column() {}.backgroundColor(curBackgroundColor).width('100%').height('100%').opacity(0.7)Text(msg).fontSize(12).fontWeight(400).textAlign(TextAlign.Center).fontColor(curFontColor).opacity(0.9).width('100%').height('100%').padding(6)}}.padding(6).justifyContent(FlexAlign.Center).width(curWidth).height(curHeight).backgroundColor(Color.White)}}@BuildertabBuilder(title: string, targetIndex: number, selectedImg: Resource, normalImg: Resource) {Column() {Image(this.currentIndex === targetIndex ? selectedImg : normalImg).size({ width: 25, height: 25 })Text(title).fontColor(this.currentIndex === targetIndex ? '#0A59F7' : '#60000000').fontSize(10).fontWeight(500).margin({ top: 4 })}.height('100%').margin({ top: 8 }).width('auto').alignItems(HorizontalAlign.Center)}build() {Tabs({barPosition: BarPosition.End,index: this.currentIndex,controller: this.controller,barModifier: this.tabBarModifier}) {TabContent() {Column() {}.padding({ top: px2vp(this.topRectHeight) }).height('100%')}.height('100%').tabBar(this.tabBuilder('主页', 0, $r('app.media.main_page'), $r('app.media.main_page')))TabContent() {Column() {}.padding({ top: px2vp(this.topRectHeight) }).height('100%')}.height('100%').tabBar(this.tabBuilder('消息', 1, $r('app.media.message'), $r('app.media.message')))TabContent() {Column() {Row() {Image($r('app.media.back')).width(42).margin({ right: 8 })Text('第8周').fontSize(22).fontWeight(800).lineHeight(27)}.margin({ left: 32, bottom: 8, top: 12 }).height(42).width('100%').justifyContent(FlexAlign.Start)Column() {Row() {Column() {Text('节').fontSize(14).fontWeight(400).textAlign(TextAlign.Center).fontColor(Color.Black).width('100%').height('100%')}.justifyContent(FlexAlign.Center).width(50).height(42)Column() {Text('上课时间').fontSize(14).fontWeight(400).textAlign(TextAlign.Center).fontColor(Color.Black).width('100%').height('100%')}.justifyContent(FlexAlign.Center).width(60).height(42)Column() {Scroll(this.weekdaysScroller) {List() {ForEach(this.classificationNames, (item: Resource, index: number) => {ListItem() {Row() {Column() {Text(item).fontSize(14).fontWeight(400).textAlign(TextAlign.Center).fontColor(Color.Black).width('100%')}.padding(4).justifyContent(FlexAlign.Center).width(120).height(42).backgroundColor(Color.White)}}}, (item: string) => item + new Date().toString())}.listDirection(Axis.Horizontal).edgeEffect(EdgeEffect.None)}.scrollable(ScrollDirection.Horizontal).scrollBar(BarState.Off).height(42).onScrollFrameBegin((offset: number) => {this.horizontalScroller.scrollBy(offset, 0);return { offsetRemain: offset };})}.width('70%')}.width('100%').position({ x: 0, y: 0 }).backgroundColor(Color.White).borderRadius({ topLeft: 16 }).shadow({ radius: 50, color: '#25000000' }).zIndex(10)Row() {Column() {Scroll(this.classScroller) {Column() {List({ space: 0, initialIndex: 0 }) {ForEach(this.classIndex, (item: string, index: number) => {ListItem() {Row() {Column() {Text(item).fontSize(14).fontWeight(400).textAlign(TextAlign.Center).fontColor(Color.Black).width('100%')}.padding(4).justifyContent(FlexAlign.Center).width(50).height(100).backgroundColor(Color.White)}}}, (item: string) => JSON.stringify(item))}.listDirection(Axis.Vertical).edgeEffect(EdgeEffect.None) // 滑动到边缘无效果}}.position({ x: 0, y: 0 }).width(50).backgroundColor('#F8F8FF').scrollBar(BarState.Off).onScrollFrameBegin((offset: number) => {this.verticalScroller.scrollBy(0, offset);this.timeScroller.scrollBy(0, offset);return { offsetRemain: offset };})Scroll(this.timeScroller) {Column() {List({ space: 0, initialIndex: 0 }) {ForEach(this.classTime, (item: string, index: number) => {ListItem() {Row() {Column() {Text(item.split('-')[0] + ' -').fontSize(12).textAlign(TextAlign.Center).fontColor(Color.Black).width('100%').height('50%').align(Alignment.Bottom).lineHeight(20)Text(item.split('-')[1]).fontSize(12).textAlign(TextAlign.Center).fontColor(Color.Black).width('100%').height('50%').align(Alignment.Top).lineHeight(20)}.padding(4).justifyContent(FlexAlign.Center).width(60).height(100).backgroundColor(Color.White)}}}, (item: string) => JSON.stringify(item))}.listDirection(Axis.Vertical).edgeEffect(EdgeEffect.None)}}.position({ x: 50, y: 0 }).width(60).backgroundColor('#F8F8FF').scrollBar(BarState.Off).onScrollFrameBegin((offset: number) => {this.verticalScroller.scrollBy(0, offset);this.classScroller.scrollBy(0, offset);return { offsetRemain: offset };})}.width(110).height('100%').backgroundColor(Color.White).shadow({ radius: 45, color: '#25000000', offsetY: -15 }).padding({top:4, bottom:20})Column() {Scroll(this.horizontalScroller) {Scroll(this.verticalScroller) {Column() {ForEach(this.arr, (_temp: number, index: number) => {Row() {ForEach(this.data[this.arr[_temp]], (item: Course) => {this.itemBuilder(item.name, 100, 120, item.backColor, Color.Black)}, (item: Course) => getResourceID().toString())}}, (_temp: number) => _temp + new Date().toString())}}.scrollBar(BarState.Off).scrollable(ScrollDirection.Vertical).height('100%').width(this.classificationNames.length * 120).onScrollFrameBegin((offset: number) => {this.classScroller.scrollBy(0, offset);this.timeScroller.scrollBy(0, offset)return { offsetRemain: offset };})}.padding({top:4, left:4, right:4, bottom:20}).scrollBar(BarState.Off).scrollable(ScrollDirection.Horizontal).onScrollFrameBegin((offset: number) => {this.weekdaysScroller.scrollBy(offset, 0);return { offsetRemain: offset };})}.zIndex(-100).width('70%')}.position({ x: 0, y: 42 }).width('100%').height('100%').backgroundColor(Color.White)}.height('88%').onAppear(() => {if (this.weekDay > 1) {this.weekdaysScroller.scrollTo({ xOffset: (this.weekDay - 1) * 120, yOffset: 0 })this.horizontalScroller.scrollTo({ xOffset: (this.weekDay - 1) * 120, yOffset: 0 })}}).width('100%')}.padding({ top: px2vp(this.topRectHeight) }).height('100%')}.height('100%').backgroundColor('#F1F3F5').tabBar(this.tabBuilder('课表', 2, $r('app.media.course_table_clicked'), $r('app.media.course_table_unclicked')))}.onChange((index: number) => {this.currentIndex = index;}).width('100%').height('100%').barHeight(80).barBackgroundColor('#F1F3F5')}
}function getResourceID(): string {preResourceId++;return preResourceId.toString()
}
// string.json
{"string": [{"name": "module_desc","value": "module description"},{"name": "EntryAbility_desc","value": "description"},{"name": "EntryAbility_label","value": "label"},{"name": "course1","value": "B4037 软件项目管理 1-18周 2C304"},{"name": "course2","value": "B1469 Java Web框架技术 1-18周 1504"},{"name": "course3","value": "B1427 统一建模语言 1-18周 1509"},{"name": "course4","value": "B1678 网络管理与维护 1-18周 1510"},{"name": "course5","value": "B2857 软件工程 1-18周 1411"},{"name": "course6","value": "B0383 大学生就业指导 1-8周 3406"},{"name": "course1_1","value": "B4037 软件项目管理 1、3、5、7、9、11、13、15、17周 2C304"},{"name": "course7","value": "B3873 信息检索 1-8周 3408"},{"name": "course8","value": "B1478 移动应用开发综合实训 1-18周 2C602"},{"name": "course4_1","value": "B1678 网络管理与维护 2、4、6、8、10、12、14、16、18周 1604"},{"name": "course5_1","value": "B2857 软件工程 1、3、5、7、9、11、13、15、17周 1509"},{"name": "course9","value": "B2857 软件工程综合实训 11-12周 1502"},{"name": "empty","value": "-"},{"name": "monday","value": "星期一"},{"name": "tuesday","value": "星期二"},{"name": "wednesday","value": "星期三"},{"name": "thursday","value": "星期四"},{"name": "friday","value": "星期五"},{"name": "saturday","value": "星期六"},{"name": "sunday","value": "星期天"}]
}
鸿蒙OS双向滚动课程表展示了二维数据可视化的优雅解决方案,通过滚动控制器的协同工作和精心设计的UI布局,实现了时间与课程维度的高效展示。这种技术不仅适用于教育领域的课程表,还能在企业管理、健康医疗、生活服务等多个领域发挥重要作用。
对于开发者而言,双向滚动技术的核心在于理解多个滚动控制器的协同机制,以及二维数据的高效渲染方式。通过合理的性能优化和多设备适配策略,可以将这种技术应用于更广泛的场景,为用户提供更加直观、高效的数据可视化体验。随着鸿蒙OS生态的不断发展,双向滚动技术必将在更多创新应用中展现其价值。