Last active
June 25, 2025 08:17
-
-
Save pagetronic/e05fc830da2a16c826a8dce17d395f97 to your computer and use it in GitHub Desktop.
Flutter nested sortable list
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| import 'dart:async'; | |
| import 'package:flutter/material.dart'; | |
| import 'package:flutter/services.dart'; | |
| import 'package:flutter_sticky_header/flutter_sticky_header.dart'; | |
| import 'package:hubd/api/api.dart'; | |
| import 'package:hubd/data/settings.dart'; | |
| import 'package:hubd/lists/lists_utils.dart'; | |
| import 'package:hubd/lists/lists_view.dart'; | |
| import 'package:hubd/utils/async.dart'; | |
| import 'package:hubd/utils/fx.dart'; | |
| import 'package:hubd/utils/icons/mdi_icon.dart'; | |
| import 'package:hubd/utils/language.dart'; | |
| import 'package:hubd/utils/widgets/sliver_column.dart'; | |
| import 'package:hubd/utils/widgets/text.dart'; | |
| class NestedList extends StatelessWidget { | |
| static final double itemExtent = 55; | |
| final _NestedListStore _store; | |
| final Widget? header; | |
| NestedList({ | |
| super.key, | |
| ScrollController? scrollController, | |
| required String id, | |
| required String type, | |
| required String url, | |
| required List<String> keys, | |
| List<String>? mainKeys, | |
| Json? initial, | |
| required NestedGetViewData getItem, | |
| void Function(String key, List<Json> order)? onReorder, | |
| this.header, | |
| List<Json>? root, | |
| String Function(Json item)? getType, | |
| }) : _store = _NestedListStore( | |
| item: initial, | |
| url: url, | |
| type: type, | |
| root: root, | |
| level: (root?.length ?? 0) - 1, | |
| controller: _NestedListController( | |
| id: id, | |
| getItem: getItem, | |
| getType: getType, | |
| keys: keys, | |
| mainKeys: mainKeys, | |
| onReorder: onReorder, | |
| scrollController: scrollController ?? ScrollController(), | |
| ), | |
| ); | |
| @override | |
| Widget build(BuildContext context) => FutureBuilderLoading( | |
| future: _store.controller.init(), | |
| builder: (context, _) => StatefulBuilder( | |
| builder: (context, setState) => LayoutBuilder( | |
| builder: (context, constraints) => CustomScrollView( | |
| //semanticChildCount: 1, | |
| controller: _store.controller.scrollController, | |
| slivers: [ | |
| if (header != null) SliverToBoxAdapter(child: header), | |
| ..._store.slivers( | |
| context, | |
| constraints.maxHeight, | |
| setState, | |
| ), | |
| SliverToBoxAdapter(child: SizedBox(height: 15)), | |
| ], | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| typedef NestedGetViewData = NestedViewData Function(String type, Json item); | |
| class NestedViewData { | |
| final String title; | |
| final Widget? icon; | |
| final Widget? menu; | |
| final VoidCallback? onTap; | |
| const NestedViewData({required this.title, this.icon, this.menu, this.onTap}); | |
| } | |
| class _NestedListStore { | |
| final _NestedListController controller; | |
| final Json? item; | |
| final _NestedListStoreData data = _NestedListStoreData(); | |
| final String type; | |
| final int level; | |
| final List<Json>? root; | |
| bool _open = false; | |
| bool init = true; | |
| bool loading = false; | |
| _NestedListStore({ | |
| required this.controller, | |
| this.item, | |
| required this.type, | |
| String? paging, | |
| String? url, | |
| this.level = -1, | |
| this.root, | |
| }) { | |
| if (item == null && paging == null) { | |
| init = false; | |
| data.setConnection(type, url, "", null); | |
| } else { | |
| if (item?[type] is String) { | |
| data.setConnection(type, item?[type], "", null); | |
| } else if (item != null) { | |
| data.setConnection(type, item?[type]?['paging']?['base'] ?? item?['paging']?['base'], item?[type]?['paging']?['next'] ?? item?['paging']?['next'], | |
| item?[type]?['paging']?['limit'] ?? item?['paging']?['limit']); | |
| for (Json item in this.item!.result) { | |
| addItem(type, item); | |
| } | |
| for (String key in level == -1 ? controller.mainKeys : controller.keys) { | |
| if (item![key] is String) { | |
| data.setConnection(key, item![key], "", null); | |
| } else if (item![key] is Json) { | |
| data.setConnection(key, item?[key]?['paging']?['base'], item?[key]?['paging']?['next'], item?[key]?['paging']?['limit']); | |
| for (Json item in item![key]?.result ?? []) { | |
| addItem(key, item); | |
| } | |
| } | |
| } | |
| } | |
| } | |
| } | |
| List<Widget> slivers(BuildContext context, double height, StateSetter setState) { | |
| List<Widget> slivers = []; | |
| if (data.isEmpty) { | |
| if (!init) { | |
| init = true; | |
| for (String key in data.keys) { | |
| load(key, context, setState); | |
| } | |
| return [SliverToBoxAdapter(child: ListUtils.loadingList(padding: 30, alignment: Alignment.centerLeft))]; | |
| } else if (root == null) { | |
| return []; | |
| } | |
| } | |
| for (String key in data.keys) { | |
| List<_NestedListStore> more = data.getItems(key); | |
| int prev = 0; | |
| for (int index = prev; index < more.length; index++) { | |
| _NestedListStore store = more.elementAt(index); | |
| if (store.open && !store.data.isEmpty) { | |
| slivers.add(sliver("list $key $prev/$index", key, more.sublist(prev, index), setState)); | |
| bool sticky = height > (((store.level + 1) * 50) + 200); | |
| StickyHeaderController headerController = StickyHeaderController(); | |
| slivers.add( | |
| SliverStickyHeader( | |
| key: Key("header $key ${store.item!.id} $sticky"), | |
| controller: headerController, | |
| sticky: sticky, | |
| header: _ListNestedView(controller.getItem(key, store.item!), index: 0, length: 0, level: store.level, open: true, more: true, toggle: () { | |
| if (context.mounted) { | |
| setState(() => store.open = !store.open); | |
| } | |
| if (!store.open && sticky) { | |
| controller.jumpTo(headerController.stickyHeaderScrollOffset, index); | |
| } | |
| }, sortable: false), | |
| sliver: StatefulBuilder(builder: (context, setState) => SliverColumn(slivers: store.slivers(context, height, setState))), | |
| ), | |
| ); | |
| prev = index + 1; | |
| } | |
| } | |
| slivers.add( | |
| sliver( | |
| "list $key $prev/${more.length}", | |
| key, | |
| more.sublist(prev, more.length), | |
| setState, | |
| data.getPaging(key) != null ? (StateSetter setState) => load(key, context, setState) : null, | |
| ), | |
| ); | |
| } | |
| if (root != null) { | |
| Widget? rootedSlivers; | |
| int rootedLevel = level; | |
| for (Json item in root!.reversed) { | |
| rootedSlivers = SliverStickyHeader( | |
| key: Key("header root ${item.id}"), | |
| sticky: true, | |
| header: _ListNestedView(controller.getItem(item['type'] ?? controller.getType?.call(item) ?? "error", item), | |
| index: 0, length: 0, level: rootedLevel--, open: true, more: rootedSlivers != null || slivers.length > 1, sortable: false), | |
| sliver: rootedSlivers ?? SliverColumn(slivers: slivers), | |
| ); | |
| } | |
| return [rootedSlivers!]; | |
| } | |
| return slivers; | |
| } | |
| Widget sliver(String key, String type, final List<_NestedListStore> items, StateSetter setState, [void Function(StateSetter setState)? load]) { | |
| int length = items.where((element) => element.item != null).length; | |
| bool sortable = length > 1 && isSortable; | |
| Widget itemBuilder(BuildContext context, int index) { | |
| if (index > items.length - data.getLimit(type, 10) / 3) { | |
| load?.call(setState); | |
| } | |
| if (index >= length) { | |
| load?.call(setState); | |
| return _NestedListDecoration( | |
| key: Key("loading $key"), | |
| index: index, | |
| length: index + 1, | |
| level: level + 1, | |
| child: ListUtils.loadingList(padding: 5, alignment: Langs.ltr ? Alignment.centerLeft : Alignment.centerRight), | |
| ); | |
| } | |
| _NestedListStore subStore = items.elementAt(index); | |
| return _ListNestedView( | |
| key: Key("item $key ${subStore.item!.id} $index"), | |
| controller.getItem(type, subStore.item!), | |
| index: index, | |
| length: items.length + (load != null ? 1 : 0), | |
| level: subStore.level, | |
| more: !subStore.data.isEmpty, | |
| open: subStore.open, | |
| toggle: () => setState(() => subStore.open = !subStore.open), | |
| sortable: sortable && controller.onReorder != null, | |
| ); | |
| } | |
| return controller.onReorder == null | |
| ? SliverList( | |
| key: ValueKey("normal $key"), | |
| delegate: SliverChildBuilderDelegate( | |
| childCount: length + (load != null ? 1 : 0), | |
| itemBuilder, | |
| )) | |
| : SliverReorderableList( | |
| key: ValueKey("order $key"), | |
| itemCount: length + (load != null ? 1 : 0), | |
| itemExtent: NestedList.itemExtent, | |
| onReorderStart: (int index) => HapticFeedback.heavyImpact(), | |
| onReorder: (int oldIndex, int newIndex) { | |
| setState( | |
| () { | |
| if (newIndex > data.getSize(type)) { | |
| return; | |
| } | |
| List<_NestedListStore> items = data.getItems(type); | |
| final _NestedListStore item = data.getItems(type).removeAt(oldIndex); | |
| items.insert(newIndex + (oldIndex > newIndex ? 0 : -1), item); | |
| controller.onReorder?.call(type, items.map<Json>((e) => e.item!).toList()); | |
| }, | |
| ); | |
| }, | |
| proxyDecorator: (child, index, animation) { | |
| _NestedListStore subStore = items.elementAt(index); | |
| return Material( | |
| elevation: 3, | |
| child: _ListNestedView( | |
| key: Key("drag item $key ${subStore.item!.id} $index"), | |
| controller.getItem(type, subStore.item!), | |
| index: index, | |
| length: items.length + (load != null ? 1 : 0), | |
| level: subStore.level, | |
| more: !subStore.data.isEmpty, | |
| open: subStore.open, | |
| toggle: () => setState(() => subStore.open = !subStore.open), | |
| sortable: sortable && controller.onReorder != null, | |
| border: false, | |
| ), | |
| ); | |
| }, | |
| itemBuilder: itemBuilder, | |
| ); | |
| } | |
| Future<void> load(String type, BuildContext context, StateSetter setState) async { | |
| String? url = data.getUrl(type); | |
| String? paging = data.getPaging(type); | |
| if (!loading && paging != null && url != null) { | |
| loading = true; | |
| Json? rez = await Api.get(url, paging: paging != "" ? paging : null, cache: true); | |
| data.setConnection(type, rez?['paging']?['base'], rez?['paging']?['next'], rez?['paging']?['limit']); | |
| for (String? key in [null, ...controller.keys]) { | |
| Json? items = key == null ? rez : rez?[key]; | |
| if (items != null) { | |
| for (Json item in items.result) { | |
| addItem(key ?? type, item); | |
| } | |
| } | |
| } | |
| loading = false; | |
| if (context.mounted) { | |
| setState.call(() {}); | |
| } | |
| } | |
| } | |
| void addItem(String type, Json item) { | |
| final List<_NestedListStore> stores = data.getItems(type); | |
| stores.add(_NestedListStore( | |
| item: item, | |
| controller: controller, | |
| type: type, | |
| level: level + 1, | |
| )); | |
| } | |
| bool get isSortable { | |
| if (controller.onReorder == null) { | |
| return false; | |
| } | |
| for (_NestedListStore store in data.all) { | |
| if (store.open || !store.isSortable) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| bool get open { | |
| if (_open) { | |
| return true; | |
| } | |
| return controller.opened(item!.id!); | |
| } | |
| set open(bool open) { | |
| _open = open; | |
| controller.onToggle(item!.id!, open); | |
| } | |
| } | |
| class _NestedListStoreData { | |
| final Map<String, ((String?, String?, int?), List<_NestedListStore>)> data = {}; | |
| Iterable<String> get keys => data.keys; | |
| bool get isEmpty { | |
| for (((String?, String?, int?), List<_NestedListStore>) value in data.values) { | |
| if (value.$2.isNotEmpty || value.$1.$2 != null) { | |
| return false; | |
| } | |
| } | |
| return true; | |
| } | |
| List<_NestedListStore> get all { | |
| List<_NestedListStore> all = []; | |
| for (((String?, String?, int?), List<_NestedListStore>) value in data.values) { | |
| all.addAll(value.$2); | |
| } | |
| return all; | |
| } | |
| void setConnection(String type, String? url, String? paging, int? limit) { | |
| data[type] = ((url, paging, limit), data[type]?.$2 ?? []); | |
| } | |
| List<_NestedListStore> getItems(String type) { | |
| if (!data.containsKey(type)) { | |
| data[type] = ((null, null, null), []); | |
| } | |
| return data[type]!.$2; | |
| } | |
| String? getUrl(String type) => data[type]?.$1.$1; | |
| String? getPaging(String type) => data[type]?.$1.$2; | |
| int getLimit(String type, int def) => data[type]?.$1.$3 ?? def; | |
| int getSize(String type) => data[type]?.$2.length ?? 0; | |
| } | |
| class _NestedListController { | |
| final String id; | |
| final List<String> keys; | |
| final List<String>? _mainKeys; | |
| final ScrollController scrollController; | |
| final NestedGetViewData getItem; | |
| final String Function(Json item)? getType; | |
| final void Function(String key, List<Json> order)? onReorder; | |
| final List<String> _opens = []; | |
| _NestedListController({ | |
| required this.id, | |
| required this.keys, | |
| List<String>? mainKeys, | |
| required this.scrollController, | |
| required this.getItem, | |
| this.onReorder, | |
| this.getType, | |
| }) : _mainKeys = mainKeys; | |
| List<String> get mainKeys => _mainKeys ?? keys; | |
| void jumpTo(double offset, int index) { | |
| if (scrollController.offset > offset && (offset != 0.0 || index == 0)) { | |
| scrollController.jumpTo(offset); | |
| } | |
| } | |
| void onToggle(String id, bool open) { | |
| if (open) { | |
| _opens.addUnique(_getId(id)); | |
| if (_opens.length > 50) { | |
| _opens.removeLast(); | |
| } | |
| } else { | |
| _opens.removeWhere((element) => element == _getId(id)); | |
| } | |
| SettingsStore.set("nestedList", _opens.join(",")); | |
| } | |
| bool opened(String id) => _opens.contains(_getId(id)); | |
| Future<void> init() async { | |
| String save = await SettingsStore.get("nestedList", ""); | |
| _opens.addAll(save.split(",")); | |
| } | |
| String _getId(String id) => "${this.id}/$id"; | |
| } | |
| class _ListNestedView extends StatelessWidget { | |
| final NestedViewData data; | |
| final int index; | |
| final int length; | |
| final int level; | |
| final bool more; | |
| final bool open; | |
| final VoidCallback? toggle; | |
| final bool sortable; | |
| final bool border; | |
| const _ListNestedView( | |
| this.data, { | |
| super.key, | |
| required this.index, | |
| required this.length, | |
| required this.level, | |
| required this.more, | |
| required this.open, | |
| this.toggle, | |
| required this.sortable, | |
| this.border = true, | |
| }); | |
| @override | |
| Widget build(BuildContext context) { | |
| return SizedBox( | |
| height: NestedList.itemExtent, | |
| child: Material( | |
| color: open ? null : Colors.transparent, | |
| elevation: open ? 3 : 0, | |
| child: InkWell( | |
| onTap: data.onTap, | |
| child: _NestedListDecoration( | |
| index: index, | |
| length: length, | |
| level: level, | |
| border: border && !open, | |
| child: Row( | |
| mainAxisSize: MainAxisSize.max, | |
| crossAxisAlignment: CrossAxisAlignment.center, | |
| mainAxisAlignment: MainAxisAlignment.start, | |
| children: [ | |
| if (sortable) ReorderableDrag(index) else SizedBox(width: 3), | |
| if (data.icon != null) data.icon!, | |
| SizedBox(width: 3), | |
| Expanded( | |
| child: Row( | |
| children: [ | |
| Flexible(child: H4(data.title)), | |
| if (more) | |
| IconButton( | |
| onPressed: toggle, | |
| icon: Icon( | |
| open | |
| ? MdiIcons.menuDown | |
| : Langs.ltr | |
| ? MdiIcons.menuRight | |
| : MdiIcons.menuLeft, | |
| ), | |
| ), | |
| ], | |
| ), | |
| ), | |
| if (data.menu != null) data.menu!, | |
| ], | |
| ), | |
| ), | |
| ), | |
| ), | |
| ); | |
| } | |
| } | |
| class _NestedListDecoration extends StatelessWidget { | |
| final Widget child; | |
| final int index; | |
| final int length; | |
| final int level; | |
| final bool border; | |
| const _NestedListDecoration({super.key, required this.index, required this.length, required this.level, required this.child, this.border = true}); | |
| @override | |
| Widget build(BuildContext context) { | |
| Widget container = Container( | |
| clipBehavior: Clip.hardEdge, | |
| height: NestedList.itemExtent, | |
| padding: EdgeInsets.all(5), | |
| decoration: !border | |
| ? BoxDecoration() | |
| : BoxDecoration( | |
| border: Border( | |
| top: index == 0 ? BorderSide(style: BorderStyle.none) : BorderSide(color: Colors.grey), | |
| bottom: index == length - 1 ? BorderSide(color: Colors.grey) : BorderSide(style: BorderStyle.none)), | |
| ), | |
| child: child, | |
| ); | |
| for (int level = this.level; level >= 0; level--) { | |
| double padding = !border ? 18 : 16; | |
| container = Container( | |
| height: NestedList.itemExtent, | |
| margin: level == 0 | |
| ? EdgeInsets.zero | |
| : EdgeInsets.only( | |
| left: Langs.ltr ? padding : 0, | |
| right: Langs.ltr ? 0 : padding, | |
| ), | |
| decoration: !border | |
| ? BoxDecoration() | |
| : BoxDecoration( | |
| border: Langs.ltr | |
| ? Border(left: level == 0 ? BorderSide(style: BorderStyle.none) : BorderSide(color: Colors.grey)) | |
| : Border(right: level == 0 ? BorderSide(style: BorderStyle.none) : BorderSide(color: Colors.grey)), | |
| ), | |
| child: container, | |
| ); | |
| } | |
| return container; | |
| } | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment