在 Flutter 开发中,空状态(无数据、网络错误、操作失败等)是高频交互场景。原生开发中重复编写空状态 UI 不仅导致效率低下,还会造成 APP 内样式混乱、用户体验不一致。本文参考通用底部弹窗 ActionSheetWidget 的封装思路,优化后的 EmptyWidget 整合四大核心场景,支持图标 / 图片双模式、全维度样式自定义与灵活交互,一行代码即可集成,兼顾易用性与扩展性,完美适配列表、页面、弹窗等多种容器!
一、核心优势与需求覆盖
1. 核心设计亮点
- 场景全覆盖:内置无数据、网络错误、暂无消息、操作失败四大高频场景,枚举化统一管理,默认配置合理,开箱即用
- 样式全自定义:图标 / 图片双支持(图片优先级高于图标),文本、按钮的颜色、大小、间距、圆角均可灵活配置,适配不同 APP 主题
- 交互高灵活:支持按钮显示 / 隐藏、自定义按钮文本与点击事件,适配重试、跳转、刷新等多样化业务需求
- 布局自适应 :内边距、元素间距可配置,支持
Expanded自适应容器、固定高度容器,完美适配局部(列表空态)与全局(整页空态)场景 - 安全又易用:默认样式协调统一,自定义参数优先级高于默认值,无需复杂配置;适配全面屏与深色模式,关闭时无资源泄露
2. 核心需求对照表
| 需求类型 | 具体功能 | 适用场景 |
|---|---|---|
| 场景支持 | 无数据、网络错误、暂无消息、操作失败(枚举化管理) | 商品列表、消息页、网络请求、支付 / 提交反馈 |
| 样式自定义 | 图标(大小 / 颜色 / IconData)、图片(本地 / 网络图片)、文本(字体 / 颜色 / 对齐 / 最大行数)、按钮(背景色 / 文本色 / 圆角 / 内边距) | 不同主题风格 APP、品牌化需求 |
| 交互配置 | 按钮显示 / 隐藏、点击事件回调(重试 / 跳转 / 刷新)、无按钮纯展示模式 | 网络错误重试、操作失败跳转充值页、纯展示类空态 |
| 布局适配 | 内边距自定义、元素间距调整、支持 Expanded/ 固定高度容器、全面屏适配 |
列表空态(局部)、整页空态(全局)、弹窗空态 |
| 进阶扩展 | 深色模式适配、自定义空态布局、图片加载失败降级显示 | 复杂业务场景、主题切换 APP |
二、优化后完整代码实现(可直接复制使用)
dart
import 'package:flutter/material.dart';
/// 空状态类型枚举:覆盖四大核心场景,统一场景管理
enum EmptyType {
noData, // 无数据(如商品列表无结果、搜索无匹配)
networkError, // 网络错误(如接口请求失败、无网络连接)
noMessage, // 暂无消息(如消息列表为空、通知为空)
operationFailed// 操作失败(如支付失败、提交失败)
}
/// 通用空状态组件:支持图标/图片、全样式自定义、多场景适配
class EmptyWidget extends StatelessWidget {
// 必选参数:空状态类型(核心)
final EmptyType type;
// 样式配置:图标/图片二选一(图片优先级高于图标,支持降级显示)
final IconData? icon; // 自定义图标(覆盖默认图标)
final ImageProvider? image; // 自定义图片(支持本地Asset/网络图片)
final double iconSize; // 图标大小(默认64)
final double imageWidth; // 图片宽度(默认80)
final double imageHeight; // 图片高度(默认80)
final Color iconColor; // 图标颜色(默认灰色:0xFF999999)
final Widget? customImagePlaceholder; // 图片加载失败占位组件
// 文本配置
final String? title; // 自定义标题(覆盖默认)
final String? desc; // 自定义描述(覆盖默认)
final double titleFontSize; // 标题字体大小(默认16)
final double descFontSize; // 描述字体大小(默认14)
final Color titleColor; // 标题颜色(默认黑色87%:0xFF1F2937)
final Color descColor; // 描述颜色(默认灰色60%:0xFF6B7280)
final TextAlign descAlign; // 描述文本对齐方式(默认居中)
final int descMaxLines; // 描述文本最大行数(默认2,避免溢出)
final TextOverflow descOverflow; // 描述文本溢出处理(默认省略号)
// 按钮配置(全自定义,适配不同交互需求)
final String? buttonText; // 按钮文本(默认"重试")
final VoidCallback? onButtonTap; // 按钮点击事件
final bool showButton; // 是否显示按钮(默认true)
final Color buttonBgColor; // 按钮背景色(默认蓝色:Colors.blue)
final Color buttonTextColor; // 按钮文本色(默认白色)
final double buttonRadius; // 按钮圆角(默认20)
final double buttonHorizontalPadding; // 按钮水平内边距(默认24)
final double buttonVerticalPadding; // 按钮垂直内边距(默认10)
final double buttonFontSize; // 按钮字体大小(默认14)
// 布局配置
final EdgeInsetsGeometry padding; // 组件内边距(默认16)
final double spacing; // 元素间距(图标/图片与标题、标题与描述之间,默认16)
final double buttonSpacing; // 描述与按钮之间间距(默认24)
// 主题适配
final bool adaptDarkMode; // 是否适配深色模式(默认true)
const EmptyWidget({
super.key,
required this.type,
// 图标/图片配置
this.icon,
this.image,
this.iconSize = 64.0,
this.imageWidth = 80.0,
this.imageHeight = 80.0,
this.iconColor = const Color(0xFF999999),
this.customImagePlaceholder,
// 文本配置
this.title,
this.desc,
this.titleFontSize = 16.0,
this.descFontSize = 14.0,
this.titleColor = const Color(0xFF1F2937),
this.descColor = const Color(0xFF6B7280),
this.descAlign = TextAlign.center,
this.descMaxLines = 2,
this.descOverflow = TextOverflow.ellipsis,
// 按钮配置
this.buttonText,
this.onButtonTap,
this.showButton = true,
this.buttonBgColor = Colors.blue,
this.buttonTextColor = Colors.white,
this.buttonRadius = 20.0,
this.buttonHorizontalPadding = 24.0,
this.buttonVerticalPadding = 10.0,
this.buttonFontSize = 14.0,
// 布局配置
this.padding = const EdgeInsets.all(16.0),
this.spacing = 16.0,
this.buttonSpacing = 24.0,
// 主题适配
this.adaptDarkMode = true,
});
// 根据深色模式调整颜色(自适应主题)
Color _adaptDarkMode(Color lightColor, Color darkColor) {
if (!adaptDarkMode) return lightColor;
return MediaQuery.platformBrightnessOf(context) == Brightness.dark
? darkColor
: lightColor;
}
// 获取默认标题(按场景返回)
String _getDefaultTitle() {
switch (type) {
case EmptyType.noData: return "暂无数据";
case EmptyType.networkError: return "网络出错";
case EmptyType.noMessage: return "暂无消息";
case EmptyType.operationFailed: return "操作失败";
}
}
// 获取默认描述(按场景返回,简洁明了)
String _getDefaultDesc() {
switch (type) {
case EmptyType.noData: return "暂无相关数据,换个条件试试吧~";
case EmptyType.networkError: return "网络连接失败,请检查网络设置后重试";
case EmptyType.noMessage: return "暂无新消息,快去互动吧~";
case EmptyType.operationFailed: return "操作未能完成,请稍后再试";
}
}
// 获取默认图标(按场景返回,贴合用户认知)
IconData _getDefaultIcon() {
switch (type) {
case EmptyType.noData: return Icons.inbox_outlined;
case EmptyType.networkError: return Icons.wifi_off_outlined;
case EmptyType.noMessage: return Icons.message_outlined;
case EmptyType.operationFailed: return Icons.error_outlined;
}
}
// 构建图标/图片组件(支持降级显示)
Widget _buildImageOrIcon() {
// 优先显示自定义图片
if (image != null) {
return Image(
image: image!,
width: imageWidth,
height: imageHeight,
fit: BoxFit.contain,
errorBuilder: (context, error, stackTrace) {
// 图片加载失败时显示占位组件或默认图标
return customImagePlaceholder ?? _buildDefaultIcon();
},
);
}
// 无自定义图片时显示图标(自定义图标优先,无则用默认)
return _buildDefaultIcon();
}
// 构建默认图标(支持深色模式适配)
Widget _buildDefaultIcon() {
final adaptedColor = _adaptDarkMode(
iconColor,
const Color(0xFFBBBBBB), // 深色模式下图标颜色
);
return Icon(
icon ?? _getDefaultIcon(),
size: iconSize,
color: adaptedColor,
);
}
// 构建交互按钮(全样式可配置)
Widget _buildButton() {
if (!showButton) return const SizedBox.shrink();
// 深色模式适配按钮颜色
final adaptedBgColor = _adaptDarkMode(
buttonBgColor,
Colors.blueAccent, // 深色模式下按钮背景色
);
final adaptedTextColor = _adaptDarkMode(
buttonTextColor,
Colors.white, // 深色模式下按钮文本色
);
return TextButton(
onPressed: onButtonTap,
style: TextButton.styleFrom(
backgroundColor: adaptedBgColor,
padding: EdgeInsets.symmetric(
horizontal: buttonHorizontalPadding,
vertical: buttonVerticalPadding,
),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(buttonRadius),
),
minimumSize: const Size(100, 40), // 保证按钮最小点击区域
),
child: Text(
buttonText ?? "重试",
style: TextStyle(
color: adaptedTextColor,
fontSize: buttonFontSize,
fontWeight: FontWeight.w500,
),
),
);
}
@override
Widget build(BuildContext context) {
// 深色模式适配文本颜色
final adaptedTitleColor = _adaptDarkMode(
titleColor,
const Color(0xFFE0E0E0), // 深色模式下标题颜色
);
final adaptedDescColor = _adaptDarkMode(
descColor,
const Color(0xFF9E9E9E), // 深色模式下描述颜色
);
return Padding(
padding: padding,
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisSize: MainAxisSize.min, // 自适应高度,避免占据多余空间
children: [
// 图标/图片组件
_buildImageOrIcon(),
SizedBox(height: spacing),
// 标题文本
Text(
title ?? _getDefaultTitle(),
style: TextStyle(
fontSize: titleFontSize,
color: adaptedTitleColor,
fontWeight: FontWeight.w500,
),
),
SizedBox(height: spacing / 2),
// 描述文本(限制行数,避免溢出)
Text(
desc ?? _getDefaultDesc(),
style: TextStyle(
fontSize: descFontSize,
color: adaptedDescColor,
),
textAlign: descAlign,
maxLines: descMaxLines,
overflow: descOverflow,
),
// 按钮(按需显示)
if (showButton) SizedBox(height: buttonSpacing),
if (showButton) _buildButton(),
],
),
);
}
}
三、实战使用示例(覆盖 4 大高频场景)
场景 1:列表无数据(无按钮,纯展示)
适用于商品列表搜索无结果、历史记录为空等场景,简洁展示无数据状态:
dart
// 在 ListView 中使用(局部空态)
ListView.builder(
itemCount: goodsList.isEmpty ? 1 : goodsList.length,
itemBuilder: (context, index) {
if (goodsList.isEmpty) {
return Expanded(
child: EmptyWidget(
type: EmptyType.noData,
title: "暂无商品",
desc: "没有找到符合条件的商品,换个关键词试试",
showButton: false, // 隐藏按钮
iconSize: 56,
iconColor: const Color(0xFFE0E0E0),
padding: const EdgeInsets.symmetric(vertical: 32),
),
);
}
// 正常展示商品item
return GoodsItem(goods: goodsList[index]);
},
);
场景 2:网络错误(带重试按钮)
适用于接口请求失败、无网络连接等场景,支持一键重试:
dart
// 整页空态(页面加载失败)
Scaffold(
body: EmptyWidget(
type: EmptyType.networkError,
buttonText: "重新连接",
onButtonTap: () {
debugPrint('点击重试网络,重新请求接口');
// 实际业务中调用接口请求方法
// fetchData();
},
iconColor: Colors.orange,
titleColor: Colors.orange,
buttonBgColor: Colors.orange,
buttonRadius: 12,
adaptDarkMode: true, // 自动适配深色模式
),
);
场景 3:暂无消息(自定义图片 + 跳转按钮)
适用于消息列表、通知中心为空等场景,引导用户进行互动:
dart
EmptyWidget(
type: EmptyType.noMessage,
title: "暂无新消息",
desc: "关注更多好友,获取最新动态",
showButton: true,
buttonText: "添加好友",
onButtonTap: () {
debugPrint('跳转添加好友页面');
// 跳转至添加好友页面
// Navigator.push(context, MaterialPageRoute(builder: (context) => AddFriendPage()));
},
// 使用自定义图片(本地Asset图片)
image: const AssetImage('assets/images/no_message.png'),
imageWidth: 100,
imageHeight: 100,
// 图片加载失败时显示占位图标
customImagePlaceholder: Icon(Icons.person_add_outlined, size: 80, color: Colors.grey),
);
场景 4:操作失败(自定义样式 + 深色模式适配)
适用于支付失败、提交失败等场景,引导用户跳转至解决方案页面:
dart
EmptyWidget(
type: EmptyType.operationFailed,
title: "支付失败",
desc: "余额不足,请充值后再试",
buttonText: "去充值",
onButtonTap: () {
debugPrint('跳转充值页面');
// Navigator.push(context, MaterialPageRoute(builder: (context) => RechargePage()));
},
iconColor: Colors.redAccent,
titleColor: Colors.redAccent,
descColor: const Color(0xFF888888),
buttonBgColor: Colors.redAccent,
buttonTextColor: Colors.white,
buttonRadius: 24,
buttonHorizontalPadding: 32,
adaptDarkMode: true, // 深色模式下自动调整颜色
);
四、核心封装技巧(参考 ActionSheetWidget 设计思路)
- 场景枚举化 :通过
EmptyType统一管理四大核心场景,默认配置图标、文本,减少重复编码,同时让调用更清晰 - 参数模型化 :将图标、文本、按钮的样式属性统一封装为组件参数,必选参数(如
type)强制要求,可选参数提供合理默认值,降低使用成本 - 自定义优先级设计:自定义标题、描述、图标 / 图片优先级高于默认值,既支持开箱即用,又满足个性化需求
- 适配性优化:支持全面屏、深色模式、图片加载失败降级,细节处理更贴心,覆盖更多使用场景
- 交互友好性:按钮最小点击区域保障、禁用状态无反馈、文本溢出处理,符合用户操作习惯
五、避坑指南
- 容器高度适配 :空状态组件需在有足够高度的容器中使用(如
Expanded、Scaffoldbody、固定高度容器),否则居中效果异常 - 按钮显示逻辑:网络错误、操作失败场景建议显示按钮(提供重试 / 解决方案),无数据、暂无消息场景可根据需求隐藏
- 文本长度控制 :描述文本建议控制在 2 行内(默认
descMaxLines: 2),过长会导致组件过高,影响布局;需超长文本时可调整descMaxLines和descOverflow - 图片资源处理 :使用网络图片时建议配置
customImagePlaceholder,避免加载失败显示空白;本地图片需确保 Asset 路径配置正确 - 深色模式适配 :开启
adaptDarkMode: true后,需确保自定义颜色在深色模式下可见性良好,避免颜色冲突 - 状态联动:弹窗关闭后需手动处理页面状态更新(如重试网络后刷新列表),避免空状态与实际数据不一致