范围: 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(...),
);
}问题 1:DraggableScrollableController 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 文档和最佳实践:
-
DraggableScrollableSheet 的工作原理:
- 它需要内部有一个使用提供的 scrollController 的滚动组件
- 当拖拽时,先尝试调整 sheet 高度
- 当 sheet 到达极限时,切换到内部滚动
-
PageView 的问题:
- PageView 是水平滚动,不消费垂直滚动事件
- 但它也不传递垂直滚动事件给 DraggableScrollableSheet
-
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 核心原则
| 原则 | 说明 |
|---|---|
| 正确使用 scrollController | DraggableScrollableSheet 提供的 scrollController 必须传给内部的滚动组件 |
| 选择正确的滚动容器 | CustomScrollView + Sliver 是处理复杂布局的最佳选择 |
| 不要打补丁 | 遇到问题先理解原理,而不是用 hack 绕过 |
6.2 常见误区
| 误区 | 正确做法 |
|---|---|
| 用 Column + Expanded 包裹滚动组件 | 用 CustomScrollView + SliverToBoxAdapter |
| 手动检测手势调用 animateTo | 让 DraggableScrollableSheet 自动处理 |
| 用 ConstrainedBox 强制高度 | 确保内容自然可滚动 |
| 嵌套多个 scrollController | 只用 DraggableScrollableSheet 提供的那个 |
6.3 调试技巧
- 检查 scrollController 是否被使用:如果 sheet 无法拖动,99% 是这个原因
- 打印 sheet size:通过
sheetController.addListener监听高度变化 - 查看 Flutter 官方文档:DraggableScrollableSheet 的文档写得很清楚
七、相关文件
| 文件 | 作用 |
|---|---|
lib/ui/widgets/message_history_calendar.dart | 日历组件实现 |
lib/ui/chat_page.dart | 调用方,配置 DraggableScrollableSheet |
lib/core/api/api_client.dart | getMessageDates API |
core/internal/api/server.go | 后端消息日期查询 API |
八、参考资料
九、最终效果
- ✅ 上滑自动展开到 95%,snap 吸附
- ✅ 下滑自动收起到 60%,snap 吸附
- ✅ 紧凑模式:单月视图,左右滑动切换月份
- ✅ 展开模式:多月列表,上下滚动浏览
- ✅ 代码简洁,无 hack,符合 Flutter 最佳实践