日历视图是DeepWake的一个很实用的功能。想象一下,你设置了很多重复闹钟,有的周一到周五响,有的只在周末响,有的每天都响。如果用列表查看,很难一眼看出哪天有哪些闹钟。日历视图就是为了解决这个问题——用日历的形式直观地展示闹钟安排。

做这个功能的时候,我选择了table_calendar这个库。它功能强大,可定制性强,而且文档完善。通过这个库,我们可以快速实现一个漂亮的日历视图。
请添加图片描述

table_calendar库介绍

在开始编码之前,先了解一下table_calendar的基本用法。

核心功能

  • 显示月视图、周视图
  • 支持日期选择
  • 支持事件标记
  • 高度可定制的样式

基本概念

  • focusedDay:当前聚焦的日期,决定显示哪个月
  • selectedDay:用户选中的日期
  • eventLoader:加载每个日期的事件
import 'package:flutter/material.dart';
import 'package:flutter_screenutil/flutter_screenutil.dart';
import 'package:get/get.dart';
import 'package:table_calendar/table_calendar.dart';
import '../../controllers/alarm_controller.dart';
import '../../models/alarm.dart';

导入依赖:除了常用的Flutter和GetX,还导入了table_calendar和项目的控制器、模型。

table_calendar是第三方库,需要在pubspec.yaml中添加依赖。

class AlarmCalendarPage extends StatefulWidget {
  const AlarmCalendarPage({super.key});

  
  State<AlarmCalendarPage> createState() => _AlarmCalendarPageState();
}

class _AlarmCalendarPageState extends State<AlarmCalendarPage> {
  DateTime _focusedDay = DateTime.now();
  DateTime? _selectedDay;

  
  Widget build(BuildContext context) {
    final controller = Get.find<AlarmController>();

状态变量

  • _focusedDay当前聚焦的日期,初始化为今天
  • _selectedDay用户选中的日期,初始为null

获取控制器:用Get.find获取AlarmController,用于加载闹钟数据。

日历组件配置

TableCalendar有很多配置选项,需要仔细设置。

    return Scaffold(
      appBar: AppBar(
        title: const Text('日历视图'),
        actions: [
          IconButton(
            icon: const Icon(Icons.today),
            onPressed: () {
              setState(() {
                _focusedDay = DateTime.now();
                _selectedDay = DateTime.now();
              });
            },
          ),
        ],
      ),

AppBar配置

  • 标题显示"日历视图"
  • 右侧添加"今天"按钮,点击后跳转到今天

这个按钮很实用,当用户浏览其他月份时,可以快速回到今天。

      body: Column(
        children: [
          TableCalendar(
            firstDay: DateTime.utc(2020, 1, 1),
            lastDay: DateTime.utc(2030, 12, 31),
            focusedDay: _focusedDay,
            selectedDayPredicate: (day) => isSameDay(_selectedDay, day),
            onDaySelected: (selectedDay, focusedDay) {
              setState(() {
                _selectedDay = selectedDay;
                _focusedDay = focusedDay;
              });
            },

日期范围

  • firstDay最早日期,设置为2020年1月1日
  • lastDay最晚日期,设置为2030年12月31日
  • 10年的范围足够使用

日期选择

  • selectedDayPredicate判断某天是否被选中
  • onDaySelected处理日期选择事件
  • 更新_selectedDay_focusedDay

isSameDay是table_calendar提供的工具函数,比较两个日期是否是同一天。

            eventLoader: (day) => controller.getAlarmsForDate(day),
            calendarStyle: CalendarStyle(
              markerDecoration: const BoxDecoration(
                color: Colors.blue,
                shape: BoxShape.circle,
              ),
              todayDecoration: BoxDecoration(
                color: Colors.orange.withOpacity(0.5),
                shape: BoxShape.circle,
              ),
              selectedDecoration: const BoxDecoration(
                color: Colors.blue,
                shape: BoxShape.circle,
              ),

事件加载

  • eventLoader为每个日期加载事件(闹钟)
  • 返回的列表长度决定标记的数量

样式配置

  • markerDecoration事件标记的样式,蓝色圆点
  • todayDecoration今天的样式,半透明橙色圆圈
  • selectedDecoration选中日期的样式,蓝色圆圈

这些样式让日历更直观,用户一眼就能看出哪天有闹钟、哪天是今天、哪天被选中。

              weekendTextStyle: TextStyle(color: Colors.red),
              outsideDaysVisible: false,
            ),
            headerStyle: HeaderStyle(
              formatButtonVisible: false,
              titleCentered: true,
              titleTextStyle: TextStyle(
                fontSize: 18.sp,
                fontWeight: FontWeight.bold,
              ),
            ),
          ),

更多样式

  • weekendTextStyle周末日期用红色显示
  • outsideDaysVisible隐藏不属于当前月的日期
  • formatButtonVisible隐藏格式切换按钮
  • titleCentered标题居中
  • titleTextStyle标题字体样式

这些配置让日历更简洁美观。

          const Divider(),
          Expanded(
            child: _selectedDay == null
                ? Center(
                    child: Text(
                      '选择日期查看闹钟',
                      style: TextStyle(fontSize: 16.sp),
                    ),
                  )
                : _buildAlarmList(controller),
          ),
        ],
      ),
    );
  }

下半部分布局

  • 分隔线分隔日历和列表
  • Expanded让列表占据剩余空间
  • 未选择日期时显示提示
  • 选择日期后显示该日期的闹钟列表

这种上下分割的布局很常见,上面是日历,下面是详情。

闹钟列表显示

选中日期后,下半部分显示该日期的闹钟列表。

  Widget _buildAlarmList(AlarmController controller) {
    return Obx(() {
      final alarms = controller.getAlarmsForDate(_selectedDay!);
      
      if (alarms.isEmpty) {
        return Center(
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(
                Icons.alarm_off,
                size: 64.sp,
                color: Colors.grey,
              ),
              SizedBox(height: 16.h),
              Text(
                '该日期没有闹钟',
                style: TextStyle(
                  fontSize: 16.sp,
                  color: Colors.grey,
                ),
              ),
            ],
          ),
        );
      }

空状态处理

  • Obx包裹,响应闹钟数据变化
  • 获取选中日期的闹钟列表
  • 如果列表为空,显示友好的空状态

空状态包含图标和文字,让用户知道不是出错了,只是没有数据。

      return ListView.builder(
        padding: EdgeInsets.all(16.w),
        itemCount: alarms.length,
        itemBuilder: (context, index) {
          final alarm = alarms[index];
          return Card(
            margin: EdgeInsets.only(bottom: 12.h),
            child: ListTile(
              leading: Icon(Icons.alarm, size: 32.sp),
              title: Text(
                '${alarm.time.hour.toString().padLeft(2, '0')}:${alarm.time.minute.toString().padLeft(2, '0')}',
                style: TextStyle(
                  fontSize: 20.sp,
                  fontWeight: FontWeight.bold,
                ),
              ),

列表构建

  • ListView.builder构建列表
  • 每个闹钟用Card包裹
  • ListTile显示闹钟信息

时间格式化:用padLeft(2, '0')确保两位数显示,比如"09:05"而不是"9:5"。

              subtitle: Column(
                crossAxisAlignment: CrossAxisAlignment.start,
                children: [
                  Text(alarm.label.isEmpty ? '闹钟' : alarm.label),
                  SizedBox(height: 4.h),
                  Text(
                    _getRepeatText(alarm),
                    style: TextStyle(
                      fontSize: 12.sp,
                      color: Colors.grey,
                    ),
                  ),
                ],
              ),
              trailing: Switch(
                value: alarm.enabled,
                onChanged: (_) => controller.toggleAlarm(alarm.id),
              ),
            ),
          );
        },
      );
    });
  }

副标题显示

  • 闹钟标签
  • 重复规则

开关控制:用Switch让用户可以直接在日历视图中启用或禁用闹钟,无需进入详情页面。

重复规则显示

闹钟的重复规则需要友好地显示给用户。

  String _getRepeatText(Alarm alarm) {
    if (alarm.repeatDays.isEmpty) {
      return '不重复';
    }
    
    if (alarm.repeatDays.length == 7) {
      return '每天';
    }
    
    if (alarm.repeatDays.length == 5 &&
        !alarm.repeatDays.contains(0) &&
        !alarm.repeatDays.contains(6)) {
      return '工作日';
    }
    
    if (alarm.repeatDays.length == 2 &&
        alarm.repeatDays.contains(0) &&
        alarm.repeatDays.contains(6)) {
      return '周末';
    }

特殊情况处理

  • 空列表:不重复
  • 7天:每天
  • 周一到周五:工作日
  • 周六周日:周末

这些是常见的重复模式,用简短的文字表示。

    const weekDays = ['周日', '周一', '周二', '周三', '周四', '周五', '周六'];
    final days = alarm.repeatDays.map((day) => weekDays[day]).toList();
    return days.join('、');
  }

一般情况处理

  • 将数字转换为星期文字
  • 用顿号连接

比如[1, 3, 5]显示为"周一、周三、周五"。

闹钟数据加载

日历需要知道每个日期有哪些闹钟。

// 在AlarmController中
List<Alarm> getAlarmsForDate(DateTime date) {
  return alarms.where((alarm) {
    if (alarm.repeatDays.isEmpty) {
      return false;
    }
    
    final weekday = date.weekday % 7;
    return alarm.repeatDays.contains(weekday);
  }).toList();
}

过滤逻辑

  • 只返回有重复规则的闹钟
  • 检查日期的星期几是否在重复规则中

星期几的转换date.weekday返回1-7(周一到周日),用% 7转换为0-6(周日到周六),与repeatDays的格式一致。

// 优化版本:缓存结果
class AlarmController extends GetxController {
  final RxMap<String, List<Alarm>> _dateAlarmsCache = <String, List<Alarm>>{}.obs;
  
  List<Alarm> getAlarmsForDate(DateTime date) {
    final key = '${date.year}-${date.month}-${date.day}';
    
    if (_dateAlarmsCache.containsKey(key)) {
      return _dateAlarmsCache[key]!;
    }
    
    final result = alarms.where((alarm) {
      if (alarm.repeatDays.isEmpty) {
        return false;
      }
      
      final weekday = date.weekday % 7;
      return alarm.repeatDays.contains(weekday);
    }).toList();
    
    _dateAlarmsCache[key] = result;
    return result;
  }

缓存优化

  • 用Map缓存每个日期的闹钟列表
  • 避免重复计算
  • 闹钟数据变化时清空缓存

这种优化在日历滚动时很有用,避免频繁计算。

  
  Future<void> createAlarm(Alarm alarm) async {
    alarms.add(alarm);
    _dateAlarmsCache.clear();
    await saveAlarms();
  }
  
  
  Future<void> updateAlarm(Alarm alarm) async {
    final index = alarms.indexWhere((a) => a.id == alarm.id);
    if (index != -1) {
      alarms[index] = alarm;
      _dateAlarmsCache.clear();
      await saveAlarms();
    }
  }
}

缓存失效:闹钟数据变化时清空缓存,确保数据一致性。

日历样式定制

table_calendar提供了丰富的样式定制选项。

日期文字样式

CalendarStyle(
  defaultTextStyle: TextStyle(fontSize: 14.sp),
  weekendTextStyle: TextStyle(fontSize: 14.sp, color: Colors.red),
  selectedTextStyle: TextStyle(fontSize: 14.sp, color: Colors.white),
  todayTextStyle: TextStyle(fontSize: 14.sp, color: Colors.white),
  outsideTextStyle: TextStyle(fontSize: 14.sp, color: Colors.grey),
)

不同状态的文字样式

  • defaultTextStyle普通日期
  • weekendTextStyle周末日期
  • selectedTextStyle选中日期
  • todayTextStyle今天
  • outsideTextStyle不属于当前月的日期

日期装饰样式

CalendarStyle(
  defaultDecoration: BoxDecoration(
    shape: BoxShape.circle,
  ),
  selectedDecoration: BoxDecoration(
    color: Colors.blue,
    shape: BoxShape.circle,
  ),
  todayDecoration: BoxDecoration(
    color: Colors.orange.withOpacity(0.5),
    shape: BoxShape.circle,
  ),
  weekendDecoration: BoxDecoration(
    shape: BoxShape.circle,
  ),
)

装饰样式

  • 都用圆形
  • 选中日期蓝色背景
  • 今天半透明橙色背景
  • 普通日期和周末无背景

标记样式

CalendarStyle(
  markerDecoration: BoxDecoration(
    color: Colors.blue,
    shape: BoxShape.circle,
  ),
  markerSize: 6.w,
  markerMargin: EdgeInsets.symmetric(horizontal: 1.w),
  markersMaxCount: 3,
)

标记配置

  • 蓝色圆点
  • 大小6个逻辑像素
  • 标记之间间隔1个逻辑像素
  • 最多显示3个标记

如果某天有多个闹钟,会显示多个圆点,但最多3个。

头部样式定制

日历头部显示月份和导航按钮。

HeaderStyle(
  formatButtonVisible: false,
  titleCentered: true,
  leftChevronIcon: Icon(Icons.chevron_left, size: 24.sp),
  rightChevronIcon: Icon(Icons.chevron_right, size: 24.sp),
  titleTextStyle: TextStyle(
    fontSize: 18.sp,
    fontWeight: FontWeight.bold,
  ),
  headerPadding: EdgeInsets.symmetric(vertical: 8.h),
  headerMargin: EdgeInsets.only(bottom: 8.h),
)

头部配置

  • 隐藏格式切换按钮
  • 标题居中
  • 自定义左右箭头图标
  • 标题字体样式
  • 内边距和外边距

这些配置让头部更简洁美观。

手势交互

table_calendar支持多种手势交互。

滑动切换月份

TableCalendar(
  // ... 其他配置
  onPageChanged: (focusedDay) {
    setState(() {
      _focusedDay = focusedDay;
    });
  },
)

页面切换回调:用户滑动切换月份时,更新_focusedDay

长按日期

TableCalendar(
  // ... 其他配置
  onDayLongPressed: (selectedDay, focusedDay) {
    Get.to(() => AlarmEditorPage(
      initialDate: selectedDay,
    ));
  },
)

长按创建闹钟:长按某个日期,跳转到闹钟编辑页面,并预填充该日期。这是个很方便的快捷操作。

双击日期

TableCalendar(
  // ... 其他配置
  onDaySelected: (selectedDay, focusedDay) {
    if (_selectedDay != null && isSameDay(_selectedDay, selectedDay)) {
      // 双击同一天,显示详情
      _showDayDetails(selectedDay);
    } else {
      setState(() {
        _selectedDay = selectedDay;
        _focusedDay = focusedDay;
      });
    }
  },
)

双击检测:如果点击的是已选中的日期,认为是双击,显示详情对话框。

性能优化

日历视图涉及大量日期计算,需要注意性能。

事件加载优化

// 不好的做法:每次都遍历所有闹钟
List<Alarm> getAlarmsForDate(DateTime date) {
  return alarms.where((alarm) {
    // 复杂的过滤逻辑
  }).toList();
}

// 好的做法:缓存结果
final _cache = <String, List<Alarm>>{};

List<Alarm> getAlarmsForDate(DateTime date) {
  final key = _dateKey(date);
  return _cache.putIfAbsent(key, () {
    return alarms.where((alarm) {
      // 复杂的过滤逻辑
    }).toList();
  });
}

缓存策略

  • 用Map缓存计算结果
  • 避免重复计算
  • 数据变化时清空缓存

Widget复用

// 不好的做法:每次都创建新Widget
Widget _buildAlarmCard(Alarm alarm) {
  return Card(
    child: ListTile(
      // ...
    ),
  );
}

// 好的做法:用const构造函数
Widget _buildAlarmCard(Alarm alarm) {
  return Card(
    child: ListTile(
      leading: const Icon(Icons.alarm),
      // ...
    ),
  );
}

const优化:能用const的地方就用const,Flutter会缓存const Widget,避免重复创建。

列表优化

ListView.builder(
  // 使用builder而不是直接传children
  itemCount: alarms.length,
  itemBuilder: (context, index) {
    return _buildAlarmCard(alarms[index]);
  },
)

builder的优势:只构建可见的Widget,不可见的不构建。这在列表很长时能显著提升性能。

用户体验优化

除了基本功能,还有很多细节提升体验。

今天按钮:AppBar右侧的今天按钮,让用户可以快速回到今天。这在浏览其他月份时很有用。

空状态提示:未选择日期或选中日期没有闹钟时,显示友好的提示。避免空白页面让用户困惑。

即时反馈:点击日期立即显示该日期的闹钟,不需要额外操作。

开关控制:可以直接在列表中启用或禁用闹钟,不需要进入详情页面。

长按快捷操作:长按日期可以快速创建闹钟,提升效率。

可访问性

日历视图也要考虑可访问性。

语义标签

Semantics(
  label: '日历',
  child: TableCalendar(
    // ...
  ),
)

为日历添加语义标签,让屏幕阅读器能正确读出。

触摸目标大小

CalendarStyle(
  cellMargin: EdgeInsets.all(4.w),
  cellPadding: EdgeInsets.all(0),
)

确保日期单元格足够大,至少48x48逻辑像素,方便点击。

颜色对比度:选中日期和今天的颜色要与背景有足够对比度,确保可见性。

总结

日历视图通过直观的日历形式展示闹钟安排,让用户一眼就能看出哪天有哪些闹钟。通过table_calendar库,我们可以快速实现一个功能完善、样式美观的日历视图。

技术要点

  • 用table_calendar显示日历
  • 用eventLoader加载每个日期的事件
  • 用CalendarStyle定制样式
  • 用缓存优化性能
  • 用Obx响应数据变化

设计要点

  • 上下分割的布局
  • 清晰的日期标记
  • 友好的空状态
  • 便捷的快捷操作

开发这个功能让我深刻体会到,选择合适的第三方库能大大提升开发效率。table_calendar提供了丰富的功能和灵活的定制选项,让我们可以专注于业务逻辑,而不是重复造轮子。

当然,使用第三方库也要注意版本兼容性、性能影响、许可证等问题。在选择库之前,要仔细评估是否适合项目需求。


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

Logo

开源鸿蒙跨平台开发社区汇聚开发者与厂商,共建“一次开发,多端部署”的开源生态,致力于降低跨端开发门槛,推动万物智联创新。

更多推荐