在这里插入图片描述

说起日历,我自己以前总是用手机自带的日历App,但总觉得不够用。想看今天有什么待办,得切换到另一个App;想看这周的安排,又得翻来翻去。后来我就想,要是能把日历和待办事项结合起来就好了。所以在做这个生活助手App时,日历视图是生活管理页面的核心组件。

为什么日历视图这么重要

在开始写代码之前,我先想清楚了这个功能的价值。

第一个是时间感知。人对时间的感知是模糊的,今天是几号、星期几,很多人都要想一下。有个日历放在那里,一眼就能看到,时间感会更强。

第二个是计划管理。看着日历,能更好地规划未来。比如看到下周三有个重要会议,就可以提前准备。如果只是列表形式,就没有这种时间上的直观感受。

第三个是回顾过去。日历不仅能看未来,还能看过去。哪天完成了什么任务,哪天有什么重要事件,都能在日历上标记出来。这种回顾对个人成长很有帮助。

功能设计的思路

在设计这个功能时,我考虑了以下几个方面。

周视图的选择

日历有月视图、周视图、日视图三种。我选择了周视图,因为它是最平衡的。月视图信息太多,看不清细节;日视图信息太少,看不到全局。周视图刚刚好,一周的安排一目了然。

快捷入口的设计

日历下方放了6个快捷入口,分别是待办事项、购物清单、备忘录、日记本、倒计时、菜谱。这些都是生活管理的常用功能,点击就能进入对应页面。

今日待办的展示

日历下方还展示了今日待办事项,让用户不用点进去就能看到今天要做什么。这种信息前置的设计,能提高效率。

页面整体结构

先看页面的基本框架:

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

  
  State<LifePage> createState() => _LifePageState();
}

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

  
  Widget build(BuildContext context) {
    return Scaffold(
      backgroundColor: Colors.grey[100],
      appBar: AppBar(
        title: const Text('生活管理'),
      ),
      body: SingleChildScrollView(
        padding: EdgeInsets.all(16.w),
        child: Column(
          crossAxisAlignment: CrossAxisAlignment.start,
          children: [
            _buildCalendar(),
            SizedBox(height: 24.h),
            _buildQuickActions(),
            SizedBox(height: 24.h),
            _buildTodayTasks(),
          ],
        ),
      ),
    );
  }
}

StatefulWidget的必要性

这里用了StatefulWidget,因为日历的选中日期会变化。用户点击不同的日期,界面要更新。如果用StatelessWidget,就没法响应用户的操作。

状态变量的设计

定义了两个状态变量:

  • _focusedDay:当前聚焦的日期,默认是今天
  • _selectedDay:用户选中的日期,默认是null

这两个变量的区别是:_focusedDay决定日历显示哪一周,_selectedDay决定哪一天被高亮。

页面布局的三个部分

页面分成三块:日历、快捷入口、今日待办。用SizedBox隔开,间距设置为24,看起来不会太挤。

外层用SingleChildScrollView包裹,这样内容超出屏幕时可以滚动。背景色设置为Colors.grey[100],浅灰色,和白色卡片形成对比。

日历组件的实现

日历是这个页面的核心,我用了table_calendar包:

Widget _buildCalendar() {
  return Container(
    padding: EdgeInsets.all(16.w),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(16.r),
    ),
    child: 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;
        });
      },
      calendarFormat: CalendarFormat.week,
      headerStyle: HeaderStyle(
        formatButtonVisible: false,
        titleCentered: true,
        titleTextStyle: TextStyle(fontSize: 16.sp, fontWeight: FontWeight.bold),
      ),
      calendarStyle: CalendarStyle(
        todayDecoration: BoxDecoration(
          color: Colors.blue.withOpacity(0.3),
          shape: BoxShape.circle,
        ),
        selectedDecoration: const BoxDecoration(
          color: Colors.blue,
          shape: BoxShape.circle,
        ),
      ),
    ),
  );
}

TableCalendar的基本参数

firstDaylastDay定义了日历的范围,从2020年到2030年。这个范围要足够大,避免用户查看历史或未来时超出范围。

focusedDay是当前聚焦的日期,决定日历显示哪一周。selectedDayPredicate是一个判断函数,返回某一天是否被选中。

日期选择的回调

onDaySelected是用户点击日期时的回调,传入两个参数:

  • selectedDay:用户点击的日期
  • focusedDay:新的聚焦日期

在回调里调用setState,更新两个状态变量。这样界面就会重新渲染,选中的日期会高亮显示。

周视图的设置

calendarFormat: CalendarFormat.week设置为周视图。这样日历只显示一周,不会占用太多空间。

头部样式的定制

headerStyle定制了日历头部的样式:

  • formatButtonVisible: false:隐藏格式切换按钮,因为我们固定用周视图
  • titleCentered: true:标题居中显示
  • titleTextStyle:标题文字样式,16号字,加粗

日期样式的定制

calendarStyle定制了日期的样式:

  • todayDecoration:今天的装饰,蓝色半透明圆圈
  • selectedDecoration:选中日期的装饰,蓝色实心圆圈

这两个装饰的区别是:今天是半透明的,选中的是实心的。这样用户能区分"今天"和"选中的日期"。

快捷入口的设计

日历下方是6个快捷入口,用网格布局展示:

Widget _buildQuickActions() {
  final actions = [
    {'title': '待办事项', 'icon': Icons.check_box, 'color': Colors.blue, 'page': const TodoListPage()},
    {'title': '购物清单', 'icon': Icons.shopping_cart, 'color': Colors.orange, 'page': const ShoppingListPage()},
    {'title': '备忘录', 'icon': Icons.note, 'color': Colors.purple, 'page': const NotesPage()},
    {'title': '日记本', 'icon': Icons.book, 'color': Colors.pink, 'page': const DiaryPage()},
    {'title': '倒计时', 'icon': Icons.timer, 'color': Colors.red, 'page': const CountdownPage()},
    {'title': '菜谱', 'icon': Icons.restaurant_menu, 'color': Colors.green, 'page': const RecipePage()},
  ];

  return GridView.builder(
    shrinkWrap: true,
    physics: const NeverScrollableScrollPhysics(),
    gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
      crossAxisCount: 3,
      crossAxisSpacing: 12.w,
      mainAxisSpacing: 12.h,
      childAspectRatio: 1,
    ),
    itemCount: actions.length,
    itemBuilder: (context, index) {
      final action = actions[index];
      return GestureDetector(
        onTap: () => Get.to(() => action['page'] as Widget),
        child: Container(
          decoration: BoxDecoration(
            color: Colors.white,
            borderRadius: BorderRadius.circular(12.r),
          ),
          child: Column(
            mainAxisAlignment: MainAxisAlignment.center,
            children: [
              Icon(action['icon'] as IconData, color: action['color'] as Color, size: 32.sp),
              SizedBox(height: 8.h),
              Text(
                action['title'] as String,
                style: TextStyle(fontSize: 12.sp),
                textAlign: TextAlign.center,
              ),
            ],
          ),
        ),
      );
    },
  );
}

数据结构的设计

每个快捷入口包含四个字段:

  • title:标题,比如"待办事项"
  • icon:图标,用Material Icons
  • color:颜色,每个入口有自己的主题色
  • page:对应的页面,点击后跳转

这种数据驱动的方式很灵活,想加新的入口,只需要在数组里加一项。

GridView的布局

crossAxisCount: 3表示每行显示3个入口。crossAxisSpacingmainAxisSpacing设置间距。childAspectRatio: 1让每个格子是正方形。

shrinkWrap: true让GridView不要自己处理滚动,交给外层的SingleChildScrollViewphysics: const NeverScrollableScrollPhysics()禁用GridView自己的滚动。

点击跳转的实现

每个入口用GestureDetector包裹,点击时调用Get.to()跳转到对应页面。这里用了GetX的路由管理,比Navigator更简洁。

图标和文字的布局

每个入口是一个白色卡片,里面是图标和文字垂直排列。图标大小32,颜色是主题色。文字大小12,居中显示。

mainAxisAlignment: MainAxisAlignment.center让图标和文字在卡片中垂直居中。

今日待办的展示

日历和快捷入口下方,展示了今日待办事项:

Widget _buildTodayTasks() {
  return Column(
    crossAxisAlignment: CrossAxisAlignment.start,
    children: [
      Text(
        '今日待办',
        style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold),
      ),
      SizedBox(height: 12.h),
      _buildTaskCard('完成项目报告', false, Colors.red),
      _buildTaskCard('去超市买菜', false, Colors.orange),
      _buildTaskCard('健身房锻炼', true, Colors.green),
    ],
  );
}

标题的设计

"今日待办"这个标题用18号加粗字体,让用户知道下面是什么内容。

任务列表的构建

这里硬编码了3个任务,实际项目中应该从数据库读取。每个任务调用_buildTaskCard方法构建卡片。

任务卡片的实现

Widget _buildTaskCard(String title, bool isCompleted, Color color) {
  return Container(
    margin: EdgeInsets.only(bottom: 12.h),
    padding: EdgeInsets.all(16.w),
    decoration: BoxDecoration(
      color: Colors.white,
      borderRadius: BorderRadius.circular(12.r),
    ),
    child: Row(
      children: [
        Icon(
          isCompleted ? Icons.check_circle : Icons.radio_button_unchecked,
          color: isCompleted ? Colors.green : color,
        ),
        SizedBox(width: 12.w),
        Expanded(
          child: Text(
            title,
            style: TextStyle(
              fontSize: 14.sp,
              decoration: isCompleted ? TextDecoration.lineThrough : null,
            ),
          ),
        ),
      ],
    ),
  );
}

图标的动态变化

任务卡片左边的图标根据完成状态变化:

  • 未完成:Icons.radio_button_unchecked,空心圆圈,颜色是任务的主题色
  • 已完成:Icons.check_circle,打勾的圆圈,颜色是绿色

文字的删除线

已完成的任务,文字会加上删除线:decoration: TextDecoration.lineThrough。这是一种常见的视觉表达,表示任务已经完成。

颜色的含义

每个任务有自己的颜色,表示优先级或类型:

  • 红色:紧急重要
  • 橙色:一般重要
  • 绿色:已完成

这种颜色编码能帮助用户快速识别任务的重要程度。

日历事件的标记

实际项目中,日历上应该标记有事件的日期。比如某天有待办事项,就在日期下方加个小圆点。

table_calendar支持事件标记,需要提供一个事件映射:

Map<DateTime, List<Event>> _events = {
  DateTime(2024, 1, 15): [Event('完成项目报告')],
  DateTime(2024, 1, 16): [Event('团队会议'), Event('健身房锻炼')],
  DateTime(2024, 1, 17): [Event('去超市买菜')],
};

TableCalendar(
  // 其他参数...
  eventLoader: (day) {
    return _events[day] ?? [];
  },
  calendarBuilders: CalendarBuilders(
    markerBuilder: (context, date, events) {
      if (events.isNotEmpty) {
        return Positioned(
          bottom: 1,
          child: Container(
            width: 6,
            height: 6,
            decoration: const BoxDecoration(
              color: Colors.red,
              shape: BoxShape.circle,
            ),
          ),
        );
      }
      return null;
    },
  ),
)

eventLoader的作用

eventLoader是一个函数,传入日期,返回该日期的事件列表。table_calendar会调用这个函数,获取每一天的事件。

markerBuilder的作用

markerBuilder是一个构建器,用来自定义事件标记的样式。这里我用了一个红色小圆点,放在日期下方。

如果某天有多个事件,可以显示多个圆点,或者显示数字。

日期选择的交互

用户点击日期后,应该有相应的交互。比如显示该日期的详细信息,或者跳转到该日期的待办列表。

onDaySelected: (selectedDay, focusedDay) {
  setState(() {
    _selectedDay = selectedDay;
    _focusedDay = focusedDay;
  });
  
  // 显示该日期的事件
  _showDayEvents(selectedDay);
},

void _showDayEvents(DateTime day) {
  final events = _events[day] ?? [];
  
  if (events.isEmpty) {
    ScaffoldMessenger.of(context).showSnackBar(
      const SnackBar(content: Text('该日期没有事件')),
    );
    return;
  }
  
  showModalBottomSheet(
    context: context,
    builder: (context) => Container(
      padding: EdgeInsets.all(16.w),
      child: Column(
        mainAxisSize: MainAxisSize.min,
        crossAxisAlignment: CrossAxisAlignment.start,
        children: [
          Text(
            DateFormat('yyyy年MM月dd日').format(day),
            style: TextStyle(fontSize: 18.sp, fontWeight: FontWeight.bold),
          ),
          SizedBox(height: 16.h),
          ...events.map((event) => ListTile(
            leading: const Icon(Icons.event),
            title: Text(event.title),
          )),
        ],
      ),
    ),
  );
}

底部弹窗的使用

点击日期后,用showModalBottomSheet弹出一个底部弹窗,显示该日期的所有事件。这种交互比跳转到新页面更轻量,用户可以快速查看后关闭。

空状态的处理

如果该日期没有事件,显示一个SnackBar提示用户。这比什么都不显示要好,让用户知道操作生效了。

周视图的切换

虽然我默认用周视图,但也可以支持切换到月视图。可以在AppBar加个按钮:

AppBar(
  title: const Text('生活管理'),
  actions: [
    IconButton(
      icon: Icon(_calendarFormat == CalendarFormat.week 
        ? Icons.calendar_view_month 
        : Icons.calendar_view_week),
      onPressed: () {
        setState(() {
          _calendarFormat = _calendarFormat == CalendarFormat.week
            ? CalendarFormat.month
            : CalendarFormat.week;
        });
      },
    ),
  ],
)

点击按钮,在周视图和月视图之间切换。图标也会跟着变化,让用户知道当前是什么视图。

实际使用体验

这个日历视图我自己用了一段时间,感觉很方便。每天打开应用,第一眼就能看到今天是几号,星期几,有什么待办。

周视图的大小刚刚好,不会占用太多空间,又能看到一周的安排。如果想看更远的日期,可以左右滑动切换周。

快捷入口也很实用。想记个待办,点一下就进去了,不用在菜单里找。6个入口基本覆盖了日常需求。

今日待办的展示很贴心。不用点进去,就能看到今天要做什么。看到已完成的任务有删除线,会有成就感。

可以改进的地方

如果要做得更完善,可以考虑以下几点。

事件的颜色标记

不同类型的事件用不同颜色的圆点标记。比如待办事项用蓝色,日记用粉色,倒计时用红色。这样一眼就能看出某天有什么类型的事件。

长按添加事件

长按日期,弹出菜单,可以快速添加待办、日记等。这比先进入对应页面再添加要快。

拖拽调整事件

支持拖拽事件到其他日期,调整计划。这种交互很直观,比编辑日期要方便。

周数的显示

在日历头部显示当前是第几周,对于需要按周规划的用户很有用。

节假日的标记

在日历上标记节假日,用不同的颜色或图标。这样用户能提前知道哪天放假,方便安排。

小结

今天实现了日历视图功能,用到了table_calendar包、网格布局、列表展示等组件。核心是用周视图展示日历,配合快捷入口和今日待办,形成一个完整的生活管理页面。

这个功能虽然看起来简单,但对用户体验影响很大。日历是时间管理的基础,有了日历,用户才能更好地规划生活。

在实现过程中,我特别注重信息的层次。日历在最上面,最重要;快捷入口在中间,方便操作;今日待办在下面,提供详细信息。这种布局符合用户的视觉习惯。

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

Logo

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

更多推荐