I have data that I would like to be displayed in a list, with the use of sticky headers to group each record. I have found plenty of examples on how to do this with on level, but I have two. So each record will have a main group and a sub group. So when the users scrolls I would like for the current main group as well as the current sub group to be sticky.
Example of data set
Main Group 1
Sub Group 1
Record 1
...
Record n
Sub Group 2
...
Record n
...
Sub Group n
...
Record n
...
Main Group n
...
Sub Group n
...
Record n
I have managed to nest 3 ListViews and get all the data to render, and have also used a StickyHeader from the sticky_headers package to get the main group sticky, but when using a StickyHeader on the sub group, it just scrolls right passed the the main group
ListView.builder(
itemCount: 10,
itemBuilder: (BuildContext context, int mainGroupIndex) {
return StickyHeader(
header: Text('Main Group: ${mainGroupIndex + 1}'),
content: ListView.builder(
itemCount: 10,
primary: false,
shrinkWrap: true,
itemBuilder: (BuildContext context, int subGroupIndex) {
return StickyHeader(
header: Text('Sub Group: ${subGroupIndex + 1}'),
content: ListView.builder(
itemCount: 10,
primary: false,
shrinkWrap: true,
itemBuilder: (BuildContext context, int recordIndex) {
return Text('Record: ${recordIndex + 1}');
},
),
);
},
),
);
},
)
The dataset would in worst case have around 100 records that are grouped in different main groups and sub groups, so it would be possible for to use shrink wrap to true in the nested lists, but if there is another way to avoid this that would be best.
Anyone have any ideas on how this could be solved?
I was able to create nested sticky headers by passing ScrollController of parent ListView.builder to both parent and child StickyHeaders so that both headers will be getting same scrolling related information.
I extended the classes StickyHeader and RenderStickyHeader so that we can add offset that will keep child header below parent header while scrolling.
Also note that here I assumed height of parent header and child header is same, if that is not the case then you should send your parent header's height as argument to determineStuckOffsetWithHeight method in performLayout method of _MyRenderStickyHeader.
class MyHomePage extends StatefulWidget {
MyHomePage({Key? key, required this.title}) : super(key: key);
final String title;
@override
_MyHomePageState createState() => _MyHomePageState();
}
final ScrollController scrollController = ScrollController();
class _MyHomePageState extends State<MyHomePage> {
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text(widget.title),
),
body: ListView.builder(
itemCount: 10,
controller: scrollController,
itemBuilder: (BuildContext context, int mainGroupIndex) {
return StickyHeader(
header: Text('Main Group: ${mainGroupIndex + 1}'),
controller: scrollController,
overlapHeaders: false,
content: ListView.builder(
itemCount: 10,
primary: false,
shrinkWrap: true,
itemBuilder: (BuildContext context, int subGroupIndex) {
final list = ListView.builder(
itemCount: 10,
primary: false,
shrinkWrap: true,
itemBuilder: (BuildContext context, int recordIndex) {
return Text('Record: ${recordIndex + 1}');
},
);
return _MyStickyHeader(
header: Text('Sub Group: ${subGroupIndex + 1}'),
controller: scrollController,
content: list,
);
},
),
);
},
),
);
}
}
class _MyStickyHeader extends StickyHeader {
_MyStickyHeader({
Key? key,
required this.header,
required this.content,
this.overlapHeaders: false,
this.controller,
this.callback,
}) : super(
key: key,
header: header,
content: content,
overlapHeaders: overlapHeaders,
controller: controller,
callback: callback,
);
final Widget header;
final Widget content;
final bool overlapHeaders;
final ScrollController? controller;
final RenderStickyHeaderCallback? callback;
@override
_MyRenderStickyHeader createRenderObject(BuildContext context) {
final scrollPosition =
this.controller?.position ?? Scrollable.of(context)!.position;
return _MyRenderStickyHeader(
scrollPosition: scrollPosition,
callback: this.callback,
overlapHeaders: this.overlapHeaders,
);
}
@override
void updateRenderObject(
BuildContext context, _MyRenderStickyHeader renderObject) {
final scrollPosition =
this.controller?.position ?? Scrollable.of(context)!.position;
renderObject
..scrollPosition = scrollPosition
..callback = this.callback
..overlapHeaders = this.overlapHeaders;
}
}
class _MyRenderStickyHeader extends RenderStickyHeader {
bool _overlapHeaders;
RenderStickyHeaderCallback? _callback;
ScrollPosition _scrollPosition;
_MyRenderStickyHeader({
required ScrollPosition scrollPosition,
RenderStickyHeaderCallback? callback,
bool overlapHeaders: false,
RenderBox? header,
RenderBox? content,
}) : _overlapHeaders = overlapHeaders,
_callback = callback,
_scrollPosition = scrollPosition,
super(
scrollPosition: scrollPosition,
callback: callback,
overlapHeaders: overlapHeaders,
header: header,
content: content,
);
RenderBox get _headerBox => lastChild!;
RenderBox get _contentBox => firstChild!;
@override
void performLayout() {
assert(childCount == 2);
final childConstraints = constraints.loosen();
_headerBox.layout(childConstraints, parentUsesSize: true);
_contentBox.layout(childConstraints, parentUsesSize: true);
final headerHeight = _headerBox.size.height;
final contentHeight = _contentBox.size.height;
final width = max(constraints.minWidth, _contentBox.size.width);
final height = max(constraints.minHeight,
_overlapHeaders ? contentHeight : headerHeight + contentHeight);
size = Size(width, height);
assert(size.width == constraints.constrainWidth(width));
assert(size.height == constraints.constrainHeight(height));
assert(size.isFinite);
final contentParentData =
_contentBox.parentData as MultiChildLayoutParentData;
contentParentData.offset =
Offset(0.0, _overlapHeaders ? 0.0 : headerHeight);
final double stuckOffset = determineStuckOffsetWithHeight(headerHeight);
final double maxOffset = height - headerHeight;
final headerParentData =
_headerBox.parentData as MultiChildLayoutParentData;
headerParentData.offset =
Offset(0.0, max(0.0, min(-stuckOffset, maxOffset)));
if (_callback != null) {
final stuckAmount =
max(min(headerHeight, stuckOffset), -headerHeight) / headerHeight;
_callback!(stuckAmount);
}
}
double determineStuckOffsetWithHeight(double headerHeight) {
final scrollBox =
_scrollPosition.context.notificationContext!.findRenderObject();
if (scrollBox?.attached ?? false) {
try {
return localToGlobal(Offset.zero, ancestor: scrollBox).dy -
headerHeight;
} catch (e) {
// ignore and fall-through and return 0.0
}
}
return 0.0;
}
}
If you love us? You can donate to us via Paypal or buy me a coffee so we can maintain and grow! Thank you!
Donate Us With