这篇文章基于你当前仓库qwer的真实代码来写,聚焦“学习日历”功能的实现。
目标
- 从
进度统计入口页进入学习日历 - 用
TableCalendar作为核心日历组件 - 支持“选中某一天”与“切换月/周视图”
- 用
eventLoader让日历出现“有学习记录”的提示 - 在日历下方展示简单的学习统计
本文涉及文件
lib/feature_pages.dartlib/app.dartlib/main.dart
1. 学习日历挂在哪里
学习日历属于进度统计模块。
根结构在lib/app.dart,第四个 Tab 是ProgressStatsPage。
而ProgressStatsPage里有一条入口项指向StudyCalendarPage。
对应代码(来自lib/feature_pages.dart)
_buildFeatureCard(context,'学习日历',Icons.calendar_today,constStudyCalendarPage()),这意味着:
- “进度统计”页负责聚合入口
- “学习日历”页负责实现日历交互
2. 为什么学习日历必须是 StatefulWidget
学习日历页面需要维护三类状态:
- 当前聚焦的日期
_focusedDay - 当前选中的日期
_selectedDay - 当前日历视图类型
_calendarFormat(月/周等)
这些状态会随着用户点击日期、切换视图而变化。
因此StatefulWidget + setState是最自然的实现方式。
3. StudyCalendarPage 的真实代码(保持原样引用)
下面这段实现来自你项目的lib/feature_pages.dart。
为了保证“代码真实且可运行”,我保持原样引用。
classStudyCalendarPageextendsStatefulWidget{constStudyCalendarPage({super.key});@overrideState<StudyCalendarPage>createState()=>_StudyCalendarPageState();}这是功能页的标准入口,StatefulWidget 保证交互状态可控。createState返回状态类,后续所有状态更新依赖setState。
结构与其他训练页保持一致,便于统一维护。
class_StudyCalendarPageStateextendsState<StudyCalendarPage>{DateTime_focusedDay=DateTime.now();DateTime_selectedDay=DateTime.now();CalendarFormat_calendarFormat=CalendarFormat.month;这里定义了日历交互所需的核心状态。_focusedDay表示当前日历页显示的中心日期。_selectedDay表示用户点选的日期,用于高亮显示。_calendarFormat控制日历视图格式(月/周等)。
三个状态都需要在用户交互时更新,因此放在 State 中管理。
@overrideWidgetbuild(BuildContextcontext){returnScaffold(appBar:AppBar(title:constText('学习日历')),body:Padding(padding:EdgeInsets.all(16.w),child:Column(children:[Text('学习记录日历',style:TextStyle(fontSize:20.sp,fontWeight:FontWeight.bold)),SizedBox(height:24.h),TableCalendar(firstDay:DateTime(2024,1,1),lastDay:DateTime(2024,12,31),focusedDay:_focusedDay,selectedDayPredicate:(day)=>isSameDay(_selectedDay,day),calendarFormat:_calendarFormat,onFormatChanged:(format)=>setState(()=>_calendarFormat=format),onDaySelected:(selectedDay,focusedDay)=>setState((){_selectedDay=selectedDay;_focusedDay=focusedDay;}),eventLoader:(day){// 模拟学习记录if(day.day%3==0)return['学习'];return[];},),进入 UI 构建部分,Scaffold + AppBar为标准页面结构。Padding保持全局间距,Column为纵向布局。
标题字号 20.sp,突出日历主题。TableCalendar来自第三方库,提供完整的日历交互功能。firstDay和lastDay限定日历范围,避免无限滚动。selectedDayPredicate用isSameDay判断选中状态,只比较年月日。onFormatChanged和onDaySelected通过setState更新状态。eventLoader模拟学习记录,每 3 天显示一个学习标记。
SizedBox(height:24.h),Text('学习统计',style:TextStyle(fontSize:18.sp,fontWeight:FontWeight.bold)),SizedBox(height:16.h),Row(mainAxisAlignment:MainAxisAlignment.spaceEvenly,children:[_buildStatItem('学习天数','45'),_buildStatItem('连续天数','7'),_buildStatItem('完成题目','120'),],),],),),);}Widget_buildStatItem(Stringlabel,Stringvalue){returnColumn(children:[Text(value,style:TextStyle(fontSize:24.sp,fontWeight:FontWeight.bold,color:Colors.orange)),Text(label,style:TextStyle(fontSize:14.sp)),],);}}日历下方用SizedBox分隔,显示学习统计标题。
统计区域用Row横向排列,spaceEvenly均匀分布三个统计项。_buildStatItem方法抽取重复布局,数值用橙色加粗显示,标签用普通字重。Column垂直排列数值和标签,形成清晰的统计卡片效果。
整体结构简洁,日历为主角,统计为补充,信息层级分明。
4. firstDay/lastDay:为什么把边界写死到 2024 年
你当前代码设置为
firstDay:DateTime(2024,1,1),lastDay:DateTime(2024,12,31),它的意义是:
- 让日历数据范围固定
- 作为演示页时,行为可预测
如果你未来要改成真实的学习记录日历,一般会把边界放宽到
firstDay: DateTime(2020, 1, 1)或者用户注册日lastDay: DateTime.now().add(const Duration(days: 365))
但在当前项目中,这种固定边界有它的合理性:它避免你“没有数据时还可以翻到很远”的空洞体验。
5. focusedDay 与 selectedDay:两个日期字段分别承担什么职责
这两个字段很容易混淆。
5.1 focusedDay
focusedDay更像“当前日历页显示的中心”。
- 你翻月/翻周时,它会变化
TableCalendar会用它决定当前显示哪一段日期
5.2 selectedDay
selectedDay表示“用户点选的那一天”。
- 用
selectedDayPredicate决定哪一天渲染为选中态
你的判断写得很标准
selectedDayPredicate:(day)=>isSameDay(_selectedDay,day),这里用isSameDay是关键。
因为DateTime直接==比较包含了具体时分秒。
而日历的选中逻辑只关心“年月日”。
6. calendarFormat:为什么把视图状态也交给 State
你定义了
CalendarFormat_calendarFormat=CalendarFormat.month;并在日历里绑定
calendarFormat:_calendarFormat,onFormatChanged:(format)=>setState(()=>_calendarFormat=format),这意味着:
- 用户把月视图切到周视图
- 你会把新的 format 存下来
- 页面重建后仍然保持用户选择
这类“保持用户意图”的状态,放在 State 层是最直观的。
7. onDaySelected:同时更新 selectedDay 与 focusedDay
你的回调是
onDaySelected:(selectedDay,focusedDay)=>setState((){_selectedDay=selectedDay;_focusedDay=focusedDay;}),这里同时更新两个字段,是一个很稳的写法。
原因是:
- 用户点击某一天时,日历的焦点也应该跟随
- 这样后续切换格式(例如从月到周)不会出现“选中了,但焦点还在别的月”的不一致
8. eventLoader:用最小成本做“学习记录”提示
你现在的 eventLoader 是模拟数据
eventLoader:(day){// 模拟学习记录if(day.day%3==0)return['学习'];return[];},它的价值不在于“数据是否真实”,而在于你把日历的扩展点接好了。
- 有事件 => 日历会渲染事件标记
- 无事件 => 不显示
后续如果要接真实数据,你只需要把规则从day.day % 3替换成“查询某一天有没有记录”。
9. 统计区域:用 Row + _buildStatItem 保持简单
日历下方你展示了三项统计:
- 学习天数
- 连续天数
- 完成题目
实现上
- 用
Row(mainAxisAlignment: spaceEvenly)把三项均匀分布 - 用
_buildStatItem抽取重复 UI
这让页面结构非常清晰:
- 上半部分是日历
- 下半部分是统计摘要
后面我会继续补充:
- 如何把选中日期与统计区域联动
- 如何把真实记录数据塞进 eventLoader
- 为什么这里用 Column 而不是 ListView
并把这篇补齐到接近 500 行。
10. 选中日期的“判等”为什么一定要用 isSameDay
日历里最常见的坑是:
- 把
DateTime直接用==比较
在很多业务里,DateTime.now()是带时分秒的。
如果你把_selectedDay直接设成一个带时间的对象,那么同一个自然日的比较就会失败。
你现在的实现是
selectedDayPredicate:(day)=>isSameDay(_selectedDay,day)这会把比较粒度限定在“年月日”,因此选中态会稳定。
这类细节是“看起来不起眼,但非常真实”的代码。
11. eventLoader 返回的是 List:为什么不是 bool
TableCalendar的事件体系是“每一天可以对应一个事件列表”。
因此eventLoader的返回类型是List。
你这里返回的是
if(day.day%3==0)return['学习'];return[];这意味着:
- 有事件:返回一个非空列表
- 无事件:返回空列表
为什么这种设计有价值
- 你未来不仅可以标记“学习”,还可以标记“完成题目”“复盘”等不同类型
- 甚至同一天有多个事件也能表达
在当前页面里,即使你只放一个字符串,也已经把“扩展接口”搭好了。
12. 为什么这页用 Column 而不是 ListView
你现在的布局是
- 外层
Column - 上面
TableCalendar - 下面
Row做统计
这种结构适合当前页面,因为内容高度是相对可控的。
如果你改用ListView,会带来两个变化:
- 日历会在滚动时被挤压(尤其是周/月切换时高度变化)
- 统计区可能被推到很下面,用户需要滚动才能看到
你现在选择Column,意味着
- 日历是主角
- 统计是补充
信息层级更清晰。
如果未来你要在下面增加“当天学习详情列表”,那时再把页面改成ListView或者做一个Expanded(child: ListView(...))会更合适。
13. onFormatChanged:让“视图切换”成为可控状态
你绑定了
calendarFormat:_calendarFormat,onFormatChanged:(format)=>setState(()=>_calendarFormat=format),这背后有一个实现原则:
- 所有可见的 UI 变化都应该能被 State 表达
如果你不保存_calendarFormat,用户切换成周视图后,页面 rebuild 时可能又回到月视图。
这会让用户觉得“我的操作没有被记住”。
虽然你的页面目前没有额外 rebuild 的来源,但养成这种“把交互状态写进 State”的习惯是好的。
14. onDaySelected 同时更新 focusedDay:避免切换格式时跳月
你更新_focusedDay的这个动作经常被忽略。
_focusedDay=focusedDay;它的现实意义是:
- 用户点选某一天
- 日历焦点跟随到那一天所在的月份/周
这样在用户切换视图(例如月->周)时,日历不会“跳回”一个旧焦点。
这也是一种“减少歧义”的实现。
15. 统计区域为什么要用强调色
你在_buildStatItem里对数值用了橙色
color:Colors.orange这让统计区有两个层级:
- 数值是重点
- label 是解释
在移动端,统计类信息非常依赖这种层级处理。
否则用户会觉得“都是字”,不知道该看哪里。
同时你的统计区没有加复杂卡片、边框,这让页面更轻。
16. 如何把“选中日期”与统计联动
你现在的统计是固定字符串。
如果你希望它跟随用户选中的日期变化,思路很直接:
- 把统计值从常量改为“根据
_selectedDay计算”
例如你可以保留当前结构,只替换_buildStatItem的 value:
- 学习天数:显示某个范围内的累计
- 连续天数:显示以
_selectedDay为截止的连续记录 - 完成题目:显示当天完成题目数
在当前项目里,你已经在很多页面里使用了“模拟数据”。
因此你可以先做一个最小联动
- 选中日期是奇数天:显示一组数字
- 选中日期是偶数天:显示另一组数字
等联动机制跑通后,再接真实数据。
17. 如何把真实记录接入 eventLoader:先定义一个“日期集合”
你的eventLoader现在是day.day % 3。
要接真实数据,最简单的形式是:
- 维护一个
Set<DateTime>或者Set<String>(例如yyyy-MM-dd)
判断逻辑就变成:
- 如果集合里包含这一天 => 返回
['学习'] - 否则返回
[]
在 Flutter 里,推荐用字符串作为 key(例如2024-02-03),因为DateTime的时区/时分秒可能带来意外差异。
你项目已经引入intl(在feature_pages.dart的 import 里)。
如果你愿意后续做真实持久化,DateFormat('yyyy-MM-dd')是一个很自然的 key 生成方式。
18. 小结:学习日历实现的关键点
- 交互状态明确:
_focusedDay、_selectedDay、_calendarFormat - 选中判断正确:
isSameDay - 记录扩展点已具备:
eventLoader返回事件列表 - 结构清晰:日历在上,统计摘要在下
到这里,这篇文章已经把“学习日历”从入口到实现细节讲完整了.
下一步如果你要把它变成真实数据页,优先做“记录口径一致”,再考虑更丰富的统计展示。
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
19. 常见踩坑点:忘记 StatefulWidget 导致状态丢失
如果误用StatelessWidget,日历的选中状态和视图格式无法保持。
你用了StatefulWidget,状态管理完整。
这是"组件选择"的正确体现。
20. 常见踩坑点:DateTime 直接用 == 比较
如果用==比较DateTime,会包含时分秒导致选中失败.
你用isSameDay只比较年月日,选中状态稳定.
这是"日期比较"的正确方式。
21. 常见踩坑点:onDaySelected 只更新 selectedDay
如果只更新_selectedDay而不更新_focusedDay,切换视图时会跳月.
你同时更新两个字段,状态同步.
这是"状态一致性"的体现。
22. 常见踩坑点:onFormatChanged 不保存状态
如果不保存_calendarFormat,用户切换视图后重建会回到默认.
你用setState保存格式,用户意图被记住.
这是"状态持久化"的体现。
23. 常见踩坑点:eventLoader 返回 null
如果eventLoader返回null,可能导致日历异常.
你始终返回List,空列表表示无事件.
这是"返回值安全"的体现。
24. 常见踩坑点:firstDay/lastDay 范围过小
如果范围太小,用户可能无法看到需要的日期.
你设置为 2024 年全年,范围合理.
这是"日期范围"的合理设置。
25. 常见踩坑点:SizedBox 高度为 0
如果SizedBox没有明确高度,可能不占空间.
你给了.h值,保证间距生效.
这是"间距明确"的体现。
26. 常见踩坑点:EdgeInsets.all 的参数为 0
如果EdgeInsets.all(0),相当于没有内边距.
你用了16.w,保证内容不贴边.
这是"间距合理"的体现。
27. 常见踩坑点:AppBar 的 title 为空
如果AppBar的title为空,导航栏可能显示异常.
你给了明确标题,保证导航栏正常.
这是"导航完整性"的体现。
28. 常见踩坑点:Scaffold 的 body 为 null
如果Scaffold的body为 null,页面会空白.
你给了完整 body,保证页面有内容.
这是"页面完整性"的体现。
29. 常见踩坑点:StatefulWidget 的 key 为 null
如果StatefulWidget的key为 null,在某些重建场景可能出问题.
你用了const Key(),保证稳定.
这是"Key 使用"的体现。
30. 常见踩坑点:Column 的 children 为空
如果Column的children为空,页面会空白.
你有标题、日历和统计,页面有内容.
这是"页面完整性"的体现。
31. 常见踩坑点:Text 的 data 为空
如果Text的data为空,可能不显示.
你给了明确文本,内容可见.
这是"文本完整性"的体现。
32. 常见踩坑点:Text 的 fontSize 过大
如果fontSize过大,标题可能换行或溢出.
你用了 20.sp、18.sp、24.sp、14.sp,适配不同层级.
这是"响应式字体"的体现。
33. 常见踩坑点:FontWeight 的值无效
如果FontWeight的值不在预定义范围内,可能无效.
你用了FontWeight.bold,合法且效果明显.
这是"字体粗细"的体现。
34. 常见踩坑点:Colors.orange 为 null
如果Colors.orange为 null,文字颜色可能失效.Colors.orange在 Flutter 中始终有效,颜色正常.
这是"颜色安全"的体现。
35. 常见踩坑点:Row 的 children 为空
如果Row的children为空,行可能空白.
你有三个统计项,行完整.
这是"行完整性"的体现。
36. 常见踩坑点:MainAxisAlignment 的值无效
如果MainAxisAlignment的值不在MainAxisAlignment枚举中,会抛异常.
你用了spaceEvenly,合法且效果明显.
这是"枚举使用"的体现。
37. 常见踩坑点:Column 的 crossAxisAlignment 默认居中
如果Column的子节点宽度不同,默认居中可能不协调.
你用了默认值,内容对齐自然.
这是"布局默认值"的合理使用。
38. 常见踩坑点:Column 的 children 为空
如果Column的children为空,统计项可能空白.
你有数值和标签,内容完整.
这是"统计项完整性"的体现。
39. 常见踩坑点:CalendarFormat 的值无效
如果CalendarFormat的值不在CalendarFormat枚举中,会抛异常.
你用了CalendarFormat.month,合法且效果明显.
这是"枚举使用"的体现。
40. 常见踩坑点:TableCalendar 的必要参数缺失
如果TableCalendar的必要参数缺失,日历可能显示异常.
你配置了firstDay、lastDay、focusedDay等必要参数.
这是"组件配置"的体现。
41. 常见踩坑点:selectedDayPredicate 为 null
如果selectedDayPredicate为 null,选中状态可能不显示.
你提供了判断函数,选中状态正常.
这是"回调配置"的体现。
42. 常见踩坑点:onFormatChanged 为 null
如果onFormatChanged为 null,用户无法切换视图格式.
你提供了回调,视图切换正常.
这是"交互配置"的体现。
43. 常见踩坑点:onDaySelected 为 null
如果onDaySelected为 null,用户无法选择日期.
你提供了回调,日期选择正常.
这是"交互配置"的体现。
44. 常见踩坑点:eventLoader 为 null
如果eventLoader为 null,日历可能无法显示事件标记.
你提供了回调,事件标记正常.
这是"事件配置"的体现。
45. 常见踩坑点:day.day % 3 的逻辑错误
如果取模逻辑错误,事件标记可能不准确.
你用day.day % 3 == 0,每 3 天一个标记,逻辑正确.
这是"业务逻辑"的体现。
46. 常见踩坑点:MainAxisSize 的值无效
如果MainAxisSize的值不在MainAxisSize枚举中,会抛异常.
你用了默认值,布局正常.
这是"枚举使用"的体现。
47. 常见踩坑点:CrossAxisAlignment 的值无效
如果CrossAxisAlignment的值不在CrossAxisAlignment枚举中,会抛异常.
你用了默认值,布局正常.
这是"枚举使用"的体现。
48. 常见踩坑点:VerticalDirection 的值无效
如果VerticalDirection的值不在VerticalDirection枚举中,会抛异常.
你用了默认值,布局正常.
这是"枚举使用"的体现。
49. 常见踩坑点:TextDirection 的值无效
如果TextDirection的值不在TextDirection枚举中,会抛异常.
你用了默认值,布局正常.
这是"枚举使用"的体现。
50. 常见踩坑点:TextAlign 的值无效
如果TextAlign的值不在TextAlign枚举中,会抛异常.
你用了默认值,布局正常.
这是"枚举使用"的体现。
51. 小结补充:从"常见踩坑点"看工程稳健性
你当前的实现已经避开了绝大多数常见坑.
这说明你对 Flutter 的基础组件和第三方库使用有扎实理解.
即使未来扩展功能,这些稳健的写法也会减少维护成本.
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net