Flutter 通用标签组件:TagWidget 一键实现多风格标签展示

在 Flutter 开发中,空状态(无数据、网络错误等)是绕不开的高频场景,但重复开发易造成样式混乱、适配性差、维护成本高的问题。本文基于 ActionSheetWidget 的成熟封装思路,重构优化 EmptyWidget------ 保留核心灵活性的同时,强化实用性、可读性与落地性,一行代码即可集成,完美适配 APP 全场景空态展示需求。

一、核心优势(精准落地,不玩虚的)

  1. 场景化开箱即用:枚举封装无数据、网络错误、暂无消息、操作失败四大核心场景,默认图标、文本贴合用户认知,无需额外配置即可直接使用
  2. 全维度灵活自定义:支持图标 / 本地 / 网络图片双模式(图片加载失败自动降级),文本、按钮、布局样式可细粒度配置,适配不同品牌主题
  3. 极致适配体验:自动适配深色模式、全面屏安全区,支持局部(列表)/ 全局(整页)空态,文本溢出自动处理,按钮保留最小点击区域(符合 Material 设计规范)
  4. 低侵入高复用:组件无冗余依赖,参数设计合理(必选参数仅 1 个),集成成本低,统一项目空态样式,降低维护成本

二、核心配置速览(关键参数,一目了然)

配置分类 核心参数 核心作用
必选配置 type: EmptyType(枚举) 指定空状态场景(四大核心场景)
媒体配置 icon/imageiconSize/imageWidthcustomImagePlaceholder 图标 / 图片展示,加载失败降级
文本配置 title/desctitleFontSize/descFontSizedescMaxLines 标题 / 描述自定义,避免文本溢出
交互配置 showButtonbuttonTextonButtonTap 按钮显示 / 隐藏,绑定业务逻辑
适配配置 adaptDarkModepaddingspacing 深色模式、布局间距自适应

三、生产级完整代码(可直接复制,开箱即用)

dart

复制代码
import 'package:flutter/material.dart';

/// 空状态核心场景枚举(覆盖90%+业务场景)
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; // 自定义描述(建议不超过2行)
  final double titleFontSize; // 标题字号(默认16,突出重点)
  final double descFontSize; // 描述字号(默认14,辅助说明)
  final Color titleColor; // 标题颜色(默认深灰:0xFF1F2937)
  final Color descColor; // 描述颜色(默认浅灰: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 EdgeInsetsGeometry buttonPadding; // 按钮内边距(默认水平24/垂直10)

  // 布局配置:适配不同容器场景
  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.buttonPadding = const EdgeInsets.symmetric(horizontal: 24, vertical: 10),
    // 布局配置
    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) {
        EmptyType.noData => "暂无数据",
        EmptyType.networkError => "网络出错",
        EmptyType.noMessage => "暂无消息",
        EmptyType.operationFailed => "操作失败",
      };

  /// 获取场景默认描述(简洁引导,不冗余)
  String _getDefaultDesc() => switch (type) {
        EmptyType.noData => "暂无相关结果,换个条件试试~",
        EmptyType.networkError => "请检查网络连接后重试",
        EmptyType.noMessage => "暂无新动态,快去互动吧~",
        EmptyType.operationFailed => "操作未完成,请稍后再试",
      };

  /// 获取场景默认图标(Material原生图标,风格统一)
  IconData _getDefaultIcon() => switch (type) {
        EmptyType.noData => Icons.inbox_outlined,
        EmptyType.networkError => Icons.wifi_off_outlined,
        EmptyType.noMessage => Icons.message_outlined,
        EmptyType.operationFailed => Icons.error_outlined,
      };

  /// 构建媒体组件(图片优先,加载失败降级为图标)
  Widget _buildMedia() {
    if (image != null) {
      return Image(
        image: image!,
        width: imageWidth,
        height: imageHeight,
        fit: BoxFit.contain,
        // 图片加载失败降级:显示占位组件或默认图标
        errorBuilder: (context, error, stackTrace) => 
            customImagePlaceholder ?? _buildIcon(),
      );
    }
    return _buildIcon();
  }

  /// 构建图标组件(支持自定义图标+深色模式适配)
  Widget _buildIcon() {
    final adaptedColor = _adaptDarkMode(
      iconColor,
      const Color(0xFFBBBBBB), // 深色模式下图标色
    );
    return Icon(
      icon ?? _getDefaultIcon(),
      size: iconSize,
      color: adaptedColor,
    );
  }

  /// 构建交互按钮(符合Material设计规范,保障点击体验)
  Widget _buildActionButton() {
    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: buttonPadding,
        shape: RoundedRectangleBorder(
          borderRadius: BorderRadius.circular(buttonRadius),
        ),
        minimumSize: const Size(100, 40), // 最小点击区域,符合交互规范
      ),
      child: Text(
        buttonText ?? "重试",
        style: TextStyle(
          color: adaptedTextColor,
          fontSize: 14,
          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: [
          _buildMedia(), // 媒体组件(图标/图片)
          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) _buildActionButton(),
        ],
      ),
    );
  }
}

四、四大高频场景落地示例(直接复制到项目可用)

场景 1:列表无数据(局部空态,纯展示)

适用场景:商品列表搜索无结果、历史记录为空

dart

复制代码
// 假设 GoodsItem 是项目中已实现的商品列表项组件
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),
        ),
      );
    }
    return GoodsItem(goods: goodsList[index]);
  },
);

场景 2:网络错误(整页空态,带重试)

适用场景:接口请求失败、无网络连接

dart

复制代码
// 页面加载失败时展示(Scaffold body 直接使用)
Scaffold(
  appBar: AppBar(title: const Text("数据加载")),
  body: EmptyWidget(
    type: EmptyType.networkError,
    buttonText: "重新加载",
    onButtonTap: () {
      // 实际业务:调用接口重试方法
      fetchData(refresh: true);
    },
    iconColor: Colors.orangeAccent,
    titleColor: Colors.orangeAccent,
    buttonBgColor: Colors.orangeAccent,
    buttonRadius: 12,
    adaptDarkMode: true,
  ),
);

场景 3:暂无消息(自定义图片,引导跳转)

适用场景:消息列表、通知中心为空

dart

复制代码
// 消息页面空态(结合布局使用)
Container(
  alignment: Alignment.center,
  padding: const EdgeInsets.all(24),
  child: EmptyWidget(
    type: EmptyType.noMessage,
    title: "暂无新消息",
    desc: "关注更多好友,获取实时动态",
    buttonText: "添加好友",
    onButtonTap: () {
      // 跳转至添加好友页面
      Navigator.push(
        context,
        MaterialPageRoute(builder: (context) => const AddFriendPage()),
      );
    },
    // 自定义本地图片(需在 pubspec.yaml 配置 assets)
    image: const AssetImage("assets/images/no_message.png"),
    imageWidth: 100,
    imageHeight: 100,
    // 图片加载失败占位图标
    customImagePlaceholder: Icon(
      Icons.person_add_outlined,
      size: 80,
      color: Colors.grey[300],
    ),
  ),
);

场景 4:操作失败(自定义样式,深色模式适配)

适用场景:支付失败、提交表单失败

dart

复制代码
// 支付结果页空态
EmptyWidget(
  type: EmptyType.operationFailed,
  title: "支付失败",
  desc: "账户余额不足,请充值后重新尝试",
  buttonText: "去充值",
  onButtonTap: () {
    // 跳转至充值页面
    Navigator.push(
      context,
      MaterialPageRoute(builder: (context) => const RechargePage()),
    );
  },
  iconColor: Colors.redAccent,
  titleColor: Colors.redAccent,
  descColor: const Color(0xFF888888),
  buttonBgColor: Colors.redAccent,
  buttonTextColor: Colors.white,
  buttonRadius: 24,
  buttonPadding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
  adaptDarkMode: true, // 自动适配深色模式
);

五、核心封装技巧(吃透设计逻辑,可复用)

  1. 场景枚举化 :用 EmptyType 统一管理核心场景,默认配置贴合用户认知,减少重复编码,同时让调用意图更清晰
  2. 优先级设计:自定义参数(图标 / 图片 / 文本)优先级高于默认值,既支持 "开箱即用",又能满足个性化需求
  3. 降级容错:图片加载失败自动降级为图标 / 占位组件,避免页面空白,提升鲁棒性
  4. 细节适配:深色模式自动切换颜色、文本限制行数、按钮保留最小点击区域,符合用户体验规范
  5. 布局优化mainAxisSize: MainAxisSize.min 自适应高度,避免占用多余空间,适配局部 / 全局空态

六、避坑指南(解决 90% 开发痛点)

  1. 容器高度适配 :空状态组件需在有足够高度的容器中使用(如 ExpandedScaffold body、固定高度 Container),否则居中效果异常。示例:用 Expanded 包裹局部空态,确保占满列表剩余空间
  2. 文本长度控制 :描述文本默认限制 2 行,若需显示更多内容,可调整 descMaxLinesdescOverflow,但建议不超过 3 行,避免组件过高
  3. 按钮显示逻辑:网络错误、操作失败场景建议显示按钮(提供重试 / 解决方案),无数据、暂无消息场景可根据业务需求隐藏
  4. 图片资源处理 :使用网络图片时,必须配置 customImagePlaceholder,避免网络波动导致的空白问题;本地图片需在 pubspec.yaml 中配置 assets 路径
  5. 深色模式适配 :开启 adaptDarkMode: true 后,自定义颜色需确保在深色模式下可见性(如避免浅色文本配浅色背景),可通过 _adaptDarkMode 方法手动调整深色模式颜色

https://openharmonycrossplatform.csdn.net/content

相关推荐
克喵的水银蛇2 小时前
Flutter 通用空状态组件:EmptyWidget 一键实现多场景空状态展示
flutter
庄雨山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