You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

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

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.13 01:12:07