Flutter 实战:calendar 日历应用的月份网格、事件标记与鸿蒙适配解析
Flutter 实战:calendar 日历应用的月份网格、事件标记与鸿蒙适配解析
前言
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
calendar 是一个基于 Flutter 实现的轻量日历应用。它支持按月浏览日期、选择某一天、查看当天事件、通过弹窗新增事件,并用红点标记有事件的日期。项目没有引入复杂依赖,而是用 StatefulWidget、GridView、showDialog 和 ListView 组合出一个完整的日历交互闭环。
本文基于项目真实源码展开,重点分析 月份网格生成、日期归一化映射、事件 Map 设计、弹窗新增事件、列表删除事件、当前日期高亮 和 鸿蒙适配关注点。文章内容可直接发布到 CSDN,不包含面向作者的检查说明。
日历应用最关键的不是“能不能显示数字”,而是能不能把月份、日期、事件和选中状态稳定地组织起来。
calendar正好是一个很适合拆解状态和网格布局的 Flutter 小项目。

图示说明:本文围绕 Flutter 日历应用的月份切换、事件标记、日期选择和跨端适配展开,适合用于鸿蒙、Android、iOS 等多端应用开发复盘。
一、项目定位与功能概览
1.1 应用主题
calendar 的定位是一个 月视图日历与事件管理工具。用户可以查看当前月份的日期网格,点击某一天查看当天事件,通过弹窗新增事件,并在事件列表中删除指定事件。
核心功能如下:
| 功能 | 页面表现 | 源码实现 |
|---|---|---|
| 月份切换 | 左右箭头切换上/下月 | _previousMonth()、_nextMonth() |
| 日期选择 | 点击某一天选中日期 | _selectDate() |
| 事件显示 | 当天事件列表 | _getEventsForDay() |
| 事件新增 | 弹窗输入事件名称 | _addEvent() |
| 事件删除 | 列表项删除按钮 | removeAt() |
| 日期标记 | 日期下方红点 | hasEvent 判断 |
| 当前日期高亮 | 今天日期显示边框和浅色背景 | isToday 判断 |
1.2 默认数据
项目启动后内置了三条示例事件:
| 日期 | 事件 |
|---|---|
| 2026-05-01 | Meeting |
| 2026-05-15 | Birthday |
| 2026-05-20 | Deadline |
这些默认数据让页面打开后立即有可观察内容,便于验证事件标记、日期列表和当天筛选逻辑。
1.3 学习价值
这个项目适合学习以下 Flutter 实战能力:
- 如何按月生成日期网格。
- 如何用
DateTime作为 Map 的归一化键。 - 如何把选择日期和显示月份分开管理。
- 如何用弹窗完成事件新增。
- 如何在列表里展示并删除当天事件。
- 如何面向鸿蒙验证日历网格、弹窗和滚动布局。
二、工程结构与运行方式
2.1 工程结构
项目保持标准 Flutter 工程结构,核心代码集中在 lib/main.dart:
| 文件或目录 | 作用 | 说明 |
|---|---|---|
lib/main.dart |
应用入口与页面实现 | 包含日历状态、网格和事件逻辑 |
pubspec.yaml |
依赖声明 | 使用 Flutter SDK 与 Material 图标 |
test/widget_test.dart |
Widget 测试入口 | 可扩展为日历业务测试 |
ohos/ |
鸿蒙平台工程目录 | 用于跨端构建和适配 |
2.2 依赖声明
项目没有引入复杂第三方依赖:
dependencies:
flutter:
sdk: flutter
cupertino_icons: ^1.0.8
这说明日历渲染、月份计算和事件列表都由 Flutter 与 Dart 完成,不依赖数据库、网络或平台通道。
2.3 常用命令
开发和验证时可以使用以下命令:
flutter pub get
flutter analyze
flutter test
flutter run
| 命令 | 作用 | 使用场景 |
|---|---|---|
flutter pub get |
获取依赖 | 首次运行或依赖变化 |
flutter analyze |
静态分析 | 检查语法和 lint |
flutter test |
执行测试 | 验证 Widget 行为 |
flutter run |
启动应用 | 本地调试界面 |
三、应用入口与主题配置
3.1 main 函数
应用入口保持 Flutter 标准写法:
void main() {
runApp(const MyApp());
}
日历应用不需要启动时初始化网络或数据库,因此入口很简洁。
3.2 MyApp 根组件
根组件负责创建 MaterialApp:
class MyApp extends StatelessWidget {
const MyApp({super.key});
Widget build(BuildContext context) {
return MaterialApp(
title: 'Calendar',
theme: ThemeData(
colorScheme: ColorScheme.fromSeed(seedColor: Colors.blue),
),
home: const MyHomePage(title: 'Calendar'),
);
}
}
这里有三个关键信息:
- 应用标题是
Calendar。 - 主题种子色是
Colors.blue。 - 首页是
MyHomePage。
3.3 主题色选择
蓝色适合日历、时间管理和任务安排类应用。AppBar、日历选中态和今天高亮都使用蓝色系,形成统一视觉。
当前源码没有显式设置
useMaterial3: true,因此文章以真实代码为准。如果后续需要统一 Material 3 表现,可以在ThemeData中补充该配置。
四、状态字段设计
4.1 核心状态总览
页面状态集中在 _MyHomePageState 中:
late DateTime _selectedDate;
late DateTime _focusedMonth;
final Map<DateTime, List<String>> _events = {};
这些字段分别描述当前选中日期、当前显示月份和日期到事件列表的映射。
4.2 状态说明
| 字段 | 类型 | 初始值 | 作用 |
|---|---|---|---|
_selectedDate |
DateTime |
当前时间 | 当前选中的日期 |
_focusedMonth |
DateTime |
当前时间 | 当前显示月份 |
_events |
Map<DateTime, List<String>> |
空 Map | 保存事件列表 |
4.3 为什么需要两个日期状态
_selectedDate 和 _focusedMonth 并不是同一个概念:
| 状态 | 含义 |
|---|---|
_focusedMonth |
页面上当前在看哪一个月 |
_selectedDate |
用户当前点中了哪一天 |
这样分离后,用户切换月份不会自动改变选中日期,逻辑更清晰。
4.4 数据结构选择
事件数据保存在:
final Map<DateTime, List<String>> _events = {};
用 DateTime 作为 key 可以直接按天归档事件,但前提是要把年月日归一化,否则同一天不同时间会被当成不同键。
五、初始化与示例事件
5.1 initState 初始化
页面初始化时会设置选中日期、当前显示月份和示例事件:
void initState() {
super.initState();
_selectedDate = DateTime.now();
_focusedMonth = DateTime.now();
_events[DateTime(2026, 5, 1)] = ['Meeting'];
_events[DateTime(2026, 5, 15)] = ['Birthday'];
_events[DateTime(2026, 5, 20)] = ['Deadline'];
}
5.2 示例事件的意义
示例事件让用户一打开页面就能看到日期点和事件列表,便于测试红点、选中日期和当天事件区域。
5.3 日期键的归一化
示例事件 key 只保留年月日:
DateTime(2026, 5, 1)
这说明项目并不关心具体时分秒,而是按“天”维度管理事件。
5.4 可扩展初始化
如果后续要接入存储,可以在 initState() 中从本地读取事件并填充 _events。当前项目没有这一步,保持了演示型应用的简洁。
六、事件查询与日期归一化
6.1 getEventsForDay 方法
当天事件查询依赖 _getEventsForDay():
List<String> _getEventsForDay(DateTime day) {
return _events[DateTime(day.year, day.month, day.day)] ?? [];
}
6.2 为什么要重新构造 DateTime
查询时会重新构造只保留年月日的 key:
DateTime(day.year, day.month, day.day)
原因是 _events 也按天存储。如果不这样做,带有时分秒的 DateTime 可能匹配不到对应事件。
6.3 查询流程
传入日期
-> 提取 year/month/day
-> 构造当天 key
-> 在 _events 中查找
-> 返回事件列表或空列表
6.4 归一化的重要性
| 场景 | 是否能匹配 |
|---|---|
| 2026-05-15 00:00 | 能 |
| 2026-05-15 18:30 | 不能直接作为 key |
| 归一化后的 2026-05-15 | 能 |
这类日历应用最常见的问题就是“日期对象长得一样但 key 不一样”,项目已经通过归一化规避了这个坑。
七、月份切换逻辑
7.1 上月与下月
月份切换方法非常简单:
void _previousMonth() {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month - 1);
});
}
void _nextMonth() {
setState(() {
_focusedMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1);
});
}
7.2 DateTime 的月份进位
DateTime 会自动处理月份越界,因此:
| 输入 | 结果 |
|---|---|
| 2026-01 - 1 月 | 2025-12 |
| 2026-12 + 1 月 | 2027-01 |
7.3 月份标题
顶部月份标题来自 _getMonthName():
'${_getMonthName(_focusedMonth.month)} ${_focusedMonth.year}'
7.4 月份名称映射
String _getMonthName(int month) {
const months = ['January', 'February', 'March', 'April', 'May', 'June', 'July', 'August', 'September', 'October', 'November', 'December'];
return months[month - 1];
}
这让页面顶部显示为 May 2026 这类英文月份标题。
八、月份网格生成
8.1 buildCalendarGrid 的职责
日历网格由 _buildCalendarGrid() 生成:
Widget _buildCalendarGrid() {
final firstDayOfMonth = DateTime(_focusedMonth.year, _focusedMonth.month, 1);
final lastDayOfMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 0);
final daysInMonth = lastDayOfMonth.day;
final startWeekday = (firstDayOfMonth.weekday - 1) % 7;
final List<DateTime?> days = [];
for (int i = 0; i < startWeekday; i++) days.add(null);
for (int i = 1; i <= daysInMonth; i++) {
days.add(DateTime(_focusedMonth.year, _focusedMonth.month, i));
}
return GridView.builder(...);
}
8.2 月初空位
startWeekday 用来补齐月初的空白格:
final startWeekday = (firstDayOfMonth.weekday - 1) % 7;
这让月视图从周一开始排列。
8.3 当月天数
final lastDayOfMonth = DateTime(_focusedMonth.year, _focusedMonth.month + 1, 0);
final daysInMonth = lastDayOfMonth.day;
这个写法能稳定得到当前月最后一天,再取 day 即可知道月份总天数。
8.4 生成日期列表
日历先补空位,再填入当月日期:
| 阶段 | 内容 |
|---|---|
| 1 | 添加月初空位 |
| 2 | 添加 1 到当月末日 |
| 3 | 交给 GridView 渲染 |
九、日期格子渲染
9.1 GridView.builder
日历使用网格构建器:
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(crossAxisCount: 7),
itemCount: days.length,
itemBuilder: (context, index) { ... },
)
9.2 七列布局
crossAxisCount: 7 表示一周七天,符合日历常见视觉。
9.3 空格子处理
当某一项是 null 时,直接返回空组件:
if (day == null) return const SizedBox();
9.4 网格优势
| 优势 | 说明 |
|---|---|
| 结构清晰 | 每周七列 |
| 适合日历 | 天然符合月份视图 |
| 易于高亮 | 今天、选中态和事件点都容易显示 |
十、今天与选中日期高亮
10.1 isSelected 判断
选中日期判断如下:
final isSelected = day.day == _selectedDate.day &&
day.month == _selectedDate.month &&
day.year == _selectedDate.year;
10.2 isToday 判断
今天日期判断如下:
final isToday = day.day == DateTime.now().day &&
day.month == DateTime.now().month &&
day.year == DateTime.now().year;
10.3 样式差异
color: isSelected ? Colors.blue : (isToday ? Colors.blue.shade50 : null),
如果是今天但未选中,会显示浅蓝背景和蓝色边框;如果是选中日期,则直接蓝底白字。
10.4 视觉层级
| 状态 | 背景 | 边框 | 文字 |
|---|---|---|---|
| 普通日期 | 默认 | 无 | 默认 |
| 今天 | 浅蓝 | 蓝边框 | 蓝色 |
| 选中日期 | 蓝色 | 无 | 白色 |
十一、事件标记红点
11.1 hasEvent 判断
是否存在事件通过这个判断:
final hasEvent = _getEventsForDay(day).isNotEmpty;
11.2 红点绘制
如果当天有事件,就在格子底部绘制一个小圆点:
if (hasEvent)
Positioned(
bottom: 4,
child: Container(
width: 6,
height: 6,
decoration: BoxDecoration(
color: isSelected ? Colors.white : Colors.red,
shape: BoxShape.circle,
),
),
),
11.3 事件标记颜色
| 状态 | 红点颜色 |
|---|---|
| 普通日期 | 无红点 |
| 未选中但有事件 | 红色 |
| 选中且有事件 | 白色 |
11.4 标记的意义
红点能让用户在浏览月份时快速识别哪一天有安排,减少逐日点击查看的成本。
十二、日期选择逻辑
12.1 selectDate 方法
点击日期时会更新选中日期:
void _selectDate(DateTime date) {
setState(() {
_selectedDate = date;
});
}
12.2 点击处理
日期格子使用 GestureDetector:
GestureDetector(
onTap: () => _selectDate(day),
child: Container(...),
)
12.3 选中后的联动
选中日期后,页面下方事件列表会自动切换到该天的事件。
12.4 选中逻辑特点
这个项目并不把月份切换和日期选择绑定在一起,因此用户可以:
- 先浏览月份。
- 再选中某一天。
- 查看当天事件。
这是一种比较符合真实日历习惯的设计。
十三、事件新增弹窗
13.1 addEvent 方法
新增事件通过弹窗完成:
void _addEvent() {
final controller = TextEditingController();
showDialog(
context: context,
builder: (context) => AlertDialog(
title: const Text('Add Event'),
content: TextField(...),
actions: [...],
),
);
}
13.2 临时控制器
弹窗内部使用局部 TextEditingController,不需要把它提升为页面状态字段。
13.3 保存按钮
点击 Add 时会把输入写入当前选中日期:
final key = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day);
_events[key] = [...(_events[key] ?? []), controller.text];
13.4 弹窗交互表
| 按钮 | 行为 |
|---|---|
| Cancel | 关闭弹窗,不保存 |
| Add | 保存事件并关闭弹窗 |
十四、当天事件列表
14.1 Events on 标题
页面下方标题会显示当前选中日期:
Text('Events on ${_selectedDate.day}/${_selectedDate.month}')
14.2 无事件空状态
如果当天没有事件,显示空卡片:
Card(
child: Padding(
padding: const EdgeInsets.all(24),
child: Center(
child: Text('No events', style: TextStyle(color: Colors.grey.shade600)),
),
),
)
14.3 有事件列表
有事件时用 asMap().entries 生成列表项:
..._getEventsForDay(_selectedDate).asMap().entries.map((e) {
return Card(
child: ListTile(...),
);
})
14.4 列表内容
每条事件都显示:
- 左侧事件图标。
- 中间事件名称。
- 右侧删除按钮。
十五、事件删除逻辑
15.1 删除流程
点击删除图标后,会从当天事件列表中移除一项:
onPressed: () {
setState(() {
final key = DateTime(_selectedDate.year, _selectedDate.month, _selectedDate.day);
final events = List<String>.from(_events[key]!);
events.removeAt(e.key);
_events[key] = events;
});
}
15.2 为什么要复制列表
先用 List<String>.from(...) 拷贝出来,再删除并写回,是为了避免直接在原列表上操作带来不清晰的状态更新。
15.3 删除后的结果
删除后:
| 情况 | 页面变化 |
|---|---|
| 仍有事件 | 列表更新 |
| 全部删完 | 显示 No events |
15.4 删除交互边界
当前删除是即时生效,没有撤销和确认弹窗。对于演示型日历足够简单;如果做成正式产品,可以加 Snackbar 撤销。
十六、鸿蒙适配关注点
16.1 为什么适配风险较低
calendar 主要由 Flutter 标准组件和 Dart 状态逻辑构成,不依赖数据库、网络、定位、系统日历或平台通道,因此基础适配风险较低。
| 模块 | 是否依赖平台能力 | 适配关注度 |
|---|---|---|
| 月份网格 | Flutter 标准布局 | 低 |
| 弹窗新增 | Flutter 标准组件 | 中 |
| 事件列表 | Flutter 标准列表 | 低 |
| 日期归一化 | Dart 逻辑 | 低 |
| 本地持久化 | 当前未实现 | 高 |
16.2 软键盘与弹窗
鸿蒙设备上需要重点验证:
- 弹窗高度是否足够。
- 多行输入框是否能滚动。
- 软键盘弹出后输入框是否被遮挡。
- 关闭弹窗后页面状态是否正确保留。
16.3 日历网格在小屏上的表现
日历使用 7 列网格,这对小屏设备比较友好,但仍建议验证:
| 设备 | 验证点 |
|---|---|
| 手机竖屏 | 日期数字是否清晰 |
| 手机横屏 | 顶部标题和网格是否拥挤 |
| 平板 | 网格是否过于稀疏 |
| 折叠屏 | 展开后是否保持比例 |
16.4 数据边界
当前事件只保存在内存中,应用重启后会丢失。若要做正式日历产品,建议增加本地存储或同步能力。
当前项目更适合作为 Flutter 月视图日历样例,而不是完整的系统级日历应用。
十七、测试设计与默认测试改造
17.1 当前测试入口
项目中的测试文件仍是默认计数器测试。对于日历应用,更有价值的是验证初始月份、月份切换、事件查询和添加流程。
17.2 初始页面测试
testWidgets('calendar renders initial month view', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Calendar'), findsWidgets);
expect(find.text('Events on'), findsOneWidget);
});
17.3 月份切换测试
testWidgets('can switch month', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.byIcon(Icons.chevron_right));
await tester.pump();
expect(find.textContaining('20'), findsOneWidget);
});
17.4 打开新增事件弹窗测试
testWidgets('can open add event dialog', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
await tester.tap(find.text('Add'));
await tester.pumpAndSettle();
expect(find.text('Add Event'), findsOneWidget);
expect(find.text('Event name'), findsOneWidget);
});
17.5 删除事件测试
testWidgets('can delete an event', (WidgetTester tester) async {
await tester.pumpWidget(const MyApp());
expect(find.text('Meeting'), findsOneWidget);
});
十八、可维护性优化方向
18.1 抽离 Event 模型
当前使用 Map<DateTime, List<String>> 存储事件。若后续事件字段增多,可以抽成模型:
class CalendarEvent {
const CalendarEvent({
required this.title,
required this.date,
});
final String title;
final DateTime date;
}
18.2 增加持久化
如果想让日历事件重启后仍然保留,可以考虑:
| 方案 | 适合场景 |
|---|---|
| SharedPreferences | 少量简单事件 |
| SQLite | 结构化日历数据 |
| 文件存储 | 导入导出 |
| 云同步 | 多设备日程 |
18.3 增加编辑能力
当前列表项点击会直接打开新增同款弹窗,但源码没有专门区分编辑和新增。若后续要增强体验,可以把事件编辑单独做成模式。
18.4 增加搜索与筛选
AppBar 上已经预留了搜索按钮,可以进一步加:
- 按标题搜索。
- 按月份筛选。
- 按事件数量筛选。
十九、完整流程复盘
19.1 页面启动流程
main()
-> runApp(MyApp)
-> MaterialApp
-> MyHomePage
-> initState 设置当前日期和示例事件
-> build 渲染月份网格
19.2 选择日期流程
点击日历格子
-> _selectDate(day)
-> 更新 selectedDate
-> 重新渲染事件列表
19.3 新增事件流程
点击 Add
-> showDialog
-> 输入事件名称
-> 点击 Add
-> 归一化当前选中日期
-> 将事件写入 _events
-> 刷新界面
19.4 删除事件流程
点击列表删除按钮
-> 复制当天事件列表
-> removeAt 删除指定项
-> 写回 _events
-> 列表刷新
二十、相关资源与继续学习
20.1 Flutter 学习资源
日历应用涉及网格、列表、弹窗和状态更新,可以结合以下资源学习:
| 资源 | 内容 |
|---|---|
| Flutter Docs | Flutter 官方开发文档 |
| Dart 官方文档 | Dart 语言与核心库 |
| Widget catalog | Flutter 常用组件 |
| Flutter testing | Widget 测试与交互模拟 |
20.2 日历应用扩展方向
后续可以继续增强:
- 本地持久化。
- 编辑事件。
- 月份跳转到今天。
- 周视图和日视图。
- 事件颜色分类。
- 重复事件。
- 搜索与筛选。
- 云同步。
20.3 跨端实践价值
calendar 很适合作为 Flutter 适配鸿蒙的小型日历样例。它依赖很轻,但覆盖了网格布局、日期归一化、事件列表、弹窗输入和删除交互,能帮助开发者验证很多跨端基础能力。
总结
calendar 用简洁的 Flutter 代码实现了一个可浏览、可标记、可新增事件的月视图日历。它通过 _focusedMonth 控制当前月份,通过 _selectedDate 控制当前选中日期,通过 _events 保存每天的事件列表,通过网格和红点把月份、日期和事件联系起来。
从工程角度看,这个项目最值得学习的是“月份网格 + 日期归一化 + 事件列表”的组合方式。面向鸿蒙适配时,项目依赖较轻,主要需要验证弹窗输入、软键盘遮挡、网格布局和日期选择反馈。对于想学习 Flutter 日历类应用的开发者来说,它是一个清晰、实用且容易扩展的案例。
如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!
相关资源:
更多推荐



所有评论(0)