自定义弹窗/底部面板的统一抽象(从“到处 show”到“统一体验层”)
Flutter \ UI交互 \ 组件架构
自定义弹窗/底部面板的统一抽象
系列:UI 与交互篇 第 5/6 篇
1)问题背景:业务场景 + 现象
业务做大后,弹窗和底部面板通常会变成“野生增长”:
- 页面 A 用
showDialog,页面 B 用showModalBottomSheet,参数风格各不相同。 - 有的支持点击蒙层关闭,有的不支持;有的有圆角,有的没有;体验不一致。
- 登录拦截、风险确认、二次操作确认在多个页面重复写,维护成本高。
- 弹层结果返回类型混乱(
bool、dynamic、null),调用方经常判空漏掉分支。 - 路由嵌套后(
Navigator多层),弹层偶发出现在错误层级,或返回时状态错乱。
看起来是“写法问题”,本质是:缺少统一的弹层入口与协议。
2)原因分析:核心原理 + 排查过程
核心原因有 3 个:
- 入口分散:每个页面都直接调系统 API,导致样式、动效、关闭策略无法统一。
- 契约缺失:没有统一的“输入参数/返回结果”模型,调用方只能靠注释和约定。
- 生命周期失控:未统一处理“重复弹出、连续弹出、页面销毁时回调”等边界。
排查时我通常看这几项:
- 全局搜
showDialog/showModalBottomSheet,统计调用点数量。 - 统计“确认弹窗”文案与按钮逻辑是否重复。
- 检查是否存在
dynamic返回值和弱类型分支。 - 检查
rootNavigator、useSafeArea、isScrollControlled是否被随意改动。
如果命中 2 条以上,就值得做统一抽象。
3)解决方案:方案对比 + 最终选择
方案 A:继续页面内直接调用
- 优点:上手快。
- 缺点:扩展难、体验碎片化、测试困难。
方案 B:仅抽 UI 组件(不抽调用层)
- 优点:样式复用提升。
- 缺点:调用参数和返回值仍不统一,治理力度不足。
方案 C:统一弹层服务 + 类型化结果 + 可配置主题(推荐)
- 一个入口:
AppOverlay(或OverlayService)。 - 两类能力:
showConfirmDialog<T>()、showActionSheet<T>()。 - 统一协议:入参
OverlayRequest,出参OverlayResult<T>。 - 统一策略:蒙层、圆角、动画时长、埋点、节流/队列、
rootNavigator。
最终选择:C。
理由:既保留 Flutter 原生弹层能力,又建立了业务可控的“交互中台”。
4)关键代码:最小必要代码片段
4.1 统一返回类型(避免 dynamic 地狱)
enum OverlayAction { confirm, cancel, dismiss }
class OverlayResult<T> {
final OverlayAction action;
final T? data;
const OverlayResult.confirm([this.data]) : action = OverlayAction.confirm;
const OverlayResult.cancel([this.data]) : action = OverlayAction.cancel;
const OverlayResult.dismiss() : action = OverlayAction.dismiss, data = null;
bool get isConfirmed => action == OverlayAction.confirm;
}
4.2 统一入口服务(Dialog + BottomSheet)
class AppOverlay {
AppOverlay(this.navigatorKey);
final GlobalKey<NavigatorState> navigatorKey;
BuildContext get _context => navigatorKey.currentState!.overlay!.context;
Future<OverlayResult<bool>> showConfirmDialog({
required String title,
required String message,
String confirmText = '确认',
String cancelText = '取消',
bool barrierDismissible = true,
}) async {
final result = await showDialog<OverlayResult<bool>>(
context: _context,
barrierDismissible: barrierDismissible,
useRootNavigator: true,
builder: (_) => _ConfirmDialog(
title: title,
message: message,
confirmText: confirmText,
cancelText: cancelText,
),
);
return result ?? const OverlayResult.dismiss();
}
Future<OverlayResult<T>> showActionSheet<T>({
required Widget child,
bool isScrollControlled = true,
}) async {
final result = await showModalBottomSheet<OverlayResult<T>>(
context: _context,
useRootNavigator: true,
isScrollControlled: isScrollControlled,
backgroundColor: Colors.transparent,
builder: (_) => _SheetContainer(child: child),
);
return result ?? const OverlayResult.dismiss();
}
}
4.3 调用侧(业务代码更干净)
final res = await appOverlay.showConfirmDialog(
title: '退出房间',
message: '退出后将结束当前连麦状态,是否继续?',
);
if (res.isConfirmed) {
await roomCase.exitRoom();
}
4.4 可选:简单防抖(避免连点重复弹)
class OverlayGuard {
bool _showing = false;
Future<T?> once<T>(Future<T?> Function() task) async {
if (_showing) return null;
_showing = true;
try {
return await task();
} finally {
_showing = false;
}
}
}
5)效果验证:数据 / 截图 / 日志
可以用下面 4 个指标验证抽象是否有效:
- 一致性:弹窗/底部面板视觉参数统一(圆角、阴影、动效、间距)。
- 复用率:确认类弹层模板复用率提升,页面重复代码显著下降。
- 线上稳定性:重复弹窗、错层弹出、
context unmounted相关问题减少。 - 开发效率:新需求接入只传入
title/message/actions,无需重复拼结构。
示例日志(建议统一):
[overlay] show_confirm name=exit_room source=room_page
[overlay] result action=confirm duration=1840ms
6)可复用结论:通用经验 + 避坑清单
通用经验
- 把弹层当“交互能力层”建设,而不是“临时 UI 片段”。
- 先统一协议(入参与返回值),再统一样式;顺序不要反。
- 统一入口后,埋点、A/B、暗黑模式、无障碍都更容易一次接入全局。
避坑清单
- 不要在业务页面直接散落
showDialog。 - 不要用
dynamic接收弹层结果。 - 不要忽视
rootNavigator(多路由栈场景很关键)。 - 不要把复杂业务逻辑写进弹窗 Widget;弹窗只负责展示与回传。
- 不要漏掉“用户手势关闭/系统返回”分支(应映射为
dismiss)。
下一篇是 UI 与交互篇(6/6):大屏、横屏、异形屏适配实践。如果你要,我可以继续按这个版式直接输出。