前言

很多 ArkUI 示例看着不大,真正难的是把状态、列表和交互放在合适的位置。这个案例就很典型。

Ink sketch notes style diagram illustrating the co

这个案例的主线是 课程表应用,后面的 周视图、课程管理、课程详情 则把页面做得更像真实业务页。读代码时,建议把交互、状态和列表这三条线一起看。

先看页面目标

CourseSchedulePage 对应的是 课程表应用 - 周视图、课程管理、课程详情 这个业务场景。别把它只当成一个 UI 练习页,它真正有价值的地方在于:同一个页面里同时出现了状态切换、列表渲染、条件展示和用户反馈。

观察点 在这个案例里的表现
页面主题 课程表应用
主要文案 高等数学, 李教授, 1-16周, ArkTS开发, 张老师, 1-8周
常用组件 Column, ForEach, Row, Scroll, Text
适合练习 状态驱动 UI、局部刷新、事件回调、页面结构拆分

使用方法

把完整代码放进 ArkTS 页面文件后,就可以直接在预览器里运行。实际使用时,建议先手动点一遍页面里的主要交互,看状态有没有跟着变化;然后再去对照代码,理解每个 @State 字段到底控制了哪一块区域。

如果你想把这个案例改成自己的版本,我建议先做三件事:换掉模拟数据、调整筛选规则、再把重复结构抽成 Builder 或子组件。顺序别反,先改结构往往最容易把自己绕进去。

数据怎么驱动 UI

ArkUI 页面写顺手之后,你会发现很多问题其实都不是样式问题,而是状态没放对位置。这个案例里能直接看到哪些数据是输入、哪些是选择、哪些是列表源,读起来会比较顺。

状态字段 类型 说明
currentWeek number 当前选中项,决定内容切换、高亮或过滤结果
selectedDay number 当前选中项,决定内容切换、高亮或过滤结果
courses CourseItem_bgsb[] 列表数据源,通常会交给 ForEach 或卡片区域渲染
selectedCourse `CourseItem_bgsb undefined`

我自己看这类示例时,会先把 @State 和事件函数圈出来,再去看布局。这样不会被大段 ColumnRow 搞乱节奏。

值得抄的片段

片段 1: getCoursesForDay

这段代码我建议单独看。它通常负责派生数据、交互动作或者局部渲染逻辑,把这部分抽出去之后,页面主结构会干净很多,后续要改规则也更省事。

  getCoursesForDay(day: number): CourseItem_bgsb[] {
    return this.courses.filter((c: CourseItem_bgsb) => c.dayOfWeek === day)
  }
片段 2: getCourseAtSection

这段代码我建议单独看。它通常负责派生数据、交互动作或者局部渲染逻辑,把这部分抽出去之后,页面主结构会干净很多,后续要改规则也更省事。

  getCourseAtSection(day: number, section: number): CourseItem_bgsb | undefined {
    return this.courses.find((c: CourseItem_bgsb) =>
      c.dayOfWeek === day && c.startSection <= section && section <= c.endSection
    )
  }

Ink-style architectural flowchart for ArkUI state-

必读小结

适合拿来练手的能力:页面拆分、状态联动、条件渲染、交互反馈。

推荐阅读顺序:先看前言 -> 再跑页面 -> 再看状态表 -> 最后啃完整代码
如果只想快速上手,优先改模拟数据和交互函数,收益最高
我会重点检查的几个地方
  • 点击或输入之后,界面有没有立即更新
  • 列表渲染是否依赖了稳定的数据结构
  • 筛选、统计、派生数据是不是单独放进函数里
  • 是否还有重复布局可以继续抽出去
  • 文案、颜色、间距是不是同一套风格

完整实现

// CourseSchedulePage - 课程表应用:周视图、课程管理、课程详情

interface CourseItem_bgsb {
  id: number
  name: string
  teacher: string
  classroom: string
  color: string
  dayOfWeek: number
  startSection: number
  endSection: number
  weeks: string
}

interface DaySchedule {
  dayIndex: number
  dayName: string
  date: string
  isToday: boolean
}
interface InfoData { label: string, value: string, emoji: string }
@Entry
@Component
struct CourseSchedulePage {
  @State currentWeek: number = 12
  @State selectedDay: number = 5
  @State courses: CourseItem_bgsb[] = [
    { id: 1, name: '高等数学', teacher: '李教授', classroom: 'A101', color: '#FF7875', dayOfWeek: 1, startSection: 1, endSection: 2, weeks: '1-16周' },
    { id: 2, name: 'ArkTS开发', teacher: '张老师', classroom: 'B205', color: '#69C0FF', dayOfWeek: 1, startSection: 3, endSection: 4, weeks: '1-8周' },
    { id: 3, name: '英语写作', teacher: 'Smith', classroom: 'C301', color: '#95DE64', dayOfWeek: 2, startSection: 1, endSection: 2, weeks: '1-16周' },
    { id: 4, name: '数据结构', teacher: '王教授', classroom: 'A201', color: '#FFB347', dayOfWeek: 2, startSection: 5, endSection: 6, weeks: '1-16周' },
    { id: 5, name: '线性代数', teacher: '赵老师', classroom: 'B101', color: '#D3ADF7', dayOfWeek: 3, startSection: 3, endSection: 4, weeks: '1-16周' },
    { id: 6, name: '计算机网络', teacher: '刘工程师', classroom: 'A302', color: '#87E8DE', dayOfWeek: 4, startSection: 1, endSection: 2, weeks: '5-16周' },
    { id: 7, name: '操作系统', teacher: '陈老师', classroom: 'B208', color: '#FFA39E', dayOfWeek: 4, startSection: 5, endSection: 6, weeks: '1-16周' },
    { id: 8, name: 'UI设计基础', teacher: '设计老师', classroom: '设计室', color: '#FFEC3D', dayOfWeek: 5, startSection: 1, endSection: 4, weeks: '1-8周' },
    { id: 9, name: '体育', teacher: '体育老师', classroom: '操场', color: '#B7EB8F', dayOfWeek: 3, startSection: 7, endSection: 8, weeks: '1-16周' },
  ]
  @State selectedCourse: CourseItem_bgsb | undefined = undefined

  private sections: string[] = ['08:00', '08:55', '10:00', '10:55', '14:00', '14:55', '16:00', '16:55', '19:00', '19:55']
  private days: DaySchedule[] = [
    { dayIndex: 1, dayName: '一', date: '6/2', isToday: false },
    { dayIndex: 2, dayName: '二', date: '6/3', isToday: false },
    { dayIndex: 3, dayName: '三', date: '6/4', isToday: false },
    { dayIndex: 4, dayName: '四', date: '6/5', isToday: true },
    { dayIndex: 5, dayName: '五', date: '6/6', isToday: false },
    { dayIndex: 6, dayName: '六', date: '6/7', isToday: false },
    { dayIndex: 7, dayName: '日', date: '6/8', isToday: false },
  ]

  getCoursesForDay(day: number): CourseItem_bgsb[] {
    return this.courses.filter((c: CourseItem_bgsb) => c.dayOfWeek === day)
  }

  getCourseAtSection(day: number, section: number): CourseItem_bgsb | undefined {
    return this.courses.find((c: CourseItem_bgsb) =>
      c.dayOfWeek === day && c.startSection <= section && section <= c.endSection
    )
  }

  shouldShowCourse(day: number, section: number): boolean {
    const course = this.getCourseAtSection(day, section)
    return course !== undefined && course.startSection === section
  }

  getCourseHeight(day: number, section: number): number {
    const course = this.getCourseAtSection(day, section)
    if (course === undefined) return 0
    return (course.endSection - course.startSection + 1) * 56 - 2
  }

  shouldShowEmptyCell(day: number, section: number): boolean {
    const course = this.getCourseAtSection(day, section)
    return course === undefined || course.startSection === section
  }

  build() {
    Column() {
      // 顶部周信息
      Row() {
        Text('◀').fontSize(18).fontColor('#595959').padding(8)
          .onClick(() => { if (this.currentWeek > 1) this.currentWeek-- })
        Text(`${this.currentWeek}`).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').layoutWeight(1).textAlign(TextAlign.Center)
        Text('▶').fontSize(18).fontColor('#595959').padding(8)
          .onClick(() => { if (this.currentWeek < 20) this.currentWeek++ })
      }.width('100%').padding({ left: 8, right: 8, top: 12, bottom: 8 })

      // 星期表头
      Row() {
        Text('节次').fontSize(11).fontColor('#BFBFBF').width(36).textAlign(TextAlign.Center)
        ForEach(this.days, (d: DaySchedule) => {
          Column() {
            Text(`${d.dayName}`).fontSize(12)
              .fontColor(d.isToday ? '#1890FF' : '#595959')
              .fontWeight(d.isToday ? FontWeight.Bold : FontWeight.Normal)
            Text(d.date).fontSize(10)
              .fontColor(d.isToday ? Color.White : '#BFBFBF')
              .padding({ left: 6, right: 6, top: 2, bottom: 2 })
              .backgroundColor(d.isToday ? '#1890FF' : 'transparent')
              .borderRadius(10).margin({ top: 2 })
          }.layoutWeight(1).alignItems(HorizontalAlign.Center).padding({ top: 6, bottom: 6 })
        })
      }.width('100%').backgroundColor(Color.White).border({ width: { bottom: 1 }, color: '#F0F0F0' })

      // 课程表格子
      Scroll() {
        Column() {
          ForEach([1, 2, 3, 4, 5, 6, 7, 8, 9, 10], (section: number) => {
            Row() {
              // 节次时间
              Column() {
                Text(`${section}`).fontSize(12).fontWeight(FontWeight.Bold).fontColor('#8C8C8C')
                Text(this.sections[section - 1]).fontSize(9).fontColor('#BFBFBF')
              }.width(36).height(56).alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)

              // 各天课程格子
              ForEach(this.days, (d: DaySchedule) => {
                if (this.shouldShowCourse(d.dayIndex, section)) {
                  Column() {
                    Text(this.getCourseAtSection(d.dayIndex, section)!.name).fontSize(11).fontWeight(FontWeight.Bold).fontColor(Color.White)
                      .textAlign(TextAlign.Center).maxLines(2)
                    Text(this.getCourseAtSection(d.dayIndex, section)!.classroom).fontSize(9).fontColor('rgba(255,255,255,0.9)').margin({ top: 2 })
                  }
                  .width('100%').height(this.getCourseHeight(d.dayIndex, section))
                  .backgroundColor(this.getCourseAtSection(d.dayIndex, section)!.color)
                  .borderRadius(4).padding(4)
                  .alignItems(HorizontalAlign.Center).justifyContent(FlexAlign.Center)
                  .layoutWeight(1).margin(1)
                  .onClick(() => { this.selectedCourse = this.getCourseAtSection(d.dayIndex, section) })
                } else if (this.shouldShowEmptyCell(d.dayIndex, section)) {
                  Column().layoutWeight(1).height(54).backgroundColor('transparent').margin(1)
                } else {
                  // 课程已占位
                  Column().layoutWeight(1).height(0).margin(1)
                }
              })
            }.width('100%').border({ width: { bottom: 1 }, color: '#F7F7F7' })
          })
        }.padding({ bottom: 20 })
      }.layoutWeight(1)

      // 课程详情弹层
      if (this.selectedCourse !== undefined) {
        Column() {
          Column() {
            Row() {
              Column() {
                Text('').width(20).height(20).backgroundColor(this.selectedCourse!.color).borderRadius(10)
              }.width(24)
              Text(this.selectedCourse!.name).fontSize(18).fontWeight(FontWeight.Bold).fontColor('#1A1A1A').layoutWeight(1).margin({ left: 8 })
              Text('✕').fontSize(20).fontColor('#8C8C8C').onClick(() => { this.selectedCourse = undefined })
            }.margin({ bottom: 16 })

            ForEach([
              { label: '授课教师', value: this.selectedCourse!.teacher, emoji: '👨‍🏫' },
              { label: '上课地点', value: this.selectedCourse!.classroom, emoji: '📍' },
              { label: '上课节次', value: `${this.selectedCourse!.startSection}-${this.selectedCourse!.endSection}`, emoji: '⏰' },
              { label: '上课周数', value: this.selectedCourse!.weeks, emoji: '📅' },
            ], (info: InfoData) => {
              Row() {
                Text(info.emoji).fontSize(16).width(24)
                Text(info.label).fontSize(13).fontColor('#8C8C8C').width(70)
                Text(info.value).fontSize(13).fontColor('#1A1A1A').layoutWeight(1)
              }.padding({ top: 10, bottom: 10 }).border({ width: { bottom: 1 }, color: '#F0F0F0' })
            })
          }
          .width('100%').padding(20).backgroundColor(Color.White)
          .borderRadius({ topLeft: 20, topRight: 20 })
        }
        .width('100%').position({ x: 0, y: 0 })
        .height('100%')
        .backgroundColor('rgba(0,0,0,0.4)')
        .justifyContent(FlexAlign.End)
        .onClick(() => { this.selectedCourse = undefined })
      }
    }
    .width('100%').height('100%').backgroundColor('#F5F5F5')
  }
}

Ink notes comparison diagram showing 'Before Refac

再补一句

这类案例最值得学的,其实不是某一个控件怎么写,而是页面组织方式。把状态放对、把交互函数收好、把布局压简单,后面你接正式项目时会轻松很多。

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐