暂无图片
暂无图片
暂无图片
暂无图片
暂无图片

87.[HarmonyOS NEXT 实战案例十八] 日历日程视图网格布局(进阶篇)

原创 若城 2025-06-07
134

[HarmonyOS NEXT 实战案例十八] 日历日程视图网格布局(进阶篇)

项目已开源,开源地址: https://gitcode.com/nutpi/HarmonyosNextCaseStudyTutorial , 欢迎fork & star

效果演示

1. 概述

在上一篇教程中,我们学习了如何使用HarmonyOS NEXT的GridRow和GridCol组件实现基本的日历日程视图网格布局。本篇教程将在此基础上,深入探讨如何优化和扩展日历日程视图,添加更多高级特性,使其更加实用和美观。

本教程将涵盖以下内容:

  • 响应式日历布局设计
  • 日期选择与高亮显示
  • 事件详情展示
  • 月份切换功能
  • 动画效果
  • 主题与样式定制
  • GridRow和GridCol的高级配置

2. 响应式日历布局设计

2.1 断点配置

为了使日历视图在不同屏幕尺寸下都能良好显示,我们需要设计响应式布局。首先,定义断点配置:

// 断点配置 private breakpoints: BreakPointType = { sm: 320, md: 600, lg: 840 }

这些断点将帮助我们根据屏幕宽度调整日历的布局。

2.2 自定义断点

我们可以使用MediaQuery组件监听屏幕尺寸变化,并根据不同断点调整布局:

@State currentBreakpoint: string = 'md' build() { Column() { GridRow() { GridCol({ span: { sm: 12, md: 12, lg: 12 } }) { // 日历内容 } } } .width('100%') .padding({ left: this.currentBreakpoint === 'sm' ? 8 : 16, right: this.currentBreakpoint === 'sm' ? 8 : 16, top: 16, bottom: 16 }) } aboutToAppear() { MediaQueryTool.queryByMediaFeature({ breakpoints: this.breakpoints }).then((breakpoint) => { this.currentBreakpoint = breakpoint }) }

2.3 日历网格的响应式布局

根据不同的断点,我们可以调整日期格子的大小和样式:

// 日期网格 GridRow({ columns: 7, gutter: this.currentBreakpoint === 'sm' ? 2 : 4 }) { ForEach(this.dates, (date: dateType) => { GridCol({ span: 1 }) { Column() { Text(date.date) .fontSize(this.currentBreakpoint === 'sm' ? 14 : 16) .textAlign(TextAlign.Center) if (date.hasEvent) { Circle() .width(this.currentBreakpoint === 'sm' ? 4 : 6) .height(this.currentBreakpoint === 'sm' ? 4 : 6) .fill('#FF5722') .margin({ top: 4 }) } } .padding(this.currentBreakpoint === 'sm' ? 4 : 8) .backgroundColor('#FFFFFF') .borderRadius(4) .height(this.currentBreakpoint === 'sm' ? 40 : 60) .justifyContent(FlexAlign.Center) } }) }

在小屏幕上,我们减小了字体大小、内边距、事件标记的大小和日期格子的高度,使日历在有限的空间内仍能清晰显示。

3. 日期选择与高亮显示

3.1 添加选中状态

首先,我们需要扩展dateType接口,添加选中状态:

interface dateType { date: string; hasEvent: boolean; isSelected: boolean; // 新增:是否被选中 }

然后,在数据初始化时设置默认值:

private dates: dateType[] = [ { date: '1', hasEvent: false, isSelected: false }, { date: '2', hasEvent: true, isSelected: false }, // ... 其他日期 ] // 当前选中的日期索引 @State selectedDateIndex: number = -1

3.2 实现日期选择功能

在日期格子上添加点击事件,实现日期选择功能:

Column() { Text(date.date) .fontSize(16) .textAlign(TextAlign.Center) .fontColor(date.isSelected ? '#FFFFFF' : '#000000') if (date.hasEvent) { Circle() .width(6) .height(6) .fill(date.isSelected ? '#FFFFFF' : '#FF5722') .margin({ top: 4 }) } } .padding(8) .backgroundColor(date.isSelected ? '#FF5722' : '#FFFFFF') .borderRadius(4) .height(60) .justifyContent(FlexAlign.Center) .onClick(() => { // 取消之前选中的日期 if (this.selectedDateIndex >= 0) { this.dates[this.selectedDateIndex].isSelected = false } // 设置当前选中的日期 const index = this.dates.indexOf(date) this.dates[index].isSelected = true this.selectedDateIndex = index // 如果有事件,显示事件详情 if (date.hasEvent) { this.showEventDetails(date) } })

选中的日期会使用不同的背景色和文本颜色,使其在视觉上更加突出。

4. 事件详情展示

4.1 扩展数据结构

首先,我们需要扩展dateType接口,添加事件详情:

interface EventDetail { title: string; time: string; location?: string; description?: string; } interface dateType { date: string; hasEvent: boolean; isSelected: boolean; events?: EventDetail[]; // 新增:事件详情数组 }

然后,在数据初始化时添加事件详情:

private dates: dateType[] = [ { date: '1', hasEvent: false, isSelected: false }, { date: '2', hasEvent: true, isSelected: false, events: [ { title: '团队会议', time: '10:00-11:30', location: '会议室A', description: '讨论项目进度' }, { title: '午餐', time: '12:00-13:00', location: '公司餐厅' } ]}, // ... 其他日期 ] // 当前显示的事件详情 @State currentEvents: EventDetail[] = [] @State showEvents: boolean = false

4.2 实现事件详情展示

创建一个方法来显示事件详情:

private showEventDetails(date: dateType) { if (date.events && date.events.length > 0) { this.currentEvents = date.events this.showEvents = true } else { this.currentEvents = [] this.showEvents = false } }

然后,在布局中添加事件详情区域:

// 事件详情区域 if (this.showEvents) { GridRow({ columns: 1 }) { GridCol({ span: 1 }) { Column() { Text('事件详情') .fontSize(16) .fontWeight(FontWeight.Bold) .margin({ top: 16, bottom: 8 }) ForEach(this.currentEvents, (event: EventDetail) => { Column() { Row() { Text(event.title) .fontSize(14) .fontWeight(FontWeight.Bold) Blank() Text(event.time) .fontSize(12) .fontColor('#666666') } .width('100%') .margin({ bottom: 4 }) if (event.location) { Row() { Text('地点:') .fontSize(12) .fontColor('#666666') Text(event.location) .fontSize(12) .fontColor('#666666') } .margin({ bottom: 4 }) } if (event.description) { Text(event.description) .fontSize(12) .fontColor('#666666') .margin({ bottom: 4 }) } } .padding(8) .backgroundColor('#F5F5F5') .borderRadius(4) .margin({ bottom: 8 }) .width('100%') }) } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(8) .margin({ top: 16 }) .width('100%') } } }

这个事件详情区域会显示选中日期的所有事件,包括标题、时间、地点和描述。

5. 月份切换功能

5.1 添加月份状态

首先,我们需要添加月份状态:

@State currentYear: number = 2023 @State currentMonth: number = 11

5.2 实现月份切换控件

在月份标题部分添加月份切换控件:

// 月份标题和切换控件 GridRow({ columns: 1 }) { GridCol({ span: 1 }) { Row() { Button({ type: ButtonType.Circle, stateEffect: true }) { Image($r('app.media.ic_arrow_left')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F5F5F5') .onClick(() => { this.previousMonth() }) Text(`${this.currentYear}${this.currentMonth}月`) .fontSize(18) .fontWeight(FontWeight.Bold) .margin({ left: 16, right: 16 }) Button({ type: ButtonType.Circle, stateEffect: true }) { Image($r('app.media.ic_arrow_right')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F5F5F5') .onClick(() => { this.nextMonth() }) } .width('100%') .justifyContent(FlexAlign.Center) .margin({ bottom: 16 }) } }

5.3 实现月份切换逻辑

创建月份切换的方法:

private previousMonth() { if (this.currentMonth > 1) { this.currentMonth-- } else { this.currentMonth = 12 this.currentYear-- } this.updateCalendarData() } private nextMonth() { if (this.currentMonth < 12) { this.currentMonth++ } else { this.currentMonth = 1 this.currentYear++ } this.updateCalendarData() } private updateCalendarData() { // 重置选中状态 this.selectedDateIndex = -1 this.showEvents = false // 根据年月更新日历数据 // 这里简化处理,实际应用中需要根据年月计算当月的日期和事件 // ... }

这些方法实现了月份的前进和后退,并在月份变化时更新日历数据。

6. 动画效果

6.1 日期选择动画

为日期选择添加动画效果,使交互更加流畅:

Column() { // 日期内容 } .padding(8) .backgroundColor(date.isSelected ? '#FF5722' : '#FFFFFF') .borderRadius(4) .height(60) .justifyContent(FlexAlign.Center) .animation({ duration: 250, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal }) .onClick(() => { // 日期选择逻辑 })

6.2 事件详情展示动画

为事件详情区域添加展开/收起动画:

// 事件详情区域 if (this.showEvents) { GridRow({ columns: 1 }) { GridCol({ span: 1 }) { Column() { // 事件详情内容 } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(8) .margin({ top: 16 }) .width('100%') .transition({ type: TransitionType.Insert, opacity: 0, scale: { x: 0.9, y: 0.9 } }) .transition({ type: TransitionType.Delete, opacity: 0, scale: { x: 0.9, y: 0.9 } }) } } }

这个动画使事件详情区域在显示时有淡入和缩放效果,在隐藏时有淡出和缩小效果。

7. 主题与样式定制

7.1 日历主题配置

创建一个主题配置对象,方便统一管理样式:

private calendarTheme = { primaryColor: '#FF5722', backgroundColor: '#FFFFFF', textColor: '#000000', secondaryTextColor: '#666666', borderRadius: 4, eventIndicatorSize: 6, dateItemHeight: 60 }

然后,在样式中使用这些主题变量:

.backgroundColor(date.isSelected ? this.calendarTheme.primaryColor : this.calendarTheme.backgroundColor) .borderRadius(this.calendarTheme.borderRadius) .height(this.calendarTheme.dateItemHeight)

7.2 日期格子样式变体

创建不同的日期格子样式变体,如今天、周末、非当月日期等:

private getDateItemStyle(date: dateType): Object { if (date.isToday) { return { backgroundColor: '#E3F2FD', borderColor: '#2196F3', borderWidth: 1 } } else if (date.isWeekend) { return { backgroundColor: '#FAFAFA' } } else if (!date.isCurrentMonth) { return { backgroundColor: '#F5F5F5', opacity: 0.6 } } else { return { backgroundColor: '#FFFFFF' } } }

然后,在日期格子中应用这些样式:

Column() { // 日期内容 } .padding(8) .stateStyles({ normal: this.getDateItemStyle(date), pressed: { backgroundColor: '#EEEEEE', scale: { x: 0.95, y: 0.95 } }, disabled: { opacity: 0.4 } }) .borderRadius(4) .height(60) .justifyContent(FlexAlign.Center)

8. GridRow和GridCol的高级配置

8.1 嵌套网格

在事件详情区域,我们可以使用嵌套的GridRow和GridCol来创建更复杂的布局:

GridRow({ columns: 1 }) { GridCol({ span: 1 }) { Column() { Text('事件详情') .fontSize(16) .fontWeight(FontWeight.Bold) .margin({ top: 16, bottom: 8 }) ForEach(this.currentEvents, (event: EventDetail) => { GridRow({ columns: 12, gutter: 8 }) { GridCol({ span: 8 }) { Column() { Text(event.title) .fontSize(14) .fontWeight(FontWeight.Bold) if (event.description) { Text(event.description) .fontSize(12) .fontColor('#666666') .margin({ top: 4 }) } } .alignItems(HorizontalAlign.Start) .width('100%') } GridCol({ span: 4 }) { Column() { Text(event.time) .fontSize(12) .fontColor('#666666') if (event.location) { Text(event.location) .fontSize(12) .fontColor('#666666') .margin({ top: 4 }) } } .alignItems(HorizontalAlign.End) .width('100%') } } .padding(8) .backgroundColor('#F5F5F5') .borderRadius(4) .margin({ bottom: 8 }) .width('100%') }) } .padding(16) .backgroundColor('#FFFFFF') .borderRadius(8) .margin({ top: 16 }) .width('100%') } }

这个嵌套网格使事件详情的布局更加灵活,标题和描述占据8列,时间和地点占据4列。

8.2 列偏移

在某些情况下,我们可能需要使用列偏移来创建特定的布局效果:

// 月份标题和切换控件 GridRow({ columns: 12 }) { GridCol({ span: 2, offset: 2 }) { Button({ type: ButtonType.Circle, stateEffect: true }) { Image($r('app.media.ic_arrow_left')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F5F5F5') .onClick(() => { this.previousMonth() }) } GridCol({ span: 4 }) { Text(`${this.currentYear}${this.currentMonth}月`) .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .width('100%') } GridCol({ span: 2 }) { Button({ type: ButtonType.Circle, stateEffect: true }) { Image($r('app.media.ic_arrow_right')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F5F5F5') .onClick(() => { this.nextMonth() }) } }

在这个例子中,我们使用12列的网格,并使用offset属性来调整按钮的位置,创建更加平衡的布局。

8.3 列顺序调整

GridCol组件的order属性允许我们调整列的显示顺序,这在响应式布局中特别有用:

GridRow({ columns: 12 }) { GridCol({ span: { sm: 12, md: 6, lg: 6 }, order: { sm: 2, md: 1, lg: 1 } }) { // 日历视图 } GridCol({ span: { sm: 12, md: 6, lg: 6 }, order: { sm: 1, md: 2, lg: 2 } }) { // 事件详情 } }

在这个例子中,在小屏幕上,事件详情会显示在日历视图之前;而在中等和大屏幕上,日历视图会显示在事件详情之前。这种灵活性使我们能够根据屏幕尺寸优化用户体验。

9. 完整优化代码

以下是日历日程视图网格布局的完整优化代码(部分示例):

// 日历日程视图网格布局(优化版) interface EventDetail { title: string; time: string; location?: string; description?: string; } interface dateType { date: string; hasEvent: boolean; isSelected: boolean; isToday?: boolean; isWeekend?: boolean; isCurrentMonth?: boolean; events?: EventDetail[]; } @Component export struct CalendarGridAdvanced { // 断点配置 private breakpoints: BreakPointType = { sm: 320, md: 600, lg: 840 } // 主题配置 private calendarTheme = { primaryColor: '#FF5722', backgroundColor: '#FFFFFF', textColor: '#000000', secondaryTextColor: '#666666', borderRadius: 4, eventIndicatorSize: 6, dateItemHeight: 60 } private days: string[] = ['日', '一', '二', '三', '四', '五', '六'] @State currentBreakpoint: string = 'md' @State currentYear: number = 2023 @State currentMonth: number = 11 @State selectedDateIndex: number = -1 @State showEvents: boolean = false @State currentEvents: EventDetail[] = [] @State dates: dateType[] = [ // 示例数据,实际应用中应根据年月动态生成 { date: '1', hasEvent: false, isSelected: false, isCurrentMonth: true, isWeekend: false }, { date: '2', hasEvent: true, isSelected: false, isCurrentMonth: true, isWeekend: false, events: [ { title: '团队会议', time: '10:00-11:30', location: '会议室A', description: '讨论项目进度' }, { title: '午餐', time: '12:00-13:00', location: '公司餐厅' } ]}, // ... 其他日期 ] // 月份切换方法 private previousMonth() { if (this.currentMonth > 1) { this.currentMonth-- } else { this.currentMonth = 12 this.currentYear-- } this.updateCalendarData() } private nextMonth() { if (this.currentMonth < 12) { this.currentMonth++ } else { this.currentMonth = 1 this.currentYear++ } this.updateCalendarData() } private updateCalendarData() { // 重置选中状态 this.selectedDateIndex = -1 this.showEvents = false // 根据年月更新日历数据 // 这里简化处理,实际应用中需要根据年月计算当月的日期和事件 // ... } // 显示事件详情 private showEventDetails(date: dateType) { if (date.events && date.events.length > 0) { this.currentEvents = date.events this.showEvents = true } else { this.currentEvents = [] this.showEvents = false } } // 获取日期格子样式 private getDateItemStyle(date: dateType): Object { if (date.isToday) { return { backgroundColor: '#E3F2FD', borderColor: '#2196F3', borderWidth: 1 } } else if (date.isWeekend) { return { backgroundColor: '#FAFAFA' } } else if (!date.isCurrentMonth) { return { backgroundColor: '#F5F5F5', opacity: 0.6 } } else { return { backgroundColor: '#FFFFFF' } } } aboutToAppear() { MediaQueryTool.queryByMediaFeature({ breakpoints: this.breakpoints }).then((breakpoint) => { this.currentBreakpoint = breakpoint }) } build() { Column() { // 月份标题和切换控件 GridRow({ columns: 12 }) { GridCol({ span: 2, offset: 2 }) { Button({ type: ButtonType.Circle, stateEffect: true }) { Image($r('app.media.ic_arrow_left')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F5F5F5') .onClick(() => { this.previousMonth() }) } GridCol({ span: 4 }) { Text(`${this.currentYear}${this.currentMonth}月`) .fontSize(18) .fontWeight(FontWeight.Bold) .textAlign(TextAlign.Center) .width('100%') } GridCol({ span: 2 }) { Button({ type: ButtonType.Circle, stateEffect: true }) { Image($r('app.media.ic_arrow_right')) .width(16) .height(16) } .width(32) .height(32) .backgroundColor('#F5F5F5') .onClick(() => { this.nextMonth() }) } } .margin({ bottom: 16 }) // 星期标题 GridRow({ columns: 7 }) { ForEach(this.days, (day: string) => { GridCol({ span: 1 }) { Text(day) .fontSize(this.currentBreakpoint === 'sm' ? 12 : 14) .textAlign(TextAlign.Center) .padding(8) } }) } // 日期网格 GridRow({ columns: 7, gutter: this.currentBreakpoint === 'sm' ? 2 : 4 }) { ForEach(this.dates, (date: dateType) => { GridCol({ span: 1 }) { Column() { Text(date.date) .fontSize(this.currentBreakpoint === 'sm' ? 14 : 16) .textAlign(TextAlign.Center) .fontColor(date.isSelected ? '#FFFFFF' : this.calendarTheme.textColor) if (date.hasEvent) { Circle() .width(this.currentBreakpoint === 'sm' ? 4 : this.calendarTheme.eventIndicatorSize) .height(this.currentBreakpoint === 'sm' ? 4 : this.calendarTheme.eventIndicatorSize) .fill(date.isSelected ? '#FFFFFF' : this.calendarTheme.primaryColor) .margin({ top: 4 }) } } .padding(this.currentBreakpoint === 'sm' ? 4 : 8) .stateStyles({ normal: this.getDateItemStyle(date), pressed: { backgroundColor: '#EEEEEE', scale: { x: 0.95, y: 0.95 } }, disabled: { opacity: 0.4 } }) .borderRadius(this.calendarTheme.borderRadius) .height(this.currentBreakpoint === 'sm' ? 40 : this.calendarTheme.dateItemHeight) .justifyContent(FlexAlign.Center) .animation({ duration: 250, curve: Curve.EaseInOut, iterations: 1, playMode: PlayMode.Normal }) .onClick(() => { // 取消之前选中的日期 if (this.selectedDateIndex >= 0) { this.dates[this.selectedDateIndex].isSelected = false } // 设置当前选中的日期 const index = this.dates.indexOf(date) this.dates[index].isSelected = true this.selectedDateIndex = index // 如果有事件,显示事件详情 if (date.hasEvent) { this.showEventDetails(date) } else { this.showEvents = false } }) } }) } // 事件详情区域 if (this.showEvents) { GridRow({ columns: 1 }) { GridCol({ span: 1 }) { Column() { Text('事件详情') .fontSize(16) .fontWeight(FontWeight.Bold) .margin({ bottom: 8 }) ForEach(this.currentEvents, (event: EventDetail) => { GridRow({ columns: 12, gutter: 8 }) { GridCol({ span: 8 }) { Column() { Text(event.title) .fontSize(14) .fontWeight(FontWeight.Bold) if (event.description) { Text(event.description) .fontSize(12) .fontColor(this.calendarTheme.secondaryTextColor) .margin({ top: 4 }) } } .alignItems(HorizontalAlign.Start) .width('100%') } GridCol({ span: 4 }) { Column() { Text(event.time) .fontSize(12) .fontColor(this.calendarTheme.secondaryTextColor) if (event.location) { Text(event.location) .fontSize(12) .fontColor(this.calendarTheme.secondaryTextColor) .margin({ top: 4 }) } } .alignItems(HorizontalAlign.End) .width('100%') } } .padding(8) .backgroundColor('#F5F5F5') .borderRadius(this.calendarTheme.borderRadius) .margin({ bottom: 8 }) .width('100%') }) } .padding(16) .backgroundColor(this.calendarTheme.backgroundColor) .borderRadius(8) .margin({ top: 16 }) .width('100%') .transition({ type: TransitionType.Insert, opacity: 0, scale: { x: 0.9, y: 0.9 } }) .transition({ type: TransitionType.Delete, opacity: 0, scale: { x: 0.9, y: 0.9 } }) } } } } .width('100%') .padding({ left: this.currentBreakpoint === 'sm' ? 8 : 16, right: this.currentBreakpoint === 'sm' ? 8 : 16, top: 16, bottom: 16 }) } }

10. 响应式布局最佳实践

在实现日历日程视图的响应式布局时,我们可以遵循以下最佳实践:

10.1 使用断点系统

定义清晰的断点系统,并根据不同断点调整布局和样式:

private breakpoints: BreakPointType = { sm: 320, // 小屏幕(手机) md: 600, // 中等屏幕(平板竖屏) lg: 840 // 大屏幕(平板横屏、桌面) }

10.2 调整内容密度

根据屏幕尺寸调整内容密度,在小屏幕上减少内边距和元素大小,在大屏幕上增加内边距和元素大小:

.padding(this.currentBreakpoint === 'sm' ? 4 : 8) .height(this.currentBreakpoint === 'sm' ? 40 : 60)

10.3 使用相对单位

尽量使用相对单位(如百分比)而不是固定单位,使布局能够适应不同屏幕尺寸:

.width('100%')

10.4 调整列布局

根据屏幕尺寸调整GridCol的span属性,在小屏幕上使用更多的列宽,在大屏幕上使用更少的列宽:

GridCol({ span: { sm: 12, md: 6, lg: 4 } })

10.5 使用order属性调整内容顺序

使用GridCol的order属性根据屏幕尺寸调整内容顺序,优化不同设备上的用户体验:

GridCol({ span: { sm: 12, md: 6 }, order: { sm: 2, md: 1 } })

11. 总结

在本教程中,我们深入探讨了如何优化和扩展日历日程视图的网格布局,添加了多种高级特性,使其更加实用和美观。

主要内容包括:

  • 响应式日历布局设计,使日历在不同屏幕尺寸下都能良好显示
  • 日期选择与高亮显示,提升用户交互体验
  • 事件详情展示,显示选中日期的事件信息
  • 月份切换功能,允许用户浏览不同月份的日历
  • 动画效果,使界面更加生动
  • 主题与样式定制,使日历视觉效果更加丰富
  • GridRow和GridCol的高级配置,如嵌套网格、列偏移和列顺序调整

通过这些优化和扩展,我们的日历日程视图不仅功能更加完善,而且在不同设备上都能提供良好的用户体验。这个示例展示了HarmonyOS NEXT的GridRow和GridCol组件在创建复杂、响应式UI时的强大功能。

「喜欢这篇文章,您的关注和赞赏是给作者最好的鼓励」
关注作者
【版权声明】本文为墨天轮用户原创内容,转载时必须标注文章的来源(墨天轮),文章链接,文章作者等基本信息,否则作者和墨天轮有权追究责任。如果您发现墨天轮中有涉嫌抄袭或者侵权的内容,欢迎发送邮件至:contact@modb.pro进行举报,并提供相关证据,一经查实,墨天轮将立刻删除相关内容。

评论