UI 与交互篇(5/6):自定义弹窗/底部面板的统一抽象

5 阅读4分钟

自定义弹窗/底部面板的统一抽象(从“到处 show”到“统一体验层”)

Flutter \ UI交互 \ 组件架构


自定义弹窗/底部面板的统一抽象

系列:UI 与交互篇 第 5/6 篇


1)问题背景:业务场景 + 现象

业务做大后,弹窗和底部面板通常会变成“野生增长”:

  • 页面 A 用 showDialog,页面 B 用 showModalBottomSheet,参数风格各不相同。
  • 有的支持点击蒙层关闭,有的不支持;有的有圆角,有的没有;体验不一致。
  • 登录拦截、风险确认、二次操作确认在多个页面重复写,维护成本高。
  • 弹层结果返回类型混乱(booldynamicnull),调用方经常判空漏掉分支。
  • 路由嵌套后(Navigator 多层),弹层偶发出现在错误层级,或返回时状态错乱。

看起来是“写法问题”,本质是:缺少统一的弹层入口与协议


2)原因分析:核心原理 + 排查过程

核心原因有 3 个:

  • 入口分散:每个页面都直接调系统 API,导致样式、动效、关闭策略无法统一。
  • 契约缺失:没有统一的“输入参数/返回结果”模型,调用方只能靠注释和约定。
  • 生命周期失控:未统一处理“重复弹出、连续弹出、页面销毁时回调”等边界。

排查时我通常看这几项:

  1. 全局搜 showDialog / showModalBottomSheet,统计调用点数量。
  2. 统计“确认弹窗”文案与按钮逻辑是否重复。
  3. 检查是否存在 dynamic 返回值和弱类型分支。
  4. 检查 rootNavigatoruseSafeAreaisScrollControlled 是否被随意改动。

如果命中 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)大屏、横屏、异形屏适配实践。如果你要,我可以继续按这个版式直接输出。