Last active
May 23, 2025 07:24
-
-
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/lists/lists_utils.dart'; | |
import 'package:hubd/lists/lists_view.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'; | |
typedef NestedGetSimpleView = NestedViewData Function(String type, Json item); | |
class NestedList extends StatefulWidget { | |
static final double itemExtent = 55; | |
final _NestedListStore _store; | |
NestedList({ | |
super.key, | |
ScrollController? scrollController, | |
required String type, | |
required String url, | |
required List<String> keys, | |
List<String>? mainKeys, | |
Json? initial, | |
required NestedGetSimpleView getItem, | |
void Function(String key, List<String> order)? onReorder, | |
}) : _store = _NestedListStore( | |
item: initial, | |
url: url, | |
type: type, | |
controller: _NestedListController( | |
getItem: getItem, | |
keys: keys, | |
mainKeys: mainKeys, | |
onReorder: onReorder, | |
scrollController: scrollController ?? ScrollController(), | |
), | |
); | |
@override | |
State<NestedList> createState() => _NestedListState(); | |
} | |
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 _NestedListState extends State<NestedList> { | |
@override | |
Widget build(BuildContext context) => LayoutBuilder( | |
builder: (context, constraints) => StatefulBuilder( | |
builder: (context, setState) => CustomScrollView( | |
semanticChildCount: 1, | |
controller: widget._store.controller.scrollController, | |
slivers: [ | |
...widget._store.slivers(context, constraints.maxHeight, setState), | |
SliverToBoxAdapter(child: SizedBox(height: 15)), | |
], | |
), | |
), | |
); | |
} | |
class _NestedListStore { | |
final _NestedListController controller; | |
final Json? item; | |
final _NestedListStoreData data = _NestedListStoreData(); | |
final String type; | |
final int level; | |
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, | |
}) { | |
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 { | |
data.setConnection(type, item?[type]?['paging']?['base'] ?? item?['paging']?['base'], item?[type]?['paging']?['next'] ?? item?['paging']?['next'], | |
item?[type]?['paging']?['limit'] ?? item?['paging']?['limit']); | |
} | |
} | |
if (item != null) { | |
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, setState); | |
} | |
return [SliverToBoxAdapter(child: ListUtils.loadingList(padding: 30, alignment: Alignment.centerLeft))]; | |
} else { | |
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: () { | |
setState(() { | |
store.open = !store.open; | |
}); | |
if (!store.open && sticky) { | |
controller.jumpTo(headerController.stickyHeaderScrollOffset, index); | |
} | |
}, reload: () {}, sortable: false), | |
sliver: 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, setState) : null, | |
), | |
); | |
} | |
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; | |
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<String>((e) => e.item!.id!).toList()); | |
}, | |
); | |
}, | |
proxyDecorator: (child, index, animation) => Material(elevation: 3, child: child), | |
itemBuilder: itemBuilder, | |
); | |
} | |
Future<void> load(String type, StateSetter setState) async { | |
String? url = data.getUrl(type); | |
String? paging = data.getPaging(type); | |
if (!loading && paging != null && url != null) { | |
loading = true; | |
//Fx.log("next: https://api.agroneo.com$url${paging != null ? '&paging=$paging' : ''}"); | |
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; | |
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; | |
} | |
} | |
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) => 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 List<String> keys; | |
final List<String>? _mainKeys; | |
final ScrollController scrollController; | |
final NestedGetSimpleView getItem; | |
final void Function(String key, List<String> order)? onReorder; | |
_NestedListController({required this.keys, List<String>? mainKeys, required this.scrollController, required this.getItem, this.onReorder}) : _mainKeys = mainKeys; | |
List<String> get mainKeys => _mainKeys ?? keys; | |
void jumpTo(double offset, int index) { | |
if (offset != 0.0 || index == 0) { | |
scrollController.jumpTo(offset); | |
} | |
} | |
} | |
class _ListNestedView extends StatelessWidget { | |
final NestedViewData data; | |
final int index; | |
final int length; | |
final void Function()? reload; | |
final int level; | |
final bool more; | |
final bool open; | |
final VoidCallback toggle; | |
final bool sortable; | |
const _ListNestedView( | |
this.data, { | |
super.key, | |
required this.index, | |
required this.length, | |
required this.level, | |
required this.more, | |
required this.open, | |
required this.toggle, | |
required this.sortable, | |
this.reload, | |
}); | |
@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: !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