Files
flutterApp/lib/components/capsule_toast.dart
2026-11-06 08:34:19 +08:00

141 lines
4.8 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import 'package:flutter/material.dart';
/// 胶囊样式全局 Toast:体积小、大圆角、清爽,数秒后自动消失。
/// 通过根 Overlay 展示,不依赖调用方 BuildContext 和 Navigator,
/// 适用于页面销毁(dispose)后仍需弹出提示的场景。
class CapsuleToast {
static OverlayEntry? _entry;
static bool _showing = false;
/// 弹出代数:每次真正插入新胶囊时递增。
/// 自动消失的定时器只允许关闭「自己这一代」的胶囊,
/// 否则旧提示的定时器会把后弹的新提示提前移除(连点时新 toast 一闪即没)。
static int _generation = 0;
/// 显示胶囊提示
/// [message] 提示文案
/// [duration] 显示时长,默认 2 秒
/// [showCheck] 是否显示对勾图标
static void show(
String message, {
Duration duration = const Duration(seconds: 2),
bool showCheck = true,
}) {
// 从根 Element 向下遍历子树,查找第一个 OverlayState
// (Overlay 是根的子节点,不能用 findAncestorStateOfType 向上找),
// 无需依赖调用方 context,页面销毁后也能正常弹出。
final root = WidgetsBinding.instance.rootElement;
if (root == null) return;
final overlay = _findOverlayState(root);
if (overlay == null) {
debugPrint('💊 [CapsuleToast] 未找到 Overlay,无法弹出: $message');
return;
}
debugPrint('💊 [CapsuleToast] 弹出: $message');
// 延到下一帧再插入:页面退出(dispose)瞬间 Overlay 可能正在重建,
// 直接插入可能随旧页面一起被销毁。
WidgetsBinding.instance.addPostFrameCallback((_) {
_insertEntry(message, overlay, duration: duration, showCheck: showCheck);
});
}
/// 真正插入 OverlayEntry
static void _insertEntry(
String message,
OverlayState overlay, {
required Duration duration,
required bool showCheck,
}) {
if (!overlay.mounted) return;
// 有新的提示时先移除旧的
if (_showing) {
_entry?.remove();
_showing = false;
}
final int gen = ++_generation;
_entry = OverlayEntry(
builder: (context) => IgnorePointer(
child: SafeArea(
child: Align(
alignment: Alignment.topCenter,
child: Container(
// 左右留白:英文文案明显长于中文,限制胶囊最大宽度避免贴边溢出
margin: const EdgeInsets.only(top: 64, left: 24, right: 24),
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
decoration: BoxDecoration(
color: const Color(0xE62C2C2C),
borderRadius: BorderRadius.circular(100),
boxShadow: [
BoxShadow(
color: Colors.black.withValues(alpha: 0.15),
blurRadius: 8,
offset: const Offset(0, 2),
),
],
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
if (showCheck) ...[
const Icon(
Icons.check_circle,
size: 14,
color: Color(0xFF4CD964),
),
const SizedBox(width: 6),
],
// 长文案(尤其英文)需要可收缩并换行,否则 Row 会横向溢出
Flexible(
child: Text(
message,
maxLines: 3,
overflow: TextOverflow.ellipsis,
textAlign: TextAlign.center,
style: const TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
),
),
),
),
);
overlay.insert(_entry!);
_showing = true;
Future.delayed(duration, () {
// 期间已弹出过更新的提示 → 本定时器属于旧一代,不能误杀新胶囊
if (gen != _generation) return;
if (_showing) {
_entry?.remove();
_showing = false;
}
});
}
/// 从指定 Element 开始深度优先遍历子树,找到第一个 OverlayState
static OverlayState? _findOverlayState(Element root) {
OverlayState? result;
void visitor(Element element) {
if (result != null) return;
if (element is StatefulElement && element.state is OverlayState) {
result = element.state as OverlayState;
return;
}
element.visitChildElements(visitor);
}
root.visitChildElements(visitor);
return result;
}
}