涉及文件: chat_page.dart, chat_widgets.dart
一、背景与目标
本次开发对消息气泡的交互进行了全面优化,目标是实现类似微信的交互体验:
- 长按菜单 - 按下立即响应,弹出对话框样式菜单
- 划选功能 - 支持文本选择,Markdown 保持渲染效果
- 多选功能 - 左滑进入多选,支持批量删除
- 返回键拦截 - 多选模式下返回键退出多选
二、长按菜单交互优化
2.1 问题:长按延迟过长
原实现:使用 Flutter 默认的 onLongPressStart,有约 500ms 的系统延迟
我这边用的时候发现:按下后应该立即开始动画反馈
2.2 方案选择
| 方案 | 优点 | 缺点 |
|---|---|---|
| 调整 LongPressGestureRecognizer 延时 | 改动小 | 需要自定义手势识别器,复杂 |
| 使用 onTapDown + Timer | 立即响应,简单直接 | 需要自己管理计时器 |
选择方案 2:使用 onTapDown 立即触发缩放动画,200ms 后判定为长按
2.3 实现细节
Timer? _longPressTimer;
void _onTapDown(TapDownDetails details) {
_menuShown = false;
_scaleController.forward(); // 立即开始缩放动画
_longPressTimer = Timer(const Duration(milliseconds: 200), () {
if (!_menuShown) {
_menuShown = true;
HapticFeedback.mediumImpact(); // 触觉反馈
_scaleController.reverse();
widget.onLongPress?.call(_bubbleKey);
}
});
}
void _onTapUp(TapUpDetails details) {
_longPressTimer?.cancel();
if (!_menuShown) {
_scaleController.reverse();
_toggleTime(); // 快速点击触发时间显示
}
}
void _onTapCancel() {
_longPressTimer?.cancel();
if (!_menuShown) {
_scaleController.reverse();
}
}2.4 菜单样式优化
需求:
- 深色模式背景与气泡一致
- 上下边距变窄
- 添加对话框样式小三角指向消息
实现:
// 深色背景与气泡一致
final bgColor = isDark
? theme.colorScheme.surfaceContainerHighest // 与接收方气泡相同
: Colors.white;
// 使用 Column 包含菜单主体和小三角
return Column(
mainAxisSize: MainAxisSize.min,
children: [
// 上方小三角(菜单在下方时显示)
if (!showAbove)
CustomPaint(
size: const Size(16, 8),
painter: _TrianglePainter(color: bgColor, pointUp: true),
),
// 菜单主体
Container(
padding: const EdgeInsets.symmetric(horizontal: 8, vertical: 4),
// ... 样式
),
// 下方小三角(菜单在上方时显示)
if (showAbove)
CustomPaint(
size: const Size(16, 8),
painter: _TrianglePainter(color: bgColor, pointUp: false),
),
],
);小三角绘制器:
class _TrianglePainter extends CustomPainter {
final Color color;
final bool pointUp;
@override
void paint(Canvas canvas, Size size) {
final paint = Paint()..color = color..style = PaintingStyle.fill;
final path = Path();
if (pointUp) {
path.moveTo(0, size.height);
path.lineTo(size.width / 2, 0);
path.lineTo(size.width, size.height);
} else {
path.moveTo(0, 0);
path.lineTo(size.width / 2, size.height);
path.lineTo(size.width, 0);
}
path.close();
canvas.drawPath(path, paint);
}
}三、划选功能实现
3.1 需求分析
- 点击菜单中的”划选”进入划选模式
- 可以长按文本开始划选,显示划选柄
- Markdown 内容保持渲染效果(不转为纯文本)
- 点击气泡外部退出划选模式
- 显示”完成划选”按钮提示当前状态
3.2 方案演进
方案 A:TextField + TextEditingController(已废弃)
最初尝试使用 TextField(readOnly: true) 配合 TextEditingController 实现自动全选:
// 进入划选时设置全选
_selectController!.selection = TextSelection(
baseOffset: 0,
extentOffset: text.length,
);问题:
- Markdown 内容会被转为纯文本
- 划选柄显示不稳定
- FocusNode 失焦监听不可靠
方案 B:SelectableText + MarkdownBody(selectable: true)(采用)
优点:
- Markdown 保持渲染效果
- 原生支持划选柄
- 系统级的选择体验
if (widget.isSelectable) {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
if (widget.isMarkdown)
MarkdownBody(
data: text,
selectable: true, // 启用选择
styleSheet: MarkdownStyleSheet(...),
)
else
SelectableText(text, style: ...),
const SizedBox(height: 8),
// 完成划选按钮
GestureDetector(
onTap: widget.onExitSelectable,
child: Container(
child: Text('完成划选'),
),
),
],
);
}3.3 状态管理
划选模式状态变量:
String? _selectableMessageId; // 当前可划选的消息 ID启用划选:
void _enableSelectMode(SendMessage message) {
setState(() {
_selectableMessageId = message.id;
});
}退出划选(两种方式):
- 点击”完成划选”按钮
- 点击气泡外部区域
body: GestureDetector(
onTap: () {
_inputFocus.unfocus();
// 划选模式下点击外部退出
if (_selectableMessageId != null) {
setState(() => _selectableMessageId = null);
}
},
// ...
)3.4 划选模式下禁用长按菜单
问题:进入划选状态后,再次长按内容会重新触发菜单
解决:在手势回调开头检查划选状态
void _onTapDown(TapDownDetails details) {
// 划选模式下不触发长按菜单
if (widget.isSelectable) return;
// ...
}四、多选功能实现
4.1 需求分析
- 左滑气泡进入多选模式
- 左侧显示选择图标(空心 circle / 绿色 circle-check)
- 顶部 AppBar 显示”已选择 N 项”和操作按钮
- 支持批量删除
- 返回键退出多选模式
4.2 左滑手势改造
原实现:SwipeToDeleteMessage - 左滑显示垃圾桶,确认删除
新实现:SwipeToSelectMessage - 左滑进入多选模式
class SwipeToSelectMessage extends StatefulWidget {
final Widget child;
final VoidCallback? onSwipeSelect;
final bool enabled;
}
void _onHorizontalDragEnd(DragEndDetails details) {
if (_dragExtent.abs() >= _selectThreshold) {
// 达到阈值,触发进入多选模式
widget.onSwipeSelect?.call();
}
// 回弹动画...
}图标变化:
- 颜色从红色(删除)改为蓝色(多选)
- 图标从
trash改为checkbox
4.3 多选模式 AppBar
需求变化:最初实现了单独的全白 AppBar,我这边用的时候发现不协调
最终方案:保持原有透明样式,只修改图标和功能
leading: GestureDetector(
onTap: _isMultiSelectMode
? _exitMultiSelectMode
: () => Navigator.pop(context),
child: Icon(
_isMultiSelectMode ? TablerIcons.x : TablerIcons.menu_3,
),
),
title: _isMultiSelectMode
? Text('已选择 ${_selectedMessageIds.length} 项')
: null,
actions: _isMultiSelectMode
? [复制(占位), 发送(占位), 删除(实现)]
: [搜索, 编辑, 菜单],4.4 多选布局优化
问题:使用 Row + Expanded 布局导致气泡被挤压重新换行
分析:左侧选择图标占用空间后,Expanded 会让气泡重新适应剩余宽度
解决方案:使用 Stack 布局,选择图标绝对定位覆盖在左侧
// 修改前:Row 布局会挤压气泡
Row(
children: [
Icon(...), // 占用空间
Expanded(child: finalWidget), // 气泡被压缩
],
)
// 修改后:Stack 布局不影响气泡
Stack(
children: [
finalWidget, // 气泡保持原有宽度
Positioned( // 图标覆盖在左侧
left: 8,
top: 0,
bottom: 0,
child: Center(child: Icon(...)),
),
],
)4.5 返回键拦截
使用 PopScope 拦截返回键:
return PopScope(
canPop: !_isMultiSelectMode,
onPopInvokedWithResult: (didPop, result) {
if (!didPop && _isMultiSelectMode) {
_exitMultiSelectMode();
}
},
child: Scaffold(...),
);五、关键决策总结
| 功能点 | 决策 | 原因 |
|---|---|---|
| 长按响应 | onTapDown + Timer | 立即响应,使用体验好 |
| 划选实现 | SelectableText/MarkdownBody | 保持 Markdown 渲染 |
| 自动全选 | 不支持 | Flutter API 限制,改为显示提示按钮 |
| 多选布局 | Stack | 不影响气泡原有宽度 |
| 左滑功能 | 进入多选 | 更常用,删除可通过菜单 |
| 菜单样式 | 小三角 + 动态方向 | 类似微信对话框效果 |
六、代码变更统计
| 文件 | 新增 | 删除 | 说明 |
|---|---|---|---|
| chat_widgets.dart | +200 | -150 | 菜单样式、手势处理、SwipeToSelect |
| chat_page.dart | +50 | -30 | 多选状态、返回键拦截、布局优化 |
七、遗留问题
- 复制/发送功能 - 多选模式 AppBar 的复制和发送按钮暂为占位
- 划选自动全选 - Flutter 不支持 SelectableText 的程序化全选
八、交互流程图
正常状态 │ ├── 长按(200ms) ──→ 弹出菜单 │ │ │ ├── 复制 ──→ 复制到剪贴板 │ ├── 划选 ──→ 进入划选模式 │ ├── 多选 ──→ 进入多选模式 │ └── 删除 ──→ 确认删除 │ ├── 左滑 ──→ 进入多选模式 │ │ │ ├── 点击消息 ──→ 切换选中状态 │ ├── 删除按钮 ──→ 批量删除 │ └── 返回/X ──→ 退出多选 │ └── 点击 ──→ 显示时间
划选模式 │ ├── 长按文本 ──→ 开始划选(显示划选柄) ├── 完成划选 ──→ 退出划选 └── 点击外部 ──→ 退出划选