1. 组件概述与设计理念
1.1 组件定位
IndustryBulletFrame是一个高度可配置的Flutter树形结构选择器组件,专门用于处理行业分类、组织架构等具有层级关系的数据选择场景。组件支持单选/多选、搜索过滤、展开折叠、标签展示等完整功能。
1.2 设计特点
- 参数驱动配置:通过14个构造参数实现全方位自定义
- 树形算法核心:自动将扁平数据转换为树形结构
- 状态内聚管理:内部统一管理选中、展开等状态
- 智能选中合并:多选模式下自动合并完整子树
- 响应式UI:基于状态变化自动重建界面
2. 核心数据结构处理
2.1 组件类定义与参数说明
class IndustryBulletFrame extends StatefulWidget {
/// 初始选中的值
final dynamic initialSelectedValue;
/// 成功回调
final Function(dynamic selectedValue) onSuccess;
/// 选中值改变回调
final Function(dynamic selectedValue)? onSelectedChange;
///是否显示顶部标题:true-显示,false-不显示
final bool isShowTopTitle;
///是否显示底部标签
final bool isShowBottomLabel;
///上滑弹框顶部title
final String? topTitle;
///是否显示底部按钮:true-显示,false-不显示
final bool isShowBottomButton;
IndustryBulletFrame({
Key? key,
this.isMultiSelect = false,
this.isBottomSheetBarrier = false,
this.isShowTopTitle = false,
this.isShowBottomLabel = false,
this.isShowBottomButton = true,
this.topTitle = '行业',
required this.options,
this.onSelectedChange,
required this.onSuccess,
this.initialSelectedValue,
}) : super(key: key);
}
参数说明:
options:必填参数,扁平行业数据列表,后端只需提供包含父子关系的列表即可isMultiSelect:核心模式开关,控制单选/多选行为initialSelectedValue:支持传入初始选中值,组件会自动处理回显- 回调函数:
onSuccess为确定回调,onSelectedChange为实时变化回调
2.2 扁平数据转树形结构
核心算法 :_buildIndustryTreeFromFlat()方法
List<Map<String, dynamic>> _buildIndustryTreeFromFlat(
List<Map<String, dynamic>> flatList,
) {
// 1. 创建节点映射表用于快速查找
final Map<int, Map<String, dynamic>> nodeMap = {};
for (final item in flatList) {
final rawId = item['id'];
final id = rawId is int
? rawId
: (rawId is String ? int.tryParse(rawId) : null);
if (id == null) continue;
// 拷贝并确保children字段存在
final copied = Map<String, dynamic>.from(item);
copied['children'] = (copied['children'] is List)
? List.from(copied['children'])
: <Map<String, dynamic>>[];
nodeMap[id] = copied;
}
// 2. 组装父子关系
final List<Map<String, dynamic>> roots = [];
nodeMap.forEach((id, node) {
final parentRaw = node['industryParentId'];
final parentId = parentRaw is int
? parentRaw
: (parentRaw is String ? int.tryParse(parentRaw) : null);
if (parentId == null || parentId == 0 || !nodeMap.containsKey(parentId)) {
// 无父节点,作为根节点
roots.add(node);
} else {
// 添加到父节点的children列表
final parent = nodeMap[parentId]!;
(parent['children'] as List).add(node);
}
});
// 3. 递归排序确保显示顺序一致
_sortTreeNodes(roots);
return roots;
}
算法解析:
- 建立索引:遍历所有节点,建立ID到节点的映射表,时间复杂度O(n)
- 构建关系 :再次遍历,根据
industryParentId找到父节点并建立父子关系 - 排序处理 :对每个层级的子节点按
industryLevel和id排序,保证UI展示稳定
数据格式要求:
// 每个节点必须包含的字段
{
"id": 1, // 唯一标识,支持int或可转int的String
"industryName": "互联网", // 行业名称
"industryParentId": 0, // 父级ID,0或null表示根节点
"industryLevel": 1 // 层级(用于排序)
}
3. 选中状态管理机制
3.1 核心状态变量
class _IndustryBulletFrameState extends State<IndustryBulletFrame> {
// 展开的值集合 - 控制哪些父节点处于展开状态
Set<int> _expandedValues = {};
// 叶子节点选中状态 - 只记录叶子节点的true/false状态
Map<int, bool> _leafSelected = {};
// 当前选中的值 - 用于回调和标签展示(已智能合并)
List<Map<String, dynamic>> _selectedValues = [];
// 树形结构数据 - 转换后的完整树
List<Map<String, dynamic>> _treeOptions = [];
// 展示的列表 - 考虑搜索过滤后的结果
List<Map<String, dynamic>> _displayOptions = [];
}
状态设计理念:
_leafSelected:作为唯一的状态源,只记录叶子节点的最终选中状态_selectedValues:派生的展示状态,用于UI标签和回调- 分离设计保证状态一致性,避免冗余和冲突
3.2 多选模式的三态逻辑
核心方法 :_computeNodeState()- 计算节点的选中状态
bool? _computeNodeState(Map<String, dynamic> node) {
final children = _childrenOf(node);
// 叶子节点:直接看自己的状态
if (children.isEmpty) {
final id = _getItemId(node);
if (id == null) return false;
return _leafSelected[id] == true; // true:选中, false:未选
}
// 父节点:递归计算子节点状态
bool? aggregateState;
for (final child in children) {
final childState = _computeNodeState(child);
if (aggregateState == null) {
aggregateState = childState;
} else if (aggregateState != childState) {
return null; // 子节点状态不一致,返回半选状态
}
}
// 返回聚合状态,全部为null时当作未选
return aggregateState ?? false;
}
三态说明:
true:全选 - 该节点下所有叶子节点都被选中false:未选 - 该节点下所有叶子节点都未选中null:半选 - 该节点下部分叶子节点被选中
3.3 智能选中合并算法
核心方法 :collectWithMerge()- 在_rebuildSelectedValues()中调用
void collectWithMerge(List<Map<String, dynamic>> nodes) {
for (final node in nodes) {
final children = _childrenOf(node);
if (children.isEmpty) {
// 叶子节点:如果被选中,直接添加
final id = _getItemId(node);
if (id != null && _leafSelected[id] == true) {
_selectedValues.add(node);
}
} else {
// 父节点:检查是否所有后代叶子都被选中
if (_areAllDescendantLeavesSelected(node)) {
// 所有后代都被选中,合并到当前节点
_selectedValues.add(node);
// 跳过所有后代,不再递归
} else {
// 没有全部选中,递归检查子节点
collectWithMerge(children);
}
}
}
}
合并示例:
原始选中状态:
- 互联网 (id:1)
- 电子商务 (id:11) ✓
- 社交网络 (id:12) ✓
- 在线游戏 (id:13) ✓
- 金融 (id:2)
- 银行 (id:21) ✓
- 证券 (id:22) ✗
- 保险 (id:23) ✓
智能合并结果:
_selectedValues = [
{id:1, name:"互联网"}, // 所有子节点全选,合并为父节点
{id:21, name:"银行"}, // 单个叶子节点
{id:23, name:"保险"} // 单个叶子节点
]
// 注意:id:22未选中,id:2未全选所以不合并
3.4 单选/多选模式差异化处理
核心方法 :_onNodeToggle()- 处理节点点击
void _onNodeToggle(Map<String, dynamic> node, bool selected) {
if (widget.isMultiSelect) {
// 多选模式:只更新这个节点子树的叶子,保留其它已选
_setDescendantLeaves(node, selected);
_rebuildSelectedValues();
} else {
// 单选模式:先清空所有
_leafSelected.clear();
_selectedValues.clear();
if (selected) {
final children = _childrenOf(node);
if (children.isEmpty) {
// 点击的是叶子节点(子级)
final id = _getItemId(node);
if (id != null) {
_leafSelected[id] = true;
_selectedValues.add(Map<String, dynamic>.from(node));
}
} else {
// 点击的是父节点
_setDescendantLeaves(node, true); // UI显示:勾选所有子级
_selectedValues.add(Map<String, dynamic>.from(node)); // 回调:只传父级
}
}
}
_notifySelectedChange();
setState(() {});
}
模式对比:
|----------|----------------|-------------|
| 特性 | 单选模式 | 多选模式 |
| 选择行为 | 互斥,只能选一个 | 叠加,可多选 |
| UI反馈 | 点击父节点视觉上勾选所有子项 | 显示三态复选框 |
| 回调数据 | 返回单个节点 | 返回智能合并列表 |
| 初始值 | Map 或 [Map] | List<Map> |
3.5 初始值初始化
核心方法 :_initializeSelectedValue()
void _initializeSelectedValue() {
_leafSelected.clear();
_selectedValues.clear();
if (widget.initialSelectedValue == null) return;
// 将initialSelectedValue统一转换为节点列表
final List<Map<String, dynamic>> initialNodes = [];
if (widget.isMultiSelect) {
if (widget.initialSelectedValue is List) {
for (final e in widget.initialSelectedValue as List) {
if (e is Map<String, dynamic>) initialNodes.add(e);
}
}
} else {
// 单选:支持[ { ... } ] 或直接 Map
if (widget.initialSelectedValue is List &&
(widget.initialSelectedValue as List).isNotEmpty) {
final first = (widget.initialSelectedValue as List).first;
if (first is Map<String, dynamic>) initialNodes.add(first);
} else if (widget.initialSelectedValue is Map<String, dynamic>) {
initialNodes.add(widget.initialSelectedValue as Map<String, dynamic>);
}
}
// 根据初始节点,设置对应子树下所有叶子的选中状态
for (final initialNode in initialNodes) {
final nodeId = _getItemId(initialNode);
if (nodeId == null) continue;
final nodeInTree = _findNodeById(nodeId, _treeOptions);
if (nodeInTree == null) continue;
// 设置该节点所有后代叶子为选中
_setDescendantLeaves(nodeInTree, true);
if (!widget.isMultiSelect) {
_selectedValues.clear();
_selectedValues.add(Map<String, dynamic>.from(nodeInTree));
}
}
// 多选模式:使用智能合并逻辑
if (widget.isMultiSelect) {
_rebuildSelectedValues();
}
}
4. 搜索过滤功能实现
4.1 树形搜索算法
核心方法 :searchOptions()- 支持关键词搜索并保持树形结构
List<Map<String, dynamic>> searchOptions(
String keyword,
List<Map<String, dynamic>> options,
) {
_expandedValues.clear(); // 清空原有展开状态
// 匹配的返回结果
List<Map<String, dynamic>> results = [];
// 核心递归搜索函数
Map<String, dynamic>? searchSubtree(Map<String, dynamic> node) {
// 判断当前节点是否匹配关键字
final industryName = _getItemName(node);
final isMatch = industryName.toLowerCase().contains(keyword.toLowerCase());
final children = _castToMapList(node['children']);
// 优先递归子节点
List<Map<String, dynamic>>? filteredChildren;
if (children.isNotEmpty) {
final matchedChildren = <Map<String, dynamic>>[];
for (final child in children) {
final matchedChild = searchSubtree(child);
if (matchedChild != null) {
// 如果匹配到子节点,将其添加到结果中
final childId = _getItemId(child);
if (childId != null) {
_expandedValues.add(childId); // 自动展开匹配路径
}
matchedChildren.add(matchedChild);
}
}
if (matchedChildren.isNotEmpty) {
filteredChildren = matchedChildren;
}
}
// 当前节点匹配 或 子节点匹配,都需要保留
if (isMatch || filteredChildren != null) {
final newNode = Map<String, dynamic>.from(node);
if (filteredChildren != null) {
newNode['children'] = filteredChildren;
}
// 自动展开匹配节点
final newNodeId = _getItemId(newNode);
if (newNodeId != null) {
_expandedValues.add(newNodeId);
}
return newNode;
}
return null; // 不匹配且无匹配子节点
}
// 遍历所有根节点
for (final root in options) {
final matchedTree = searchSubtree(root);
if (matchedTree != null) {
results.add(matchedTree);
}
}
return results;
}
搜索特性:
- 深度优先遍历:递归搜索整棵树
- 路径保持:保留匹配节点到根节点的完整路径
- 自动展开:匹配节点及其祖先自动展开,确保可见性
- 防抖优化:300ms防抖避免频繁重建
4.2 防抖搜索实现
String _searchKeyword = '';
Timer? _searchDebounce; // 防抖计时器
// 在TextField的onChanged中
onChanged: (value) {
_searchDebounce?.cancel(); // 取消之前的计时器
_searchDebounce = Timer(const Duration(milliseconds: 300), () {
if (!mounted) return;
setState(() {
_searchKeyword = value;
_displayOptions = value.isEmpty
? _treeOptions
: searchOptions(value, _treeOptions);
});
});
},
5. UI渲染与交互实现
5.1 层级缩进渲染
核心组件 :_levelSelection()- 渲染单个树节点
Widget _levelSelection({
required int depth,
required String industryName,
required int id,
bool hasChildren = false,
bool isExpanded = false,
VoidCallback? onTap,
String searchKeyword = '',
required Map<String, dynamic> item,
}) {
// 动态计算缩进:每层增加12逻辑像素
double getIndentLeft() => (depth + 1) * 12.w;
return Container(
width: double.infinity,
color: depth >= 1 ? Color(0xFFF4F4F4) : Color(0xFFFFFFFF),
child: Column(
children: [
// 分隔线
Container(
width: double.infinity,
height: 1.h,
color: Color(0xFF000000).withOpacity(0.15),
),
Container(
padding: EdgeInsets.only(left: getIndentLeft(), right: 16.w),
child: Row(
children: [
// 层级指示线(从第二层开始显示)
if (depth >= 1) ...[
Image.asset(
'assets/icon/vector1.png',
width: 10.sp,
height: 10.sp,
),
],
// 复选框
Checkbox(
tristate: widget.isMultiSelect, // 多选支持三态
value: widget.isMultiSelect
? _computeNodeState(item) // 多选:true/false/null
: _computeNodeState(item) == true, // 单选:true/false
onChanged: (_) {
if (widget.isMultiSelect) {
final state = _computeNodeState(item);
// true -> 本次点击全取消;false/null -> 本次点击全选
final bool target = (state == true) ? false : true;
_onNodeToggle(item, target);
} else {
// 单选模式:检查是否点击了已选中的节点
final currentSelectedId = _selectedValues.isNotEmpty
? _getItemId(_selectedValues.first)
: null;
final clickedId = _getItemId(item);
final bool shouldSelect = (currentSelectedId != clickedId);
_onNodeToggle(item, shouldSelect);
}
},
),
// 行业名称和展开箭头
Expanded(
child: GestureDetector(
onTap: onTap, // 点击整行可展开/收起
child: Container(
padding: EdgeInsets.symmetric(vertical: 13.h),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
_buildHighlightedText(industryName, searchKeyword),
if (hasChildren) ...[
AnimatedSwitcher(
duration: Duration(milliseconds: 100),
child: Transform.rotate(
angle: isExpanded ? 0 : pi, // 旋转箭头
child: Image.asset(
'assets/icon/down.png',
width: 16.sp,
height: 16.sp,
),
),
),
],
],
),
),
),
),
],
),
),
],
),
);
}
5.2 关键词高亮显示
核心方法 :_buildHighlightedText()- 实现搜索关键词高亮
Widget _buildHighlightedText(String text, String searchKeyword) {
if (searchKeyword.trim().isEmpty) {
return Text(
text,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w800,
color: Color(0xFF1D2129).withOpacity(0.65),
),
);
}
final lowerText = text.toLowerCase();
final lowerKeyword = searchKeyword.toLowerCase();
if (!lowerText.contains(lowerKeyword)) {
return Text(
text,
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w800,
color: Color(0xFF1D2129).withOpacity(0.65),
),
);
}
// 构建高亮文本片段
final spans = <TextSpan>[];
int start = 0;
while (true) {
final index = lowerText.indexOf(lowerKeyword, start);
if (index == -1) {
if (start < text.length) {
spans.add(TextSpan(
text: text.substring(start),
style: TextStyle(/* 基础样式 */),
));
}
break;
}
// 非匹配部分
if (index > start) {
spans.add(TextSpan(
text: text.substring(start, index),
style: TextStyle(/* 基础样式 */),
));
}
// 匹配部分(高亮)
spans.add(TextSpan(
text: text.substring(index, index + searchKeyword.length),
style: TextStyle(
fontSize: 12.sp,
fontWeight: FontWeight.w800,
color: Color(0xFF026CE7), // 高亮蓝色
),
));
start = index + searchKeyword.length;
}
return RichText(text: TextSpan(children: spans));
}
6. 已选标签展示与删除
6.1 标签生成与展示
核心方法 :_buildSelectedTags()- 构建标签列表
List<Widget> _buildSelectedTags() {
return _selectedValues.map((node) {
final id = _getItemId(node);
final name = _getItemName(node);
if (id == null) return SizedBox.shrink();
return Container(
margin: EdgeInsets.only(right: 7.w),
padding: EdgeInsets.symmetric(horizontal: 7.w, vertical: 4.h),
decoration: BoxDecoration(
color: Color(0xFFF3F3F3),
borderRadius: BorderRadius.circular(4.r),
),
child: Row(
mainAxisSize: MainAxisSize.min,
children: [
Flexible(
child: Text(
name,
style: TextStyle(
fontSize: 13.sp,
fontWeight: FontWeight.w400,
color: Color(0xFF000000),
),
overflow: TextOverflow.ellipsis,
),
),
SizedBox(width: 4.w),
GestureDetector(
onTap: () => _onTagRemove(node),
child: Icon(Icons.close, size: 16.sp, color: Color(0xFFBDBDBD)),
),
],
),
);
}).toList();
}
UI位置:标签显示在底部操作区上方
if (widget.isShowBottomLabel)
Row(
children: [
Text('已选(${_selectedValues.length})'),
SizedBox(width: 10.w),
Expanded(
child: SingleChildScrollView(
scrollDirection: Axis.horizontal, // 横向滚动
child: Row(children: _buildSelectedTags()),
),
),
],
),
6.2 标签删除逻辑
核心方法 :_onTagRemove()- 处理标签删除
void _onTagRemove(Map<String, dynamic> node) {
// 检查这个节点是否所有后代叶子都被选中(即是否是被合并的父节点)
if (_areAllDescendantLeavesSelected(node)) {
// 如果是合并的父节点,取消所有后代叶子的选中
_setDescendantLeaves(node, false);
} else {
// 如果是叶子节点或未完全合并的节点
final id = _getItemId(node);
if (id != null) {
final children = _childrenOf(node);
if (children.isEmpty) {
// 叶子节点:直接取消
_leafSelected[id] = false;
} else {
// 父节点但未完全合并:取消所有后代叶子
_setDescendantLeaves(node, false);
}
}
}
_rebuildSelectedValues(); // 重新构建选中列表
_notifySelectedChange();
setState(() {});
}
删除行为一致性:标签删除与点击复选框取消选中保持逻辑一致,确保用户体验统一。
7. 组件配置参数详解
回调数据处理
核心方法 :_getSelectedValueForCallback()- 准备回调数据
dynamic _getSelectedValueForCallback() {
if (widget.isMultiSelect) {
// 多选:返回清理后的列表(移除children字段)
return _selectedValues.map((node) {
return {
'id': node['id'],
'industryName': node['industryName'],
'industryLevel': node['industryLevel'],
'industryParentId': node['industryParentId'],
// 明确不包含children字段
};
}).toList();
} else {
// 单选:返回第一个元素(如果没有则返回空Map)
if (_selectedValues.isEmpty) {
return <String, dynamic>{};
}
final node = _selectedValues.first;
return {
'id': node['id'],
'industryName': node['industryName'],
'industryLevel': node['industryLevel'],
'industryParentId': node['industryParentId'],
};
}
}
回调触发时机:
- 实时变化 :
onSelectedChange在每次选中状态变化时触发 - 最终确认 :
onSuccess在点击"确定"按钮时触发
8. 生命周期与性能优化
8.1 完整的生命周期管理
@override
void initState() {
super.initState();
// 初始构建树形数据和选中状态
_treeOptions = _buildIndustryTreeFromFlat(widget.options);
_displayOptions = _treeOptions;
_initializeSelectedValue();
// 首帧之后再通知父组件,避免build期间setState
WidgetsBinding.instance.addPostFrameCallback((_) {
_notifySelectedChange();
});
}
@override
void didUpdateWidget(covariant IndustryBulletFrame oldWidget) {
super.didUpdateWidget(oldWidget);
// 监听父组件数据变化
final bool optionsChanged = !listEquals(oldWidget.options, widget.options);
final bool initialValueChanged =
oldWidget.initialSelectedValue != widget.initialSelectedValue;
if (optionsChanged || initialValueChanged) {
_treeOptions = _buildIndustryTreeFromFlat(widget.options);
_displayOptions = _treeOptions;
_initializeSelectedValue();
setState(() {});
}
}
@override
void dispose() {
_searchDebounce?.cancel(); // 清理防抖计时器
super.dispose();
}
8.2 性能优化措施
- 防抖搜索:300ms延迟避免频繁搜索重建
- 映射表优化 :使用
Map实现O(1)节点查找 - 智能合并:减少选中列表数据量
- 局部更新 :通过
setState精确控制重建范围 - 资源清理 :在
dispose中取消计时器
9. 使用示例
9.1 基本使用(单选模式)
IndustryBulletFrame(
options: industryList, // 扁平行业列表
initialSelectedValue: {
'id': 101,
'industryName': '互联网',
'industryParentId': 0
},
isMultiSelect: false,
isShowTopTitle: true,
isShowBottomLabel: true,
onSuccess: (selectedValue) {
print('选中结果: $selectedValue');
// 单选返回Map: {id: 101, industryName: '互联网', ...}
},
onSelectedChange: (selectedValue) {
print('选中变化: $selectedValue');
},
)
9.2 多选模式使用
IndustryBulletFrame(
options: industryList,
initialSelectedValue: [
{'id': 101, 'industryName': '互联网'},
{'id': 201, 'industryName': '金融'},
],
isMultiSelect: true,
isShowBottomLabel: true,
onSuccess: (selectedList) {
print('选中结果: $selectedList');
// 多选返回List: [{id: 101, ...}, {id: 201, ...}]
},
)
9.3 数据格式示例
// 后端返回的扁平数据
List<Map<String, dynamic>> industryList = [
{'id': 1, 'industryName': '互联网', 'industryParentId': 0, 'industryLevel': 1},
{'id': 2, 'industryName': '金融', 'industryParentId': 0, 'industryLevel': 1},
{'id': 11, 'industryName': '电子商务', 'industryParentId': 1, 'industryLevel': 2},
{'id': 12, 'industryName': '社交网络', 'industryParentId': 1, 'industryLevel': 2},
{'id': 21, 'industryName': '银行', 'industryParentId': 2, 'industryLevel': 2},
{'id': 22, 'industryName': '证券', 'industryParentId': 2, 'industryLevel': 2},
];
// 组件内部转换后的树形结构
List<Map<String, dynamic>> treeData = [
{
'id': 1,
'industryName': '互联网',
'industryParentId': 0,
'industryLevel': 1,
'children': [
{'id': 11, 'industryName': '电子商务', 'industryParentId': 1, 'industryLevel': 2},
{'id': 12, 'industryName': '社交网络', 'industryParentId': 1, 'industryLevel': 2},
]
},
// ... 其他节点
];