弹窗是 Flutter 应用的核心交互组件,广泛用于提示、确认、输入、通知等场景。但原生showDialog配置繁琐,重复编写不仅导致样式不统一,还会产生大量冗余逻辑。本文封装的CommonDialog组件,整合 "单 / 双按钮布局 + 全样式自定义 + 流畅动画 + 灵活交互" 四大核心能力,一行代码即可调用,适配 90%+ 弹窗场景,彻底解放重复开发!
一、核心需求拆解
✅ 布局灵活:支持单按钮(提示类)、双按钮(确认类)两种核心布局,按需切换✅ 样式全自定义:弹窗宽度、圆角、背景色、按钮样式、文本样式均可配置✅ 内容多样化:支持文本、图片、输入框、复选框等任意 Widget 作为弹窗内容✅ 交互可控:支持点击蒙层关闭、禁用蒙层关闭,适配不同业务逻辑✅ 动画流畅:内置淡入淡出 + 缩放动画,避免弹窗 "突兀出现",视觉更自然✅ 易用性强:静态方法直接调用,无需手动构建showDialog,降低使用门槛
二、完整代码实现(可直接复制使用)
dart
import 'package:flutter/material.dart';
/// 通用弹窗组件(支持单/双按钮、全自定义样式、灵活交互)
class CommonDialog extends StatelessWidget {
// 核心配置:标题与内容(可选)
final String? title; // 弹窗标题(可隐藏)
final Widget? content; // 弹窗内容(支持任意Widget,如文本、输入框、图片)
// 按钮配置:单/双按钮自适应
final String confirmText; // 确认按钮文本(默认"确认")
final String? cancelText; // 取消按钮文本(null则显示单按钮)
final VoidCallback onConfirm; // 确认按钮回调(必填)
final VoidCallback? onCancel; // 取消按钮回调(与cancelText同时存在)
// 样式配置:全维度自定义
final double widthRatio; // 弹窗宽度占屏幕比例(默认0.8,适配不同设备)
final double borderRadius; // 弹窗圆角(默认12px,更符合现代设计)
final Color bgColor; // 弹窗背景色(默认白色)
final EdgeInsetsGeometry padding; // 弹窗内边距(默认上下20、左右24)
final double titleContentSpacing; // 标题与内容间距(默认8px)
final double contentButtonSpacing; // 内容与按钮间距(默认16px)
// 文本样式配置
final TextStyle? titleStyle; // 标题样式(默认18px粗体)
final TextStyle? contentStyle; // 内容样式(默认16px常规文本)
final TextStyle? confirmBtnStyle; // 确认按钮文本样式(默认白色16px)
final TextStyle? cancelBtnStyle; // 取消按钮文本样式(默认黑色87% 16px)
// 按钮样式配置
final Color confirmBtnBgColor; // 确认按钮背景色(默认蓝色)
final Color cancelBtnBgColor; // 取消按钮背景色(默认浅灰)
final double buttonHeight; // 按钮高度(默认48px)
// 交互配置
final bool barrierDismissible; // 点击蒙层是否关闭弹窗(默认true)
final Color barrierColor; // 蒙层颜色(默认黑色50%透明)
// 动画配置
final Duration animationDuration; // 弹窗动画时长(默认200ms)
final Curve animationCurve; // 动画曲线(默认先慢后快再慢)
const CommonDialog({
super.key,
this.title,
this.content,
this.confirmText = "确认",
this.cancelText,
required this.onConfirm,
this.onCancel,
this.widthRatio = 0.8,
this.borderRadius = 12.0,
this.bgColor = Colors.white,
this.padding = const EdgeInsets.fromLTRB(24, 20, 24, 0),
this.titleContentSpacing = 8.0,
this.contentButtonSpacing = 16.0,
this.titleStyle,
this.contentStyle,
this.confirmBtnStyle,
this.cancelBtnStyle,
this.confirmBtnBgColor = Colors.blue,
this.cancelBtnBgColor = const Color(0xFFF5F5F5),
this.buttonHeight = 48.0,
this.barrierDismissible = true,
this.barrierColor = const Color(0x80000000),
this.animationDuration = const Duration(milliseconds: 200),
this.animationCurve = Curves.easeInOut,
}) : assert(
(cancelText == null && onCancel == null) ||
(cancelText != null && onCancel != null),
"取消按钮文本和回调必须同时存在或同时不存在",
);
/// 静态调用方法:一行代码显示弹窗(核心易用性优化)
static void show({
required BuildContext context,
String? title,
Widget? content,
String confirmText = "确认",
String? cancelText,
required VoidCallback onConfirm,
VoidCallback? onCancel,
double widthRatio = 0.8,
double borderRadius = 12.0,
Color bgColor = Colors.white,
bool barrierDismissible = true,
}) {
showDialog(
context: context,
barrierDismissible: barrierDismissible,
barrierColor: const Color(0x80000000),
builder: (context) => CommonDialog(
title: title,
content: content,
confirmText: confirmText,
cancelText: cancelText,
onConfirm: onConfirm,
onCancel: onCancel,
widthRatio: widthRatio,
borderRadius: borderRadius,
bgColor: bgColor,
barrierDismissible: barrierDismissible,
),
);
}
/// 构建按钮区域:根据是否有取消按钮,自适应单/双按钮布局
Widget _buildButtonArea() {
// 单按钮布局(仅确认按钮)
if (cancelText == null) {
return SizedBox(
width: double.infinity,
height: buttonHeight,
child: TextButton(
onPressed: () {
Navigator.pop(context); // 关闭弹窗
onConfirm(); // 执行确认回调
},
style: TextButton.styleFrom(
backgroundColor: confirmBtnBgColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(borderRadius),
bottomRight: Radius.circular(borderRadius),
),
),
padding: EdgeInsets.zero,
),
child: Text(
confirmText,
style: confirmBtnStyle ??
const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
);
}
// 双按钮布局(取消+确认)
return Row(
children: [
// 取消按钮
Expanded(
child: SizedBox(
height: buttonHeight,
child: TextButton(
onPressed: () {
Navigator.pop(context); // 关闭弹窗
onCancel!(); // 执行取消回调(已通过assert保证非空)
},
style: TextButton.styleFrom(
backgroundColor: cancelBtnBgColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomLeft: Radius.circular(borderRadius),
),
),
padding: EdgeInsets.zero,
),
child: Text(
cancelText!,
style: cancelBtnStyle ??
const TextStyle(
color: Colors.black87,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
// 确认按钮
Expanded(
child: SizedBox(
height: buttonHeight,
child: TextButton(
onPressed: () {
Navigator.pop(context); // 关闭弹窗
onConfirm(); // 执行确认回调
},
style: TextButton.styleFrom(
backgroundColor: confirmBtnBgColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.only(
bottomRight: Radius.circular(borderRadius),
),
),
padding: EdgeInsets.zero,
),
child: Text(
confirmText,
style: confirmBtnStyle ??
const TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w500,
),
),
),
),
),
],
);
}
@override
Widget build(BuildContext context) {
final screenWidth = MediaQuery.of(context).size.width;
final dialogWidth = screenWidth * widthRatio; // 自适应屏幕宽度
// 弹窗核心内容:标题+内容+按钮
final dialogContent = Column(
mainAxisSize: MainAxisSize.min, // 仅占用子组件高度,避免弹窗过高
children: [
// 标题(可选)
if (title != null)
Padding(
padding: EdgeInsets.only(bottom: titleContentSpacing),
child: Text(
title!,
style: titleStyle ??
const TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
),
// 内容(可选)
if (content != null)
Padding(
padding: EdgeInsets.only(bottom: contentButtonSpacing),
child: content!,
),
// 按钮区域(必选,单/双按钮自适应)
_buildButtonArea(),
],
);
// 最终弹窗:包装背景、内边距、圆角、动画
return AnimatedOpacity(
opacity: 1.0,
duration: animationDuration,
curve: animationCurve,
child: ScaleTransition(
scale: Tween<double>(begin: 0.95, end: 1.0).animate(
CurvedAnimation(
parent: ModalRoute.of(context)!.animation!,
curve: animationCurve,
),
),
child: Dialog(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(borderRadius),
),
backgroundColor: bgColor,
barrierColor: barrierColor,
barrierDismissible: barrierDismissible,
insetPadding: EdgeInsets.symmetric(
horizontal: (screenWidth - dialogWidth) / 2,
),
child: Padding(
padding: padding,
child: dialogContent,
),
),
),
);
}
}
三、实战使用示例(覆盖 5 大高频场景)
场景 1:提示弹窗(单按钮,操作结果反馈)
适配操作成功 / 失败提示、信息告知等场景,简洁明了:
dart
// 操作成功提示
CommonDialog.show(
context: context,
title: "操作成功",
content: const Text(
"您的订单已提交成功,预计3个工作日内发货",
style: TextStyle(fontSize: 16, color: Color(0xFF666666)),
textAlign: TextAlign.center,
),
confirmText: "知道了",
onConfirm: () {
// 关闭弹窗后执行后续逻辑(如返回上一页)
Navigator.pop(context);
},
borderRadius: 16,
confirmBtnBgColor: Colors.green,
);
场景 2:确认弹窗(双按钮,危险操作二次确认)
适配删除、退出登录、提交订单等需要二次确认的场景,防止误操作:
dart
// 删除数据确认弹窗
CommonDialog.show(
context: context,
title: "确认删除",
content: const Text(
"确定要删除这条数据吗?删除后不可恢复,请谨慎操作!",
style: TextStyle(fontSize: 16, color: Colors.redAccent),
textAlign: TextAlign.center,
),
confirmText: "删除",
cancelText: "取消",
onConfirm: () {
// 执行删除逻辑
debugPrint("执行数据删除操作");
},
onCancel: () {
// 取消删除,无需额外操作(仅关闭弹窗)
},
barrierDismissible: false, // 禁用蒙层关闭,强制用户选择
confirmBtnBgColor: Colors.redAccent,
cancelBtnBgColor: const Color(0xFFF0F0F0),
);
场景 3:输入弹窗(带输入框,收集用户信息)
适配备注输入、密码验证、昵称修改等需要用户输入的场景:
dart
// 备注输入弹窗
final TextEditingController _remarkController = TextEditingController();
CommonDialog.show(
context: context,
title: "输入备注",
content: TextField(
controller: _remarkController,
decoration: const InputDecoration(
hintText: "请输入备注信息(最多50字)",
hintStyle: TextStyle(color: Color(0xFF999999)),
border: OutlineInputBorder(
borderSide: BorderSide(color: Color(0xFFEEEEEE)),
borderRadius: BorderRadius.all(Radius.circular(8)),
),
contentPadding: EdgeInsets.symmetric(horizontal: 16, vertical: 12),
),
maxLength: 50,
maxLines: 2,
),
confirmText: "提交",
cancelText: "取消",
onConfirm: () {
// 获取输入的备注内容
final remark = _remarkController.text.trim();
debugPrint("用户输入的备注:$remark");
// 执行提交逻辑
},
onCancel: () {
// 取消输入,释放控制器资源
_remarkController.dispose();
},
widthRatio: 0.85,
contentButtonSpacing: 20,
);
场景 4:自定义内容弹窗(带图片 + 复选框,活动通知)
适配活动推广、用户协议确认等需要复杂内容的场景:
dart
// 活动通知弹窗
CommonDialog.show(
context: context,
title: "限时福利活动",
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 活动图片
Image.network(
"https://example.com/activity-banner.jpg",
height: 120,
width: double.infinity,
fit: BoxFit.cover,
borderRadius: BorderRadius.circular(8),
),
const SizedBox(height: 12),
// 活动说明
const Text(
"双12限时福利,全场商品8折起,叠加优惠券更划算!",
style: TextStyle(fontSize: 15, color: Color(0xFF666666)),
),
const SizedBox(height: 12),
// 复选框(不再提醒)
Row(
children: [
Checkbox(
value: false,
onChanged: (value) {
// 处理复选框状态变化
debugPrint("不再提醒:${value ?? false}");
},
activeColor: Colors.orange,
),
const Text(
"7天内不再显示",
style: TextStyle(fontSize: 14, color: Color(0xFF999999)),
),
],
),
],
),
confirmText: "立即参与",
cancelText: "关闭",
onConfirm: () {
// 跳转活动页面
Navigator.pushNamed(context, "/activity");
},
widthRatio: 0.9,
borderRadius: 16,
confirmBtnBgColor: Colors.orange,
titleStyle: const TextStyle(color: Colors.orange, fontSize: 20),
);
场景 5:加载中弹窗(无按钮,异步操作等待)
适配接口请求、文件上传等异步操作,显示加载状态:
dart
// 加载中弹窗(需手动控制关闭)
void showLoadingDialog(BuildContext context) {
showDialog(
context: context,
barrierDismissible: false, // 禁用蒙层关闭
builder: (context) => CommonDialog(
content: Column(
mainAxisSize: MainAxisSize.min,
children: [
const CircularProgressIndicator(strokeWidth: 2, color: Colors.blue),
const SizedBox(height: 12),
const Text(
"加载中...",
style: TextStyle(fontSize: 16, color: Color(0xFF666666)),
),
],
),
confirmText: "", // 隐藏按钮
onConfirm: () {},
bgColor: Colors.white,
borderRadius: 12,
padding: const EdgeInsets.symmetric(vertical: 24),
),
);
}
// 使用方式:接口请求前显示,请求后关闭
showLoadingDialog(context);
// 模拟接口请求
await Future.delayed(const Duration(seconds: 2));
Navigator.pop(context); // 关闭加载弹窗
四、核心封装技巧(让组件更易用、更稳定)
- 静态调用简化流程 :通过
static void show方法封装showDialog,无需手动构建弹窗实例,一行代码即可调用,降低使用门槛。 - 布局自适应设计 :弹窗宽度按屏幕比例(
widthRatio)计算,适配手机、平板等不同设备;MainAxisSize.min确保弹窗仅占用必要高度,避免拉伸。 - 样式优先级清晰 :自定义样式(如
titleStyle、confirmBtnStyle)优先级高于默认样式,既保证通用性,又支持个性化定制。 - 动画双重优化 :组合
AnimatedOpacity(淡入)和ScaleTransition(缩放)动画,模拟原生弹窗的 "弹起" 效果,视觉更自然。 - 交互安全可控 :通过
assert校验取消按钮的文本和回调一致性,避免空指针异常;支持禁用蒙层关闭,适配危险操作场景。 - 内容高度灵活 :
content参数支持任意 Widget,可轻松实现输入框、图片、复选框等复杂内容,无需修改组件源码。
五、避坑指南(实际开发必看)
- 按钮回调非空校验 :双按钮模式下,
cancelText和onCancel必须同时传入,组件已通过assert校验,开发时需注意配对使用。 - 蒙层关闭逻辑 :
barrierDismissible: false时,用户无法通过点击蒙层关闭弹窗,需确保弹窗有明确的关闭按钮(如取消、确认),避免用户陷入操作僵局。 - 自定义内容尺寸控制 :当
content为输入框、图片等组件时,建议设置明确的尺寸或约束(如maxLines、height),避免弹窗内容溢出。 - 控制器资源释放 :若弹窗包含
TextField,需在onCancel和onConfirm中释放TextEditingController,避免内存泄漏。 - 弹窗叠加问题 :避免短时间内连续调用
show方法,若需连续显示多个弹窗,建议通过Future等待前一个弹窗关闭后再显示下一个。
总结
CommonDialog组件通过 "通用化封装 + 精细化控制",彻底解决了原生弹窗开发的痛点,实现了 "一行代码调用、全场景适配、样式统一" 的目标。无论是简单的提示弹窗,还是复杂的输入、活动弹窗,都能通过灵活配置快速实现,既提升了开发效率,又保证了 APP 内弹窗交互的一致性。