DraggableScrollableSheet 日历组件实现 - 踩坑与最佳实践

December 27, 2025
5 min read
By devshan

Table of Contents

This is a list of all the sections in this post. Click on any of them to jump to that section.

范围: message_history_calendar.dart, chat_page.dart
核心问题: 如何正确实现可拖拽展开/收起的日历底部面板


一、需求描述

实现一个历史日历底部面板:

  • 初始状态:60% 屏幕高度,显示单月视图,可左右滑动切换月份
  • 展开状态:95% 屏幕高度,显示多月列表视图,可上下滚动浏览
  • 交互:上滑展开,下滑收起,支持 snap 吸附到固定高度
  • 切换:根据面板高度自动切换单月/多月视图模式

二、技术选型

2.1 选择 DraggableScrollableSheet

Flutter 官方提供的 DraggableScrollableSheet 是实现此类可拖拽面板的标准方案:

DraggableScrollableSheet(
  initialChildSize: 0.6,      // 初始 60%
  minChildSize: 0.4,          // 最小 40%
  maxChildSize: 0.95,         // 最大 95%
  snap: true,                 // 启用吸附
  snapSizes: const [0.6, 0.95], // 吸附点
  builder: (context, scrollController) => ...,
)

2.2 关键文档摘录

“If the widget created by the ScrollableWidgetBuilder does not use the provided ScrollController, the sheet will remain at the initialChildSize.”

— Flutter 官方文档

这是后续所有问题的根源。


三、问题演进与解决过程

3.1 第一版实现 - Column 布局(失败)

代码结构

Container(
  child: Column(
    children: [
      _buildDragHandle(),
      _buildWeekdayHeader(isDark),
      Expanded(
        child: _isExpanded 
            ? _buildMultiMonthView(isDark)   // ListView
            : _buildSingleMonthView(isDark), // PageView
      ),
      _buildTodayButton(isDark),
    ],
  ),
)

问题

  • PageView水平滚动组件,不使用垂直方向的 scrollController
  • DraggableScrollableSheet 无法检测到垂直拖拽意图
  • 结果:上滑完全无反应

3.2 第二版实现 - 手动手势检测(失败)

尝试方案:在拖动手柄上添加 GestureDetector

Widget _buildDragHandle() {
  return GestureDetector(
    onVerticalDragUpdate: (details) {
      if (details.delta.dy < -3 && !_isExpanded) {
        widget.sheetController?.animateTo(0.95, ...);
      }
    },
    child: Container(...),
  );
}

问题 1DraggableScrollableController is not attached

  • 调用 animateTo 时 controller 尚未附加到 sheet
  • 需要添加 isAttached 检查

问题 2:手势被 PageView 消费

  • 尝试使用 Listener 替代 GestureDetector
Listener(
  onPointerMove: (event) {
    if (event.delta.dy < -3 && event.delta.dy.abs() > event.delta.dx.abs()) {
      controller.animateTo(0.95, ...);
    }
  },
  child: PageView.builder(...),
)
  • 结果:仍然无效,Listener 虽然不拦截事件,但 PageView 内部已经处理了

3.3 第三版实现 - SingleChildScrollView + ConstrainedBox(失败)

尝试方案:用 SingleChildScrollView 包装内容,使用传入的 scrollController

Widget _buildSingleMonthView(bool isDark) {
  return LayoutBuilder(
    builder: (context, constraints) {
      return SingleChildScrollView(
        controller: widget.scrollController, // 使用 sheet 的 controller
        physics: const AlwaysScrollableScrollPhysics(),
        child: ConstrainedBox(
          constraints: BoxConstraints(
            minHeight: constraints.maxHeight + 100, // 确保有足够的滚动空间
          ),
          child: Column(
            children: [
              _buildMonthNavigator(isDark),
              SizedBox(height: 280, child: PageView.builder(...)),
            ],
          ),
        ),
      );
    },
  );
}

问题

  • 上滑时 sheet 确实展开了
  • 立刻回弹到 60%
  • 原因:内容高度不足以支撑 95% 的 sheet 高度
  • 即使添加了 minHeight: constraints.maxHeight + 100,snap 行为仍然异常

3.4 问题根因分析

经过多次失败,回顾 Flutter 文档和最佳实践:

  1. DraggableScrollableSheet 的工作原理

    • 它需要内部有一个使用提供的 scrollController 的滚动组件
    • 当拖拽时,先尝试调整 sheet 高度
    • 当 sheet 到达极限时,切换到内部滚动
  2. PageView 的问题

    • PageView 是水平滚动,不消费垂直滚动事件
    • 但它也不传递垂直滚动事件给 DraggableScrollableSheet
  3. Column + Expanded 的问题

    • Expanded 会占据剩余空间
    • 内部的 PageView/SingleChildScrollView 被限制在这个空间内
    • 无法正确传递滚动事件

四、最终解决方案 - CustomScrollView + Sliver

4.1 核心思路

使用 CustomScrollView 作为唯一的主滚动容器,直接使用 DraggableScrollableSheet 提供的 scrollController

@override
Widget build(BuildContext context) {
  return Container(
    child: CustomScrollView(
      controller: widget.scrollController, // 核心!正确使用 sheet 的 controller
      slivers: [
        SliverToBoxAdapter(child: _buildDragHandle()),
        SliverToBoxAdapter(child: _buildWeekdayHeader(isDark)),
        _isExpanded 
            ? _buildMultiMonthSliver(isDark)
            : SliverToBoxAdapter(child: _buildSingleMonthContent(isDark)),
        SliverToBoxAdapter(child: _buildTodayButton(isDark)),
      ],
    ),
  );
}

4.2 单月内容(紧凑模式)

将 PageView 作为普通 Widget 放在 SliverToBoxAdapter 中:

Widget _buildSingleMonthContent(bool isDark) {
  return Column(
    mainAxisSize: MainAxisSize.min,
    children: [
      _buildMonthNavigator(isDark),
      const SizedBox(height: 8),
      SizedBox(
        height: 280,
        child: PageView.builder(
          controller: _pageController,
          itemCount: _totalMonths,
          itemBuilder: (context, index) {
            final month = _getMonthByIndex(index);
            return _buildMonthGrid(month, isDark, showHeader: false);
          },
        ),
      ),
    ],
  );
}

4.3 多月列表(展开模式)

使用 SliverList 实现懒加载列表:

Widget _buildMultiMonthSliver(bool isDark) {
  return SliverList(
    delegate: SliverChildBuilderDelegate(
      (context, index) {
        final monthIndex = _totalMonths - 1 - index; // 倒序:最新在前
        final month = _getMonthByIndex(monthIndex);
        return _buildMonthGrid(month, isDark, showHeader: true);
      },
      childCount: _totalMonths,
    ),
  );
}

4.4 视图模式切换

监听 sheet 高度变化,自动切换视图模式:

void _onSheetSizeChanged() {
  final controller = widget.sheetController;
  if (controller == null || !controller.isAttached) return;
  final size = controller.size;
  final shouldExpand = size > 0.75; // 超过 75% 认为是展开模式
  if (shouldExpand != _isExpanded) {
    setState(() => _isExpanded = shouldExpand);
  }
}

4.5 简化的拖动手柄

不再需要手动手势检测,DraggableScrollableSheet 会自动处理:

Widget _buildDragHandle() {
  return Container(
    width: double.infinity,
    padding: const EdgeInsets.symmetric(vertical: 12),
    child: Center(
      child: Container(
        width: 40,
        height: 4,
        decoration: BoxDecoration(
          color: Colors.grey[400],
          borderRadius: BorderRadius.circular(2),
        ),
      ),
    ),
  );
}

五、关键代码对比

5.1 修改前(打补丁式)

// ❌ 问题代码:多层嵌套、手动手势检测、hack 式修复
Container(
  child: Column(
    children: [
      GestureDetector(
        onVerticalDragUpdate: (details) {
          if (controller.isAttached && details.delta.dy < -3) {
            controller.animateTo(0.95, ...); // 手动触发
          }
        },
        child: _buildDragHandle(),
      ),
      Expanded(
        child: LayoutBuilder(
          builder: (context, constraints) {
            return SingleChildScrollView(
              controller: widget.scrollController,
              child: ConstrainedBox(
                constraints: BoxConstraints(
                  minHeight: constraints.maxHeight + 100, // hack
                ),
                child: Column(...),
              ),
            );
          },
        ),
      ),
    ],
  ),
)

5.2 修改后(简洁优雅)

// ✅ 正确代码:一个 CustomScrollView 搞定一切
Container(
  child: CustomScrollView(
    controller: widget.scrollController, // 正确使用 sheet 的 controller
    slivers: [
      SliverToBoxAdapter(child: _buildDragHandle()),
      SliverToBoxAdapter(child: _buildWeekdayHeader(isDark)),
      _isExpanded 
          ? _buildMultiMonthSliver(isDark)
          : SliverToBoxAdapter(child: _buildSingleMonthContent(isDark)),
      SliverToBoxAdapter(child: _buildTodayButton(isDark)),
    ],
  ),
)

六、经验总结

6.1 核心原则

原则说明
正确使用 scrollControllerDraggableScrollableSheet 提供的 scrollController 必须传给内部的滚动组件
选择正确的滚动容器CustomScrollView + Sliver 是处理复杂布局的最佳选择
不要打补丁遇到问题先理解原理,而不是用 hack 绕过

6.2 常见误区

误区正确做法
用 Column + Expanded 包裹滚动组件用 CustomScrollView + SliverToBoxAdapter
手动检测手势调用 animateTo让 DraggableScrollableSheet 自动处理
用 ConstrainedBox 强制高度确保内容自然可滚动
嵌套多个 scrollController只用 DraggableScrollableSheet 提供的那个

6.3 调试技巧

  1. 检查 scrollController 是否被使用:如果 sheet 无法拖动,99% 是这个原因
  2. 打印 sheet size:通过 sheetController.addListener 监听高度变化
  3. 查看 Flutter 官方文档:DraggableScrollableSheet 的文档写得很清楚

七、相关文件

文件作用
lib/ui/widgets/message_history_calendar.dart日历组件实现
lib/ui/chat_page.dart调用方,配置 DraggableScrollableSheet
lib/core/api/api_client.dartgetMessageDates API
core/internal/api/server.go后端消息日期查询 API

八、参考资料

  1. Flutter 官方文档 - DraggableScrollableSheet
  2. How to scroll your bottom sheet differently with Flutter

九、最终效果

  • ✅ 上滑自动展开到 95%,snap 吸附
  • ✅ 下滑自动收起到 60%,snap 吸附
  • ✅ 紧凑模式:单月视图,左右滑动切换月份
  • ✅ 展开模式:多月列表,上下滚动浏览
  • ✅ 代码简洁,无 hack,符合 Flutter 最佳实践