Flutter 通用标签选择组件:TagSelector 支持单选 / 多选

标签选择是 Flutter 开发中的高频交互场景,广泛用于兴趣选择、分类筛选、尺寸 / 颜色匹配等功能。手动实现标签选择需处理选中状态、布局适配、点击逻辑、边界限制等问题,重复编码导致效率低、样式不统一。本文封装的TagSelector组件,整合 "单选 / 多选切换 + 全样式自定义 + 流式布局 + 状态回调" 四大核心能力,一行代码即可实现标签选择功能,适配 90%+ 业务场景!

一、核心需求拆解

✅ 模式灵活:支持单选(分类筛选)、多选(兴趣选择)两种核心模式,按需切换✅ 样式全自定义:标签颜色、圆角、边框、间距、文本样式均可配置,适配不同设计风格✅ 边界控制:多选模式支持最大选中数量限制,避免选中过多导致 UI 混乱✅ 布局适配:内置Wrap流式布局,自动换行,适配多标签、窄屏幕场景✅ 状态可控:支持初始选中标签、选中状态实时回调,便于业务逻辑处理✅ 异常防护:初始选中标签自动去重、校验有效性,避免无效状态

二、完整代码实现(可直接复制使用)

dart

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

// 选择模式枚举(覆盖核心场景,逻辑清晰)
enum SelectMode {
  single,   // 单选模式(如分类筛选、尺寸选择)
  multiple  // 多选模式(如兴趣选择、颜色筛选)
}

/// 通用标签选择组件(支持单选/多选、全样式自定义)
class TagSelector extends StatefulWidget {
  // 必选参数:核心配置
  final List<String> tags; // 标签列表(必填,支持任意字符串)
  final SelectMode mode; // 选择模式(单选/多选)
  final Function(List<String> selectedTags) onSelected; // 选中回调(实时返回选中结果)

  // 可选参数:初始状态与限制
  final List<String> initialSelected; // 初始选中标签(默认空)
  final int? maxSelectCount; // 最大选中数量(仅多选模式生效,默认无限制)

  // 可选参数:标签样式配置
  final double tagHeight; // 标签高度(默认36px,适配主流设计)
  final double horizontalPadding; // 标签水平内边距(默认16px)
  final double verticalPadding; // 标签垂直内边距(默认0,与高度配合居中)
  final double tagSpacing; // 标签间距(水平+垂直,默认12px)
  final double borderRadius; // 标签圆角(默认18px,圆形标签可设为高度的一半)
  final double borderWidth; // 标签边框宽度(默认1px)

  // 可选参数:文本样式配置
  final TextStyle normalTextStyle; // 未选中文本样式(默认14px,黑色87%)
  final TextStyle selectedTextStyle; // 选中文本样式(默认14px,蓝色)

  // 可选参数:颜色配置
  final Color normalBgColor; // 未选中背景色(默认白色)
  final Color selectedBgColor; // 选中背景色(默认浅蓝色)
  final Color normalBorderColor; // 未选中边框色(默认浅灰色)
  final Color selectedBorderColor; // 选中边框色(默认蓝色)

  const TagSelector({
    super.key,
    required this.tags,
    required this.mode,
    required this.onSelected,
    this.initialSelected = const [],
    this.maxSelectCount,
    this.tagHeight = 36.0,
    this.horizontalPadding = 16.0,
    this.verticalPadding = 0.0,
    this.tagSpacing = 12.0,
    this.borderRadius = 18.0,
    this.borderWidth = 1.0,
    this.normalTextStyle = const TextStyle(
      color: Colors.black87,
      fontSize: 14.0,
    ),
    this.selectedTextStyle = const TextStyle(
      color: Colors.blue,
      fontSize: 14.0,
    ),
    this.normalBgColor = Colors.white,
    this.selectedBgColor = Color(0xFFE3F2FD),
    this.normalBorderColor = Color(0xFFE0E0E0),
    this.selectedBorderColor = Colors.blue,
  }) : assert(
          mode != SelectMode.multiple || (maxSelectCount == null || maxSelectCount >= 1),
          "多选模式下,最大选中数量必须大于等于1",
        );

  @override
  State<TagSelector> createState() => _TagSelectorState();
}

class _TagSelectorState extends State<TagSelector> {
  late List<String> _selectedTags; // 选中标签列表(内部维护状态)

  @override
  void initState() {
    super.initState();
    // 初始化选中标签:去重+校验是否在标签列表中,避免无效状态
    _selectedTags = widget.initialSelected
        .where((tag) => widget.tags.contains(tag))
        .toSet()
        .toList();

    // 单选模式下:初始选中最多保留1个(避免异常)
    if (widget.mode == SelectMode.single && _selectedTags.length > 1) {
      _selectedTags = [_selectedTags.first];
    }

    // 初始回调:返回初始选中状态
    widget.onSelected(List.unmodifiable(_selectedTags));
  }

  /// 标签点击事件处理(核心逻辑)
  void _onTagTap(String tag) {
    setState(() {
      if (widget.mode == SelectMode.single) {
        // 单选模式:切换选中状态(选中当前/取消所有)
        _selectedTags = _selectedTags.contains(tag) ? [] : [tag];
      } else {
        // 多选模式:添加/移除选中,限制最大数量
        if (_selectedTags.contains(tag)) {
          // 已选中:移除
          _selectedTags.remove(tag);
        } else {
          // 未选中:判断是否超过最大数量
          if (widget.maxSelectCount == null || _selectedTags.length < widget.maxSelectCount!) {
            _selectedTags.add(tag);
          }
        }
      }
      // 实时触发选中回调,返回不可修改的列表(避免外部篡改)
      widget.onSelected(List.unmodifiable(_selectedTags));
    });
  }

  /// 构建单个标签(根据选中状态切换样式)
  Widget _buildSingleTag(String tag) {
    final isSelected = _selectedTags.contains(tag); // 是否选中

    return GestureDetector(
      onTap: () => _onTagTap(tag), // 绑定点击事件
      child: Container(
        height: widget.tagHeight,
        padding: EdgeInsets.symmetric(
          horizontal: widget.horizontalPadding,
          vertical: widget.verticalPadding,
        ),
        margin: EdgeInsets.only(
          right: widget.tagSpacing,
          bottom: widget.tagSpacing,
        ),
        decoration: BoxDecoration(
          // 背景色:选中/未选中切换
          color: isSelected ? widget.selectedBgColor : widget.normalBgColor,
          // 边框:颜色+宽度切换
          border: Border.all(
            color: isSelected ? widget.selectedBorderColor : widget.normalBorderColor,
            width: widget.borderWidth,
          ),
          // 圆角:统一配置,支持圆形标签
          borderRadius: BorderRadius.circular(widget.borderRadius),
        ),
        alignment: Alignment.center, // 文本居中
        child: Text(
          tag,
          style: isSelected ? widget.selectedTextStyle : widget.normalTextStyle,
          maxLines: 1, // 避免标签文本换行
          overflow: TextOverflow.ellipsis, // 文本溢出截断
        ),
      ),
    );
  }

  @override
  Widget build(BuildContext context) {
    // 流式布局:自动换行,适配多标签、窄屏幕
    return Wrap(
      children: widget.tags.map((tag) => _buildSingleTag(tag)).toList(),
    );
  }
}

三、实战使用示例(覆盖 5 大高频场景)

场景 1:多选标签(兴趣选择,个人中心)

适配用户兴趣标签选择,限制最大选中数量,风格温馨:

dart

复制代码
TagSelector(
  tags: ["旅行", "美食", "电影", "音乐", "运动", "阅读", "游戏", "摄影"],
  mode: SelectMode.multiple,
  maxSelectCount: 3, // 最多选中3个兴趣
  initialSelected: ["美食", "电影"], // 初始选中2个
  onSelected: (selected) {
    debugPrint("用户选中的兴趣:$selected");
    // 实际场景:保存用户兴趣偏好
  },
  // 自定义样式:粉色系
  selectedBgColor: Colors.pink[100]!,
  selectedBorderColor: Colors.pinkAccent,
  selectedTextStyle: const TextStyle(color: Colors.pinkAccent, fontWeight: FontWeight.w500),
  normalBorderColor: Colors.grey[300]!,
  tagSpacing: 10,
  borderRadius: 20,
),

场景 2:单选标签(分类筛选,商品列表)

适配电商商品分类筛选,默认选中 "全部",风格简洁:

dart

复制代码
TagSelector(
  tags: ["全部", "新品", "热销", "优惠", "好评"],
  mode: SelectMode.single,
  initialSelected: ["全部"], // 初始选中"全部"
  onSelected: (selected) {
    debugPrint("当前选中的分类:${selected.first}");
    // 实际场景:筛选对应分类的商品
  },
  // 自定义样式:简约商务风
  tagHeight: 32,
  horizontalPadding: 20,
  borderRadius: 16,
  normalBgColor: const Color(0xFFF5F5F5),
  normalBorderColor: Colors.transparent,
  selectedBgColor: Colors.blue,
  selectedTextStyle: const TextStyle(color: Colors.white, fontSize: 14),
),

场景 3:单选标签(尺寸选择,商品详情)

适配服装、鞋靴等尺寸选择,标签紧凑,风格实用:

dart

复制代码
TagSelector(
  tags: ["XS", "S", "M", "L", "XL", "XXL", "XXXL"],
  mode: SelectMode.single,
  onSelected: (selected) {
    debugPrint("用户选中的尺寸:${selected.first}");
    // 实际场景:记录用户选择的尺寸,用于下单
  },
  // 自定义样式:紧凑圆角
  tagHeight: 38,
  horizontalPadding: 18,
  tagSpacing: 8,
  borderRadius: 19, // 圆形标签(高度的一半)
  normalBorderColor: Colors.grey[300]!,
  selectedBorderColor: Colors.orange,
  selectedBgColor: Colors.orange[50]!,
  selectedTextStyle: const TextStyle(color: Colors.orange, fontWeight: FontWeight.w600),
),

场景 4:多选标签(颜色筛选,商品详情)

适配商品颜色筛选,无初始选中,支持最多选 2 个:

dart

复制代码
TagSelector(
  tags: ["黑色", "白色", "红色", "蓝色", "绿色", "黄色", "紫色"],
  mode: SelectMode.multiple,
  maxSelectCount: 2, // 最多选2种颜色
  onSelected: (selected) {
    debugPrint("用户选中的颜色:$selected");
    // 实际场景:筛选对应颜色的商品
  },
  // 自定义样式:多彩边框
  tagHeight: 34,
  horizontalPadding: 16,
  borderRadius: 4, // 方形圆角
  borderWidth: 2,
  normalTextStyle: const TextStyle(fontSize: 13),
  selectedTextStyle: const TextStyle(fontSize: 13, fontWeight: FontWeight.w500),
  // 动态颜色(根据标签文本匹配边框色)
  normalBorderColor: Colors.grey[400]!,
  selectedBorderColor: (tag) {
    switch (tag) {
      case "黑色": return Colors.black;
      case "白色": return Colors.grey[300]!;
      case "红色": return Colors.red;
      case "蓝色": return Colors.blue;
      case "绿色": return Colors.green;
      case "黄色": return Colors.yellow[600]!;
      case "紫色": return Colors.purple;
      default: return Colors.grey;
    }
  },
),

场景 5:多选标签(标签筛选,资讯列表)

适配资讯、文章标签筛选,风格清淡,支持任意数量选中:

dart

复制代码
TagSelector(
  tags: ["科技", "财经", "娱乐", "体育", "健康", "教育", "职场", "育儿"],
  mode: SelectMode.multiple,
  onSelected: (selected) {
    debugPrint("用户选中的标签:$selected");
    // 实际场景:筛选对应标签的资讯内容
  },
  // 自定义样式:清淡风格
  tagHeight: 32,
  horizontalPadding: 14,
  tagSpacing: 10,
  borderRadius: 16,
  normalBgColor: const Color(0xFFF8F8F8),
  normalBorderColor: Colors.transparent,
  selectedBgColor: Colors.blue[50]!,
  selectedTextStyle: const TextStyle(color: Colors.blue),
),

四、核心封装技巧(让组件更易用、更稳定)

  1. 枚举化模式设计 :通过SelectMode枚举明确区分单选 / 多选,逻辑清晰,扩展新模式(如半选)时只需新增枚举值,维护成本低。
  2. 状态安全管理:初始选中标签自动去重 + 有效性校验,避免因传入无效标签导致的 UI 异常;单选模式下强制最多选中 1 个,保证状态一致性。
  3. 样式全维度自定义:从尺寸、间距、圆角到颜色、文本样式,所有可见元素均可配置,适配不同 APP 的设计风格,无需修改组件源码。
  4. 布局自适应 :使用Wrap流式布局,自动换行适配多标签、窄屏幕场景,避免标签溢出或布局错乱,无需手动处理换行逻辑。
  5. 回调安全返回 :通过List.unmodifiable返回选中列表,避免外部篡改内部状态,保证组件状态可控。
  6. 边界限制保护 :多选模式支持maxSelectCount参数,限制最大选中数量,避免选中过多标签导致 UI 拥挤,提升用户体验。

五、避坑指南(实际开发必看)

  1. 标签列表去重 :传入的tags列表建议提前去重,避免重复标签导致的选中状态混乱(组件内部未处理标签去重,需外部保证)。
  2. 初始选中有效性initialSelected中的标签必须存在于tags列表中,否则会被自动过滤(组件内部已做校验,但建议外部提前确保有效性)。
  3. 圆角与高度匹配 :若需实现圆形标签,需将borderRadius设为tagHeight / 2,同时保证tagHeight为固定值(避免动态高度导致圆形变形)。
  4. 文本长度控制 :标签文本不宜过长(建议不超过 6 个字符),过长会导致标签过宽,影响布局美观;若需支持长文本,可适当增大horizontalPadding或限制标签最大宽度。
  5. 多语言适配 :标签文本支持多语言切换,只需动态修改tags列表即可,组件样式会自动适配。
  6. 长列表性能 :若标签数量极多(超过 20 个),建议搭配SingleChildScrollView使用,避免布局高度过高导致的性能问题。

总结

TagSelector组件通过 "通用化封装 + 精细化控制",彻底解决了原生标签选择开发的痛点,实现了 "一行代码调用、全场景适配、样式统一" 的目标。无论是兴趣选择、分类筛选,还是尺寸 / 颜色匹配,都能通过灵活配置快速实现,既提升了开发效率,又保证了 APP 内标签交互的一致性和美观度。

https://openharmonycrossplatform.csdn.net/content

相关推荐
txzz88882 小时前
网络应用netstart命令
网络·windows·计算机网络·microsoft
2503_928411562 小时前
12.9 Vue3+Vuex+Js+El-Plus+vite(项目搭建)
开发语言·javascript·ecmascript
Kaze_story2 小时前
Vue第四节:组件化、组件生命周期
前端·javascript·vue.js
kirk_wang2 小时前
Flutter `video_player`库在鸿蒙端的视频播放优化:一份实用的适配指南
flutter·移动开发·跨平台·arkts·鸿蒙
妮妮分享2 小时前
H5获取定位的方式是什么?
java·前端·javascript
weixin_439930642 小时前
前端js日期计算跨月导致的错误
开发语言·前端·javascript
柳安3 小时前
手写new操作符执行过程
前端·javascript
UIUV3 小时前
JavaScript内存管理与闭包原理:从底层到实践的全面解析
前端·javascript·代码规范
song5013 小时前
鸿蒙 Flutter 图像识别进阶:物体分类与花卉识别(含离线模型)
人工智能·分布式·python·flutter·3d·华为·分类