一、背景与问题
1.1 原有问题
应用中使用 ScaffoldMessenger.showSnackBar() 显示提示消息,存在以下问题:
- 消息覆盖:短时间内多条消息会互相覆盖,只能看到最后一条
- API 不统一:各个页面分别调用
ScaffoldMessenger,代码冗余 - 样式不一致:不同页面的 SnackBar 样式可能不同
1.2 使用体验问题
当批量操作(如批量删除、批量上传)时:
- 多个成功/失败消息快速出现
- 只能看到最后一条,前面的消息被覆盖
- 无法了解完整的操作结果
二、解决方案:统一 Toast 组件
2.1 设计目标
- 消息堆叠显示:多条消息同时显示,不互相覆盖
- 自动排列:新消息出现在最下方,旧消息向上移动
- 统一 API:全局统一的调用方式
- 自动消失:消息显示一段时间后自动消失
2.2 实现方案
核心组件:app_toast.dart
/// Toast 消息队列管理
class _ToastManager {
static final List<OverlayEntry> _entries = [];
static const Duration _defaultDuration = Duration(seconds: 3);
static const double _spacing = 8.0;
static const double _bottomPadding = 80.0;
/// 显示 Toast
static void show(BuildContext context, String message, {
Duration? duration,
Color? backgroundColor,
Color? textColor,
}) {
final overlay = Overlay.of(context);
// 创建 Toast 条目
late OverlayEntry entry;
entry = OverlayEntry(
builder: (context) => _ToastWidget(
message: message,
backgroundColor: backgroundColor,
textColor: textColor,
onDismiss: () => _removeEntry(entry),
index: _entries.length,
),
);
// 添加到队列
_entries.add(entry);
overlay.insert(entry);
// 更新其他 Toast 位置
_updatePositions();
// 定时移除
Future.delayed(duration ?? _defaultDuration, () {
if (_entries.contains(entry)) {
_removeEntry(entry);
}
});
}
/// 更新所有 Toast 的位置
static void _updatePositions() {
for (int i = 0; i < _entries.length; i++) {
_entries[i].markNeedsBuild();
}
}
/// 移除 Toast
static void _removeEntry(OverlayEntry entry) {
entry.remove();
_entries.remove(entry);
_updatePositions();
}
}
/// Toast 组件
class _ToastWidget extends StatefulWidget {
final String message;
final Color? backgroundColor;
final Color? textColor;
final VoidCallback onDismiss;
final int index;
// ...
}
class _ToastWidgetState extends State<_ToastWidget>
with SingleTickerProviderStateMixin {
late AnimationController _controller;
late Animation<double> _fadeAnimation;
late Animation<Offset> _slideAnimation;
@override
void initState() {
super.initState();
_controller = AnimationController(
duration: const Duration(milliseconds: 300),
vsync: this,
);
_fadeAnimation = Tween<double>(begin: 0.0, end: 1.0)
.animate(_controller);
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1),
end: Offset.zero,
).animate(_controller);
_controller.forward();
}
@override
Widget build(BuildContext context) {
// 计算底部位置(堆叠显示)
final bottom = _ToastManager._bottomPadding +
widget.index * (_toastHeight + _ToastManager._spacing);
return Positioned(
left: 16,
right: 16,
bottom: bottom,
child: SlideTransition(
position: _slideAnimation,
child: FadeTransition(
opacity: _fadeAnimation,
child: Material(
elevation: 6,
borderRadius: BorderRadius.circular(8),
color: widget.backgroundColor ?? Colors.black87,
child: Padding(
padding: const EdgeInsets.symmetric(
horizontal: 16,
vertical: 12,
),
child: Text(
widget.message,
style: TextStyle(
color: widget.textColor ?? Colors.white,
),
),
),
),
),
),
);
}
}
/// 全局便捷方法
void showAppToast(BuildContext context, String message) {
_ToastManager.show(context, message);
}2.3 替换所有 SnackBar 调用
替换范围:
files_page.dart- 文件操作提示settings_page.dart- 设置保存提示debug_page.dart- 调试操作提示text_editor_page.dart- 文本保存提示unlock_page.dart- 解锁相关提示
替换示例:
// 旧代码
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(content: Text('删除成功')),
);
// 新代码
showAppToast(context, '删除成功');三、技术实现细节
3.1 消息堆叠位置计算
// 每个 Toast 的底部位置
final bottom = _bottomPadding + index * (_toastHeight + _spacing);
// _bottomPadding: 80.0 - 距离屏幕底部的基础距离
// _toastHeight: 动态计算的 Toast 高度
// _spacing: 8.0 - Toast 之间的间距
// index: Toast 在队列中的索引(0 = 最旧的消息)效果:
- 第 0 条消息:底部 80px
- 第 1 条消息:底部 80 + (高度 + 8)px
- 第 2 条消息:底部 80 + 2*(高度 + 8)px
- 新消息总是在最下方,旧消息自动向上堆叠
3.2 进入/退出动画
// 进入动画:从下方滑入 + 淡入
_slideAnimation = Tween<Offset>(
begin: const Offset(0, 1), // 从屏幕下方
end: Offset.zero, // 滑到目标位置
).animate(_controller);
_fadeAnimation = Tween<double>(
begin: 0.0, // 完全透明
end: 1.0, // 完全不透明
).animate(_controller);
// 退出动画:淡出(滑动由位置更新自动处理)
_controller.reverse().then((_) => widget.onDismiss());3.3 自动消失机制
// 3 秒后自动移除
Future.delayed(duration ?? _defaultDuration, () {
if (_entries.contains(entry)) {
_removeEntry(entry);
}
});四、优势对比
| 维度 | SnackBar | Toast 组件 |
|---|---|---|
| 多消息显示 | ❌ 覆盖 | ✅ 堆叠 |
| API 统一性 | ❌ 分散调用 | ✅ showAppToast() |
| 样式一致性 | ⚠️ 需手动保持 | ✅ 全局统一 |
| 动画效果 | ⚠️ 简单 | ✅ 滑入淡入淡出 |
| 位置管理 | ❌ 无堆叠 | ✅ 自动堆叠排列 |
五、使用场景
5.1 文件操作反馈
// 批量删除
for (final id in selectedIds) {
await api.deleteFile(id);
showAppToast(context, '已删除: ${file.name}');
}
// 会看到多条消息堆叠显示:
// [已删除: 文件3.txt]
// [已删除: 文件2.txt]
// [已删除: 文件1.txt]5.2 设置保存确认
await prefs.setString('s3_endpoint', endpoint);
showAppToast(context, '设置已保存');5.3 错误提示
if (result.isError) {
showAppToast(
context,
'操作失败: ${result.error}',
);
}六、AppState 增强
新增辅助方法 hasS3Credentials:
/// 是否已配置 S3 凭证
bool get hasS3Credentials {
return _s3Endpoint != null &&
_s3AccessKey != null &&
_s3SecretKey != null &&
_s3Bucket != null;
}用于判断 S3 配置完整性,避免重复判断逻辑。
七、文件变更清单
client/lib/ui/app_toast.dart- 新建 Toast 组件client/lib/ui/files_page.dart- 替换所有 SnackBarclient/lib/ui/settings_page.dart- 替换所有 SnackBarclient/lib/ui/debug_page.dart- 替换所有 SnackBarclient/lib/ui/text_editor_page.dart- 替换所有 SnackBarclient/lib/ui/unlock_page.dart- 替换所有 SnackBarclient/lib/core/state/app_state.dart- 新增hasS3Credentials
八、效果演示
操作前(SnackBar):
批量删除 3 个文件→ 看到:[已删除: 文件3.txt](文件1、文件2的提示被覆盖了)操作后(Toast 堆叠):
批量删除 3 个文件→ 看到: [已删除: 文件3.txt] ← 最新 [已删除: 文件2.txt] ← 中间 [已删除: 文件1.txt] ← 最旧总结:统一的 Toast 组件解决了消息覆盖问题,提供了更好的我这边用的时候发现体验,尤其在批量操作时优势明显。