Flutter 实战:calendar 日历应用的月份网格、事件标记与鸿蒙适配解析

前言

欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net

calendar 是一个基于 Flutter 实现的轻量日历应用。它支持按月浏览日期、选择某一天、查看当天事件、通过弹窗新增事件,并用红点标记有事件的日期。项目没有引入复杂依赖,而是用 StatefulWidgetGridViewshowDialogListView 组合出一个完整的日历交互闭环。

本文基于项目真实源码展开,重点分析 月份网格生成日期归一化映射事件 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 实战能力:

  1. 如何按月生成日期网格。
  2. 如何用 DateTime 作为 Map 的归一化键。
  3. 如何把选择日期和显示月份分开管理。
  4. 如何用弹窗完成事件新增。
  5. 如何在列表里展示并删除当天事件。
  6. 如何面向鸿蒙验证日历网格、弹窗和滚动布局。

二、工程结构与运行方式

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 日历类应用的开发者来说,它是一个清晰、实用且容易扩展的案例。

如果这篇文章对你有帮助,欢迎点赞、收藏、关注,你的支持是我持续创作的动力!


相关资源:

Logo

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

更多推荐