Apple日历两日视图事件排布逻辑解析及Flutter实现参考请求
Apple日历两日视图事件排布逻辑解析
核心规则
1. 基础排序与纵向定位
- 所有事件优先按开始时间升序排列,同时间段的事件按结束时间降序(长事件优先)。
- 事件的纵向位置直接由开始/结束时间映射到时间轴:比如1小时对应固定高度,开始时间为9:30的事件,顶部位置就是9.5倍的单小时高度。
2. 重叠事件的横向分配
- 计算事件覆盖时间段内的最大重叠数量,把当前日的横向空间等分成对应份数,每个重叠事件占据1份宽度。
- 若事件仅与部分重叠事件交叉(比如事件A从10点到12点,事件B从11点到13点),事件B会在10-11点占据全宽,11-12点和A平分宽度,12-13点再回到全宽,自动适配可用空间。
- 嵌套事件(小事件完全包含在大事件内):大事件占据主宽度,小事件压缩到剩余空间,同时保证大事件的视觉优先级。
3. 特殊事件处理
- 全天/跨天事件固定显示在顶部独立栏,不占用主时间轴的纵向空间。
- 短时长事件(如<30分钟)会强制保留最小宽度,避免过窄无法交互。
- 重叠事件通过半透明底色+边框区分,不会完全遮挡下层事件。
Flutter实现示例
1. 定义事件模型
class CalendarEvent { final String id; final DateTime start; final DateTime end; final Color color; final String title; CalendarEvent({ required this.id, required this.start, required this.end, required this.color, required this.title, }); // 计算事件持续分钟数 int get durationInMinutes => end.difference(start).inMinutes; }
2. 计算布局参数(重叠分析+位置分配)
// 按开始时间排序,同时间的长事件优先 List<CalendarEvent> sortEvents(List<CalendarEvent> events) { return List.from(events) ..sort((a, b) { final startCompare = a.start.compareTo(b.start); if (startCompare != 0) return startCompare; // 开始时间相同,结束时间晚的排前面(长事件优先) return b.end.compareTo(a.end); }); } // 按15分钟粒度计算每个时间点的最大重叠数 Map<DateTime, int> calculateMaxOverlaps(List<CalendarEvent> events) { final overlaps = <DateTime, int>{}; for (final event in events) { var currentTime = DateTime( event.start.year, event.start.month, event.start.day, event.start.hour, (event.start.minute ~/ 15) * 15, ); while (currentTime.isBefore(event.end)) { overlaps[currentTime] = (overlaps[currentTime] ?? 0) + 1; currentTime = currentTime.add(const Duration(minutes: 15)); } } return overlaps; } // 为每个事件计算横向偏移和宽度占比 List<EventLayout> calculateEventLayouts(List<CalendarEvent> events) { final sortedEvents = sortEvents(events); final maxOverlaps = calculateMaxOverlaps(sortedEvents); final layouts = <EventLayout>[]; for (final event in sortedEvents) { // 找到事件覆盖时间段内的最大重叠数 int maxOverlap = 1; var currentTime = DateTime( event.start.year, event.start.month, event.start.day, event.start.hour, (event.start.minute ~/ 15) * 15, ); while (currentTime.isBefore(event.end)) { maxOverlap = max(maxOverlap, maxOverlaps[currentTime] ?? 1); currentTime = currentTime.add(const Duration(minutes: 15)); } // 确定当前事件在重叠组中的索引 int index = 0; for (final existing in layouts) { // 判断是否与已有事件重叠 if (existing.event.start.isBefore(event.end) && existing.event.end.isAfter(event.start)) { index++; } } layouts.add(EventLayout( event: event, leftRatio: index / maxOverlap, widthRatio: 1 / maxOverlap, )); } return layouts; } // 存储事件布局信息的模型 class EventLayout { final CalendarEvent event; final double leftRatio; // 横向偏移占比(0-1) final double widthRatio; // 宽度占比(0-1) EventLayout({ required this.event, required this.leftRatio, required this.widthRatio, }); }
3. 两日视图UI实现
class TwoDayCalendar extends StatelessWidget { final List<EventLayout> eventLayouts; final DateTime firstDay; final DateTime secondDay; final double hourHeight; // 每个小时对应的高度 const TwoDayCalendar({ super.key, required this.eventLayouts, required this.firstDay, required this.secondDay, this.hourHeight = 60.0, }); @override Widget build(BuildContext context) { final dayWidth = MediaQuery.of(context).size.width / 2; return Row( crossAxisAlignment: CrossAxisAlignment.start, children: [ // 第一日视图 _buildSingleDayView(firstDay, dayWidth), // 第二日视图 _buildSingleDayView(secondDay, dayWidth), ], ); } Widget _buildSingleDayView(DateTime targetDay, double dayWidth) { // 筛选当日事件 final dayEvents = eventLayouts.where((layout) { final eventDay = DateTime( layout.event.start.year, layout.event.start.month, layout.event.start.day, ); return eventDay.isAtSameMomentAs(targetDay); }).toList(); return Container( width: dayWidth, height: 24 * hourHeight, child: Stack( children: [ // 绘制时间轴刻度线 ...List.generate(24, (hour) { return Positioned( top: hour * hourHeight, left: 0, right: 0, child: Divider( height: 1, color: Colors.grey.shade200, thickness: 1, ), ); }), // 绘制时间文本(如9:00) ...List.generate(24, (hour) { return Positioned( top: hour * hourHeight - 8, left: 4, child: Text( '$hour:00', style: TextStyle(fontSize: 12, color: Colors.grey.shade600), ), ); }), // 绘制事件卡片 ...dayEvents.map((layout) { // 计算纵向位置和高度 final startOffset = layout.event.start.hour + layout.event.start.minute / 60; final endOffset = layout.event.end.hour + layout.event.end.minute / 60; final top = startOffset * hourHeight; final height = (endOffset - startOffset) * hourHeight; return Positioned( top: top, left: layout.leftRatio * dayWidth + 2, width: layout.widthRatio * dayWidth - 4, height: height, child: Container( padding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), decoration: BoxDecoration( color: layout.event.color.withOpacity(0.85), borderRadius: BorderRadius.circular(4), border: Border.all(color: Colors.white.withOpacity(0.3), width: 0.5), ), child: Text( layout.event.title, style: const TextStyle(fontSize: 12, color: Colors.white), overflow: TextOverflow.ellipsis, ), ), ); }), ], ), ); } }
使用示例
void main() { final events = [ CalendarEvent( id: '1', start: DateTime(2024, 5, 20, 9, 0), end: DateTime(2024, 5, 20, 11, 30), color: Colors.blue, title: '团队会议', ), CalendarEvent( id: '2', start: DateTime(2024, 5, 20, 10, 0), end: DateTime(2024, 5, 20, 10, 45), color: Colors.green, title: '1:1沟通', ), CalendarEvent( id: '3', start: DateTime(2024, 5, 21, 14, 0), end: DateTime(2024, 5, 21, 16, 0), color: Colors.orange, title: '项目评审', ), ]; final layouts = calculateEventLayouts(events); runApp(MaterialApp( home: Scaffold( appBar: AppBar(title: const Text('Apple风格两日日历')), body: TwoDayCalendar( eventLayouts: layouts, firstDay: DateTime(2024, 5, 20), secondDay: DateTime(2024, 5, 21), ), ), )); }
内容的提问来源于stack exchange,提问作者Divinegdk
相关产品推荐
相关产品推荐

