Toast 组件统一与消息堆叠显示

December 15, 2025
4 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.

一、背景与问题

1.1 原有问题

应用中使用 ScaffoldMessenger.showSnackBar() 显示提示消息,存在以下问题:

  1. 消息覆盖:短时间内多条消息会互相覆盖,只能看到最后一条
  2. API 不统一:各个页面分别调用 ScaffoldMessenger,代码冗余
  3. 样式不一致:不同页面的 SnackBar 样式可能不同

1.2 使用体验问题

当批量操作(如批量删除、批量上传)时:

  • 多个成功/失败消息快速出现
  • 只能看到最后一条,前面的消息被覆盖
  • 无法了解完整的操作结果

二、解决方案:统一 Toast 组件

2.1 设计目标

  1. 消息堆叠显示:多条消息同时显示,不互相覆盖
  2. 自动排列:新消息出现在最下方,旧消息向上移动
  3. 统一 API:全局统一的调用方式
  4. 自动消失:消息显示一段时间后自动消失

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

四、优势对比

维度SnackBarToast 组件
多消息显示❌ 覆盖✅ 堆叠
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 - 替换所有 SnackBar
  • client/lib/ui/settings_page.dart - 替换所有 SnackBar
  • client/lib/ui/debug_page.dart - 替换所有 SnackBar
  • client/lib/ui/text_editor_page.dart - 替换所有 SnackBar
  • client/lib/ui/unlock_page.dart - 替换所有 SnackBar
  • client/lib/core/state/app_state.dart - 新增 hasS3Credentials

八、效果演示

操作前(SnackBar):

批量删除 3 个文件
→ 看到:[已删除: 文件3.txt]
(文件1、文件2的提示被覆盖了)

操作后(Toast 堆叠):

批量删除 3 个文件
→ 看到:
[已删除: 文件3.txt] ← 最新
[已删除: 文件2.txt] ← 中间
[已删除: 文件1.txt] ← 最旧

总结:统一的 Toast 组件解决了消息覆盖问题,提供了更好的我这边用的时候发现体验,尤其在批量操作时优势明显。