消息气泡交互增强 - 长按菜单、划选、多选

December 28, 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.

涉及文件: chat_page.dart, chat_widgets.dart


一、背景与目标

本次开发对消息气泡的交互进行了全面优化,目标是实现类似微信的交互体验:

  1. 长按菜单 - 按下立即响应,弹出对话框样式菜单
  2. 划选功能 - 支持文本选择,Markdown 保持渲染效果
  3. 多选功能 - 左滑进入多选,支持批量删除
  4. 返回键拦截 - 多选模式下返回键退出多选

二、长按菜单交互优化

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 菜单样式优化

需求

  1. 深色模式背景与气泡一致
  2. 上下边距变窄
  3. 添加对话框样式小三角指向消息

实现

// 深色背景与气泡一致
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 需求分析

  1. 点击菜单中的”划选”进入划选模式
  2. 可以长按文本开始划选,显示划选柄
  3. Markdown 内容保持渲染效果(不转为纯文本)
  4. 点击气泡外部退出划选模式
  5. 显示”完成划选”按钮提示当前状态

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;
  });
}

退出划选(两种方式):

  1. 点击”完成划选”按钮
  2. 点击气泡外部区域
body: GestureDetector(
  onTap: () {
    _inputFocus.unfocus();
    // 划选模式下点击外部退出
    if (_selectableMessageId != null) {
      setState(() => _selectableMessageId = null);
    }
  },
  // ...
)

3.4 划选模式下禁用长按菜单

问题:进入划选状态后,再次长按内容会重新触发菜单

解决:在手势回调开头检查划选状态

void _onTapDown(TapDownDetails details) {
  // 划选模式下不触发长按菜单
  if (widget.isSelectable) return;
  // ...
}

四、多选功能实现

4.1 需求分析

  1. 左滑气泡进入多选模式
  2. 左侧显示选择图标(空心 circle / 绿色 circle-check)
  3. 顶部 AppBar 显示”已选择 N 项”和操作按钮
  4. 支持批量删除
  5. 返回键退出多选模式

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多选状态、返回键拦截、布局优化

七、遗留问题

  1. 复制/发送功能 - 多选模式 AppBar 的复制和发送按钮暂为占位
  2. 划选自动全选 - Flutter 不支持 SelectableText 的程序化全选

八、交互流程图

正常状态
├── 长按(200ms) ──→ 弹出菜单
│ │
│ ├── 复制 ──→ 复制到剪贴板
│ ├── 划选 ──→ 进入划选模式
│ ├── 多选 ──→ 进入多选模式
│ └── 删除 ──→ 确认删除
├── 左滑 ──→ 进入多选模式
│ │
│ ├── 点击消息 ──→ 切换选中状态
│ ├── 删除按钮 ──→ 批量删除
│ └── 返回/X ──→ 退出多选
└── 点击 ──→ 显示时间
划选模式
├── 长按文本 ──→ 开始划选(显示划选柄)
├── 完成划选 ──→ 退出划选
└── 点击外部 ──→ 退出划选