How to add Sticky Header to List View?

Viewed 1120

I want to add a sticky header to a list view without using third-party libraries. I already created a class for grouped list view, I want to know how can I add a sticky header to it, is it possible to have a stack widget contain list view and another widget for the sticky header and rebuild header widget by listening to list view scroll controller (like below code)? is there a better way to doing this?

class GroupedListView<E, G extends Comparable<Object>> extends StatefulWidget {
  const GroupedListView({
    @required this.groupBy,
    @required this.groupBuilder,
    @required this.itemBuilder,
    Key key,
    this.elements,
  }) : super(key: key);

  final G Function(E element) groupBy;
  final Widget Function(G group) groupBuilder;
  final Widget Function(BuildContext context, E element) itemBuilder;
  final List<E> elements;

  @override
  _GroupedListViewState<E, G> createState() => _GroupedListViewState<E, G>();
}

class _GroupedListViewState<E, G extends Comparable<Object>>
    extends State<GroupedListView<E, G>> {
  List<E> _elements;

  @override
  void initState() {
    super.initState();

    _elements = widget.elements;
  }

  @override
  Widget build(BuildContext context) => Stack(
        children: <Widget>[
          ListView.builder(
            itemCount: _elements.length * 2,
            itemBuilder: (BuildContext context, int index) {
              final int actualIndex = index ~/ 2;

              if (index.isEven) {
                final G currentGroup = widget.groupBy(_elements[actualIndex]);
                final G previousGroup = actualIndex - 1 < 0
                    ? null
                    : widget.groupBy(_elements[actualIndex - 1]);

                if (previousGroup != currentGroup) {
                  return widget.groupBuilder(currentGroup);
                }

                return Container();
              }

              return widget.itemBuilder(context, _elements[actualIndex]);
            },
          ),
          // THIS WIDGET SHOULD REBUILD BY LISTENING TO SCROLL VIEW CONTROLLER
          Container(
            color: Colors.black,
            child: ListTile(
              title: Text(
                'STICKY HEADER',
                style: TextStyle(color: Colors.white),
              ),
            ),
          ),
        ],
      );
}

sample code:

class HomePage extends StatefulWidget {
  HomePage({
    Key key,
    this.title,
  }) : super(key: key);

  final String title;

  @override
  _HomePageState createState() => _HomePageState();
}

class _HomePageState extends State<HomePage> {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: Text(widget.title),
      ),
      body: GroupedListView<dynamic, String>(
        elements: listItems,
        groupBy: (dynamic element) => element['group'],
        groupBuilder: (String value) => Container(
          color: Colors.grey,
          child: ListTile(
            title: Text(
              value,
              style: TextStyle(color: Colors.white),
            ),
          ),
        ),
        itemBuilder: (BuildContext context, dynamic element) => ListTile(
          title: Text(element['name']),
        ),
      ),
    );
  }
}

final List<Map<String, String>> listItems = <Map<String, String>>[
  <String, String>{'name': 'user_01', 'group': 'group_01'},
  <String, String>{'name': 'user_02', 'group': 'group_01'},
  <String, String>{'name': 'user_03', 'group': 'group_02'},
  <String, String>{'name': 'user_04', 'group': 'group_02'},
  <String, String>{'name': 'user_05', 'group': 'group_02'},
  <String, String>{'name': 'user_06', 'group': 'group_03'},
  <String, String>{'name': 'user_07', 'group': 'group_04'},
  <String, String>{'name': 'user_08', 'group': 'group_04'},
  <String, String>{'name': 'user_09', 'group': 'group_05'},
  <String, String>{'name': 'user_10', 'group': 'group_05'},
  <String, String>{'name': 'user_11', 'group': 'group_06'},
  <String, String>{'name': 'user_12', 'group': 'group_06'},
];

Any help would be greatly appreciated.

1 Answers

Yes, you can use a Stack to achieve this behavior. First, you need to do some math to get the height of each group title row. Then you need to add a listener to the scroll controller. This listener checks if the current offset of the scrolling controller is in an area related to a group, it sets the current variable to that group's index in the list of group names. separatorHeight is the height of the group title row. tileHeight is the height of the item row.

import 'package:flutter/material.dart';
import 'package:collection/collection.dart';

class GroupedListView<E, G extends Comparable<Object>> extends StatefulWidget {
  const GroupedListView({
    @required this.groupBy,
    @required this.groupBuilder,
    @required this.itemBuilder,
    @required this.separatorHeight,
    @required this.tileHeight,
    Key key,
    this.elements,
  }) : super(key: key);

  final G Function(E element) groupBy;
  final Widget Function(G group) groupBuilder;
  final Widget Function(BuildContext context, E element) itemBuilder;
  final List<E> elements;
  final double separatorHeight;
  final double tileHeight;

  @override
  _GroupedListViewState<E, G> createState() => _GroupedListViewState<E, G>();
}

class _GroupedListViewState<E, G extends Comparable<Object>>
    extends State<GroupedListView<E, G>> {
  List<E> _elements;

  List<double> _groupHeights;
  List<G> _groupNames;
  int _currentGroupIndex = 0;

  final ScrollController _scrollController = ScrollController();

  @override
  void initState() {
    super.initState();

    _elements = widget.elements;

    _groupNames = groupBy<E, G>(_elements, widget.groupBy)
        .entries
        .map<G>((dynamic entry) => entry.key)
        .toList();

    _groupHeights = groupBy<E, G>(_elements, widget.groupBy)
        .entries
        .map<double>((dynamic entry) => entry.value.length.toDouble())
        .toList();

    double sum = 0;

    _groupHeights = _groupHeights
        .map<double>((double itemCount) =>
            sum += itemCount * widget.tileHeight + widget.separatorHeight)
        .toList();

    _scrollController.addListener(() {
      final double controllerOffset =
          _scrollController.offset + widget.separatorHeight;

      if (controllerOffset < _groupHeights.first) {
        setState(() => _current = 0);
      } else {
        for (int i = 1; i < _groupHeights.length; i++) {
          if (controllerOffset >= _groupHeights[i - 1] &&
              controllerOffset < _groupHeights[i]) {
            setState(() => _current = i);

            break;
          }
        }
      }
    });
  }

  @override
  Widget build(BuildContext context) => Stack(
        children: <Widget>[
          Container(
            margin: EdgeInsets.only(top: widget.separatorHeight),
          ),
          ListView.builder(
            key: widget.key,
            controller: _scrollController,
            itemCount: _elements.length * 2,
            itemBuilder: (BuildContext context, int index) {
              final int actualIndex = index ~/ 2;

              if (index.isEven) {
                final G currentGroup = widget.groupBy(_elements[actualIndex]);
                final G previousGroup = actualIndex - 1 < 0
                    ? null
                    : widget.groupBy(_elements[actualIndex - 1]);

                if (previousGroup != currentGroup) {
                  return SizedBox(
                    height: widget.separatorHeight,
                    child: widget.groupBuilder(currentGroup),
                  );
                }

                return Container();
              }

              return SizedBox(
                height: widget.tileHeight,
                child: widget.itemBuilder(context, _elements[actualIndex]),
              );
            },
          ),
          SizedBox(
            height: widget.separatorHeight,
            child: widget.groupBuilder(_groupNames[_currentGroupIndex]),
          ),
        ],
      );
}

Result:

res

Related