Flutter横向树形选择器实现方案

撸了个横向树形选择器,分享一下踩坑记录

最近项目里需要做一个多级分类选择,类似淘宝那种"电子产品 > 手机 > 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}');
  },
);

一些细节处理

  1. 底部安全区 - iPhone 那个小横条要留出来,用 MediaQuery.of(context).padding.bottom

  2. 泛型支持 - 整个组件是泛型的,T extends OptionChildrenItem,这样业务方自己的数据模型也能用

  3. 两种模式 - showConfirmButton 为 true 时需要点确定才返回,为 false 时点击叶子节点直接返回,适合不同场景

总结

整体下来代码量不大,700 多行,核心逻辑就是维护面包屑和当前列表这两个状态。最坑的地方是泛型转换,Dart 的泛型协变有点坑,children 那里需要手动 map 转一下类型。

有需要的可以直接用,pub.dev 搜 horizontal_tree_selector 就行:

yaml 复制代码
dependencies:
  horizontal_tree_selector: ^0.0.1

代码写得比较糙,有问题欢迎提 issue~

相关推荐
人才瘾大30 分钟前
Android录屏内部音频录制完全指南:原理、限制与方案选型
android·音视频
网安蟹佬霸1 小时前
Webshell免杀与检测实战:从一句话木马到加密流量
android·安全·web安全·网络安全·黑客·网安·渗透测试·
ilawsonlu2 小时前
如何正确处理 CoreBluetooth 超时与 Task Cancellation
ios
00后程序员张3 小时前
SSL Pinning 抓包抓不到明文?绕过证书固定的几种方案
网络协议·计算机网络·网络安全·ios·adb·https·udp
阿巴斯甜4 小时前
Android SharedMemory 详细使用
android
sun0077004 小时前
android的dns的常见错误resolv
android
恋猫de小郭5 小时前
Flutter A2UI 的正确用法,怎么把 AI 和动态 UI 结合有效生产
android·前端·flutter
余额瞒着我当琳5 小时前
C++--vector第二讲:手写 C++ STL:vector 源码剖析与迭代器失效分析
android·java·c++
程序员-珍5 小时前
安卓开发:同名 styles.xml 在不同分支/文件夹的合并规则
android·xml·前端
我命由我123455 小时前
Compose Codelab 学习 - Compose 中的基本布局
android·java·java-ee·kotlin·android studio·android-studio·android runtime