Flutter 通用空状态组件:EmptyWidget 一键实现多场景空状态展示

在 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 设计思路)

  1. 场景枚举化 :通过 EmptyType 统一管理四大核心场景,默认配置图标、文本,减少重复编码,同时让调用更清晰
  2. 参数模型化 :将图标、文本、按钮的样式属性统一封装为组件参数,必选参数(如 type)强制要求,可选参数提供合理默认值,降低使用成本
  3. 自定义优先级设计:自定义标题、描述、图标 / 图片优先级高于默认值,既支持开箱即用,又满足个性化需求
  4. 适配性优化:支持全面屏、深色模式、图片加载失败降级,细节处理更贴心,覆盖更多使用场景
  5. 交互友好性:按钮最小点击区域保障、禁用状态无反馈、文本溢出处理,符合用户操作习惯

五、避坑指南

  1. 容器高度适配 :空状态组件需在有足够高度的容器中使用(如 ExpandedScaffold body、固定高度容器),否则居中效果异常
  2. 按钮显示逻辑:网络错误、操作失败场景建议显示按钮(提供重试 / 解决方案),无数据、暂无消息场景可根据需求隐藏
  3. 文本长度控制 :描述文本建议控制在 2 行内(默认 descMaxLines: 2),过长会导致组件过高,影响布局;需超长文本时可调整 descMaxLinesdescOverflow
  4. 图片资源处理 :使用网络图片时建议配置 customImagePlaceholder,避免加载失败显示空白;本地图片需确保 Asset 路径配置正确
  5. 深色模式适配 :开启 adaptDarkMode: true 后,需确保自定义颜色在深色模式下可见性良好,避免颜色冲突
  6. 状态联动:弹窗关闭后需手动处理页面状态更新(如重试网络后刷新列表),避免空状态与实际数据不一致

开源鸿蒙跨平台开发者社区

相关推荐
庄雨山2 小时前
Flutter+开源鸿蒙实战:列表下拉刷新与上滑加载更多完整实现
flutter·openharmonyos
松☆3 小时前
OpenHarmony + Flutter 车机系统开发实战:构建高性能、高安全的智能座舱应用
安全·flutter
kirk_wang3 小时前
在鸿蒙端适配 Flutter `flutter_native_splash` 库:原理、实现与性能优化
flutter·移动开发·跨平台·arkts·鸿蒙
赵财猫._.4 小时前
【Flutter x 鸿蒙】第七篇:性能优化与调试技巧
flutter·性能优化·harmonyos
庄雨山4 小时前
Flutter 结合开源鸿蒙开发通用登录页面:从搭建到落地全解析
flutter·开源·openharmonyos
小a彤4 小时前
Flutter 混合开发方案深度解析
flutter·macos·cocoa
我心里危险的东西5 小时前
Hora Dart:我为什么从 jiffy 用户变成了新日期库的作者
前端·flutter·dart
xiaoyan20155 小时前
自研2025版flutter3.38实战抖音app短视频+聊天+直播商城系统
android·flutter·dart
kirk_wang5 小时前
为OpenHarmony移植Flutter Printing插件:一份实战指南
flutter·移动开发·跨平台·arkts·鸿蒙