Skip to content

Instantly share code, notes, and snippets.

@pagetronic
Last active May 23, 2025 07:24
Show Gist options
  • Save pagetronic/e05fc830da2a16c826a8dce17d395f97 to your computer and use it in GitHub Desktop.
Save pagetronic/e05fc830da2a16c826a8dce17d395f97 to your computer and use it in GitHub Desktop.
Flutter nested sortable list
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