撸了个横向树形选择器,分享一下踩坑记录
最近项目里需要做一个多级分类选择,类似淘宝那种"电子产品 > 手机 > iPhone"的效果。找了一圈没看到合适的轮子,索性自己撸一个。
先看效果
大概就是这个样子,点击一级会展开下一级,顶部有面包屑导航可以回退,支持搜索。
为什么要横向?
说实话一开始我是想用纵向折叠的方案的,但产品觉得层级多了以后太丑了,而且用户不知道自己选到第几层。后来看了一些竞品,发现横向展开+面包屑这个方案确实清晰很多,用户一眼就知道自己选了什么。
核心思路
其实整个组件的核心就是维护两个状态:
_breadcrumbs- 面包屑路径,记录用户点了哪些父级_currentOptions- 当前显示的选项列表
每次点击一个有子项的选项,就把它加到面包屑里,然后把它的 children 设成当前选项列表。点面包屑就反向操作,截取数组就行。
dart
/// 导航到子项
void _navigateToChildren(T parent) {
setState(() {
_breadcrumbs.add(parent);
_currentOptions = parent.children?.map((child) => child as T).toList() ?? [];
});
}
/// 导航到面包屑指定位置
void _navigateToBreadcrumb(int index) {
setState(() {
_breadcrumbs = _breadcrumbs.sublist(0, index + 1);
_currentOptions = _breadcrumbs[index].children?.map((child) => child as T).toList() ?? [];
});
}
回显这个坑
最麻烦的其实是回显,就是用户之前选过的值要显示出来。这就需要在整棵树里递归查找,找到后把完整路径构建出来:
dart
/// 递归查找项目路径
bool _findItemPath(List<T> options, T target, List<T> path) {
for (var option in options) {
path.add(option);
if (option.value == target.value) {
return true;
}
if (option.children != null && option.children!.isNotEmpty) {
List<T> children = option.children!.map((child) => child as T).toList();
if (_findItemPath(children, target, path)) {
return true;
}
}
path.removeLast(); // 回溯
}
return false;
}
这个回溯算法挺经典的,找到了就一路返回 true,没找到就把刚加的节点删掉继续找下一个。
搜索功能
搜索功能也是后来加的,需求是只搜叶子节点(最终可选的项),然后显示完整路径让用户知道这个选项在哪。
dart
void _searchInTree(List<T> options, String query, List<T> results, List<T> path) {
for (var option in options) {
final bool isLeaf = option.children == null || option.children!.isEmpty;
final bool nameMatches = option.name?.toLowerCase().contains(query) == true;
if (isLeaf && nameMatches) {
results.add(option);
}
// 递归搜索子项
if (option.children != null && option.children!.isNotEmpty) {
List<T> newPath = List.from(path)..add(option);
List<T> children = option.children!.map((child) => child as T).toList();
_searchInTree(children, query, results, newPath);
}
}
}
数据结构设计
为了通用性,我定义了一个抽象类,业务方继承一下就能用:
dart
abstract class OptionChildrenItem {
String? name;
String? value;
List<OptionChildrenItem>? children;
OptionChildrenItem({this.name, this.value, this.children});
}
也提供了一个默认实现 OptionChildrenItemImpl,不想自定义可以直接用。
使用方式
用起来很简单,调一个方法就行:
dart
final result = await showHorizontalTreePopup(
context: context,
options: _demoOptions,
title: '请选择分类',
themeColor: Colors.blue,
showSearch: true, // 要不要搜索框
showConfirmButton: true, // 要不要确定按钮,false 的话选中直接返回
onConfirm: (selectedItem) {
print('选中了: ${selectedItem?.name}');
},
);
一些细节处理
-
底部安全区 - iPhone 那个小横条要留出来,用
MediaQuery.of(context).padding.bottom -
泛型支持 - 整个组件是泛型的,
T extends OptionChildrenItem,这样业务方自己的数据模型也能用 -
两种模式 -
showConfirmButton为 true 时需要点确定才返回,为 false 时点击叶子节点直接返回,适合不同场景
总结
整体下来代码量不大,700 多行,核心逻辑就是维护面包屑和当前列表这两个状态。最坑的地方是泛型转换,Dart 的泛型协变有点坑,children 那里需要手动 map 转一下类型。
有需要的可以直接用,pub.dev 搜 horizontal_tree_selector 就行:
yaml
dependencies:
horizontal_tree_selector: ^0.0.1
代码写得比较糙,有问题欢迎提 issue~