TatsuUkraine / flutter_sticky_infinite_list

Multi directional infinite list with Sticky headers for Flutter applications
BSD 2-Clause "Simplified" License
341 stars 31 forks source link

Optional headers #25

Closed unveloper closed 4 years ago

unveloper commented 4 years ago

Hi! I want to group several items in a same header. Is it possible? Example:

Header content content --> no header Header content Header content content --> no header content --> no header

Thanks!

TatsuUkraine commented 4 years ago

Yes for sure. You can use this widget in any scrollable widget https://pub.dev/documentation/sticky_infinite_list/latest/widget/StickyListItem-class.html

Here is an example in docs https://github.com/TatsuUkraine/flutter_sticky_infinite_list/blob/master/README.md#need-more-override

Here is an example in example project https://github.com/TatsuUkraine/flutter_sticky_infinite_list_example/blob/master/lib/main.dart#L490

TatsuUkraine commented 4 years ago

And here is how it will look https://github.com/TatsuUkraine/flutter_sticky_infinite_list_example/blob/5dabe8503ad2d578f9b07018d2d1c76a61a258ef/doc/images/single-scroll.gif?raw=true

TatsuUkraine commented 4 years ago

@unveloper does it solve your issue?

unveloper commented 4 years ago

Thanks for your answer but I don't think this solve my issue. I want to have optional headers inside a ListView and use a builder delegate like you do in your InfiniteList (I want to create list items on demand).

I see now: maybe I have to use InfiniteListItem and set "hasStickyHeader" to false?

TatsuUkraine commented 4 years ago

@unveloper you can do this too. Links that I sent you, just showing a basic example. You can use any scroll widget around StickyListItem (ListView one of the scroll widgets)

For example - here is an infinite list with ListView.buider where first and each third item doesn't have a sticky header:

      ListView.builder(
        itemBuilder: (context, index) {
          if (index % 3 == 0) {
            /// Item without header
            return Container(
              height: 100,
              color: Colors.lightBlueAccent,
              child: Text('Item $index without header'),
            );
          }
         /// Item with header
          return StickyListItem<int>(
              minOffsetProvider: (_) => 50,
              header: Container(
                height: 50,
                width: double.infinity,
                color: Colors.orange,
                child: Center(
                  child: Text('Header $index'),
                ),
              ),
              content: Padding(
                padding: const EdgeInsets.only(top: 50),
                child: Column(
                  children: <Widget>[
                    Container(
                      height: 100,
                      width: double.infinity,
                      color: Colors.blueAccent,
                      child: Text('First Item content with $index header'),
                    ),
                    Container(
                      height: 100,
                      width: double.infinity,
                      color: Colors.blueAccent,
                      child: Text('Second Item content with $index header'),
                    ),
                  ],
                ),
              ),
              itemIndex: index,
            );
        },
      ),
unveloper commented 4 years ago

That is useful, thanks! But I have another question: I have a use case with lot of items so I want to load them on demand with pagination (like this class tries to do https://stackoverflow.com/questions/60074466/pagination-infinite-scrolling-in-flutter-with-caching-and-realtime-invalidatio)

Let's suppose I want to use a page with size of 10, and suppose the last element of the first page is a header (that I want to be sticky). The next item of the header is obviously a "non-header" but I haven't load it yet... so I can't return anything in the "content" delegate of the StickyListItem because I don't know what's next until I load the next page. Beside this, when I load the second page, the first element is the content but I don't have a way of connecting the previous element, which is its header.

So, how can I use your awesome sticky headers with pagination?

TatsuUkraine commented 4 years ago

Beside this, when I load the second page, the first element is the content but I don't have a way of connecting the previous element, which is its header.

if I understood you right - you still will rebuild ListView each time new data chunk is loaded. Or not?

Let's suppose I want to use a page with size of 10, and suppose the last element of the first page is a header (that I want to be sticky). The next item of the header is obviously a "non-header" but I haven't load it yet... so I can't return anything in the "content"

So your header - is your data item? If so - in this scenario you can just return empty Container.

TatsuUkraine commented 4 years ago

Basically, what you need to do in any case - group your collection in the way when you could properly define, which content items should have a header (and what header it should be), and which shouldn't

TatsuUkraine commented 4 years ago

@unveloper do you need any additional help? If not, please close this issue)

unveloper commented 4 years ago

Beside this, when I load the second page, the first element is the content but I don't have a way of connecting the previous element, which is its header.

if I understood you right - you still will rebuild ListView each time new data chunk is loaded. Or not?

Let's suppose I want to use a page with size of 10, and suppose the last element of the first page is a header (that I want to be sticky). The next item of the header is obviously a "non-header" but I haven't load it yet... so I can't return anything in the "content"

So your header - is your data item? If so - in this scenario you can just return empty Container.

I'm gonna close the issue as soon as I'll do sone experiments. Thanks!

TatsuUkraine commented 4 years ago

closing this issue due to no activity, if you need any additional help you can re-open this issue or create a new one

unveloper commented 4 years ago

Hi! Sorry for the inactivity.

Basically, what you need to do in any case - group your collection in the way when you could properly define, which content items should have a header (and what header it should be), and which shouldn't

My problem is I can't group my collection because I'm working with Futures of paged data.

import 'dart:math';

import 'package:fimber/fimber_base.dart';
import 'package:flutter/material.dart';
import 'package:flutter_playground/pskink3_list.dart';
import 'package:sticky_infinite_list/sticky_infinite_list.dart';

import 'main_drawer.dart';
import 'moor.dart';

class StickyCategoryList extends StatelessWidget {
  final MyDatabase database = MyDatabase();
  final Random random = Random.secure();

  @override
  Widget build(BuildContext context) {
    return new Scaffold(
      drawer: MainDrawer(DrawerItemIndex.CATEGORY_INDEX),
      appBar: AppBar(
        title: new Text("Categories"),
      ),
      body: LazyListView(
        pageSize: 10,
        pageFuture: (pageIndex) =>
            Future.delayed(Duration(milliseconds: (random.nextDouble() * 5000).toInt()), () => database.getCategories(10, 10 * pageIndex)),
        countStream: database.countCategories(),
        itemBuilder: _itemBuilder,
        waitBuilder: _waitBuilder,
        placeholderBuilder: _placeholderBuilder,
        emptyResultBuilder: _emptyResultBuilder,
        errorBuilder: _errorBuilder,
      ),
      floatingActionButton: Column(
        mainAxisAlignment: MainAxisAlignment.end,
        children: <Widget>[
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: FloatingActionButton(
              heroTag: "addCategoryFab",
              onPressed: () {
                showDialog<Widget>(context: context, builder: (context) => _addDialog(context));
              },
              tooltip: 'Add',
              child: Icon(Icons.add),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: FloatingActionButton(
              heroTag: "deleteCategoryFab",
              onPressed: () {
                showDialog<Widget>(context: context, builder: (context) => _deleteDialog(context));
              },
              tooltip: 'Delete',
              child: Icon(Icons.remove),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: FloatingActionButton(
              heroTag: "deleteAllCategoryFab",
              onPressed: () {
                database.deleteAllCategories();
              },
              tooltip: 'Delete all',
              child: Icon(Icons.remove_circle_outline),
            ),
          ),
          Padding(
            padding: const EdgeInsets.all(8.0),
            child: FloatingActionButton(
              heroTag: "refreshCategoryFab",
              onPressed: () {
                database.refreshCategories();
              },
              tooltip: 'Reset',
              child: Icon(Icons.refresh),
            ),
          ),
        ],
      ),
    );
  }

  Widget _normalItemBuilder(BuildContext context, int index, Category item) => Container(
        height: 65,
        child: Center(
          child: ListTile(
            key: Key(item.id.toString()),
            title: Text(item.description),
            subtitle: Text("id = ${item.id}, index = $index"),
            trailing: RaisedButton(
              onPressed: () {
                database.deleteCategory(item.id);
              },
              child: Text("Delete"),
            ),
            onTap: () {
              showDialog<Widget>(context: context, builder: (context) => _editDialog(context, item.id));
            },
          ),
        ),
      );

  Widget _headerItemBuilder(BuildContext context, int index, Category item) => StickyListItem<int>(
        minOffsetProvider: (_) => 50,
        header: Container(
          height: 50,
          width: double.infinity,
          color: Colors.orange,
          child: Center(
            child: Text('Header $index'),
          ),
        ),
        content: Container(),
        itemIndex: index,
      );

  Widget _itemBuilder(BuildContext context, int index, Category item) {
    if (index % 7 == 0 || index % 19 == 0) {
      return _headerItemBuilder(context, index, item);
    }
    return _normalItemBuilder(context, index, item);
  }

}

I have a LazyListView which needs a "count stream" giving the correct amount of elements and a Future o List returning a page of elements.

database.countCategories() is the count stream returning a Stream<int> database.getCategories(10, 10 * pageIndex) is the page future returning a Future<List<Category>>

LazyListView is in charge of invoking the Future every time needs to load elements in a given index. I don't reload every time the list because is cached. Pages of elements are reload only when the cache is full and the oldest page is replaced with the new one or when the count stream signals that something is changed in the database (like Room LiveData).

Of course this code won't create proper sticky headers since, in this example, every one is without content. But the problem is always there: I can't know how many children a sticky header will have before. Since I'm elements are generated with an "item builder" I can only rely on the element of the given index, I don't have to made assumption on what is before or after.

How would you approch this issue?

unveloper commented 4 years ago

What I think this awesome library is missing are two new classes. One representing only the header and one representing only a "normal" item. Also the two classes must only have a content builder, not like StickyListItem which has both header and content builder. Something you can use like this:

Widget _normalItemBuilder(BuildContext context, int index, CategoryItem item) => NewNormalListItem(
    content: Container(
      height: 65,
      child: Center(
        child: ListTile(
          key: Key(item.id.toString()),
          title: Text(item.description),
          subtitle: Text("id = ${item.id}, index = $index"),
          trailing: RaisedButton(
            onPressed: () {
              database.deleteCategory(item.id);
            },
            child: Text("Delete"),
          ),
          onTap: () {
            showDialog<Widget>(context: context, builder: (context) => _editDialog(context, item.id));
          },
        ),
      ),
    )
  );

  Widget _headerItemBuilder(BuildContext context, int index, CategoryHeader item) => NewStickyListItem<int>(
        minOffsetProvider: (_) => 50,
        content: Container(
          height: 50,
          width: double.infinity,
          color: Colors.orange,
          child: Center(
            child: Text('Header $index'),
          ),
        ),
        itemIndex: index,
      );

  Widget _itemBuilder(BuildContext context, int index, BaseCategory item) {
    if (item is CategoryHeader) {
      return _headerItemBuilder(context, index, item);
    }
    return _normalItemBuilder(context, index, item as CategoryItem);
  }
TatsuUkraine commented 4 years ago

the case here that Header, in order to define edges (min and max position) requires height and height is calculated against content. Header can't leave as a standalone Sliver item since Viewport eventually will destroy it as soon as it goes outside of allowed offset

TatsuUkraine commented 4 years ago

what library provides LazyListView widget?

TatsuUkraine commented 4 years ago

I'm asking, because quite likely it actually rerenders all list as soon as New count value comes in. But to understand how it works I need to take a look at its sources. In any case - you can solve rendering issue by making an appropriate grouping logic + streams, which will be split based on your groups. In that way, as soon as new data comes in an appropriate Stream group will be notified, which will lead to rendering one specific category (with or without header)

TatsuUkraine commented 4 years ago

but again, it's hard to suggest anything without knowing full info(

unveloper commented 4 years ago

That is useful, thanks! But I have another question: I have a use case with lot of items so I want to load them on demand with pagination (like this class tries to do https://stackoverflow.com/questions/60074466/pagination-infinite-scrolling-in-flutter-with-caching-and-realtime-invalidatio)

LazyListView is the result of my stackoverflow question. Here it is the final version:

import 'dart:async';
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';
import 'package:quiver/cache.dart';
import 'package:quiver/collection.dart';

typedef Future<List<T>> PageFuture<T>(int pageIndex);

typedef Widget ItemBuilder<T>(BuildContext context, int index, T entry);
typedef Widget ErrorBuilder(BuildContext context, dynamic error);

class LazyListView<T> extends StatefulWidget {
  final int pageSize;
  final PageFuture<T> pageFuture;
  final Stream<int> countStream;

  final ItemBuilder<T> itemBuilder;
  final IndexedWidgetBuilder placeholderBuilder;
  final IndexedWidgetBuilder separatorBuilder;
  final WidgetBuilder waitBuilder;
  final WidgetBuilder emptyResultBuilder;
  final ErrorBuilder errorBuilder;
  final double velocityThreshold;

  LazyListView({
    @required this.pageSize,
    @required this.pageFuture,
    @required this.countStream,
    @required this.itemBuilder,
    @required this.placeholderBuilder,
    this.separatorBuilder,
    this.waitBuilder,
    this.emptyResultBuilder,
    this.errorBuilder,
    this.velocityThreshold = 128,
  })  : assert(pageSize > 0),
        assert(pageFuture != null),
        assert(countStream != null),
        assert(itemBuilder != null),
        assert(placeholderBuilder != null),
        assert(velocityThreshold >= 0);

  @override
  _LazyListViewState<T> createState() => _LazyListViewState<T>();
}

class _LazyListViewState<T> extends State<LazyListView<T>> {
  Map<int, PageResult<T>> map;
  MapCache<int, PageResult<T>> cache;
  dynamic error;
  int totalCount = -1;
  bool _frameCallbackInProgress = false;

  StreamSubscription<int> countStreamSubscription;

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

    countStreamSubscription = widget.countStream.listen((int count) {
      totalCount = count;
      print('totalCount = $totalCount');
      _initCache();
      setState(() {});
    });
  }

  @override
  void dispose() {
    countStreamSubscription.cancel();
    super.dispose();
  }

  @override
  Widget build(BuildContext context) {
    //debugPrintBeginFrameBanner = true;
    //debugPrintEndFrameBanner = true;
    //print('build');
    if (error != null && widget.errorBuilder != null) {
      return widget.errorBuilder(context, error);
    }
    if (totalCount == -1 && widget.waitBuilder != null) {
      return widget.waitBuilder(context);
    }
    if (totalCount == 0 && widget.emptyResultBuilder != null) {
      return widget.emptyResultBuilder(context);
    }

    if (widget.separatorBuilder == null) {
      return ListView.builder(
        physics: _LazyListViewPhysics(velocityThreshold: widget.velocityThreshold),
        itemCount: max(totalCount, 0),
        itemBuilder: (context, index) {
          // print('builder $index');
          final page = index ~/ widget.pageSize;
          final pageResult = map[page];
          final value = pageResult?.items?.elementAt(index % widget.pageSize);
          if (value != null) {
            return widget.itemBuilder(context, index, value);
          }

          // print('$index ${Scrollable.of(context).position.activity.velocity}');
          if (!Scrollable.recommendDeferredLoadingForContext(context)) {
            cache.get(page, ifAbsent: _loadPage).then(_reload).catchError(_error);
          } else if (!_frameCallbackInProgress) {
            _frameCallbackInProgress = true;
            SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context));
          }
          return widget.placeholderBuilder(context, index);
        },
      );
    }
    return ListView.separated(
      physics: _LazyListViewPhysics(velocityThreshold: widget.velocityThreshold),
      itemCount: max(totalCount, 0),
      itemBuilder: (context, index) {
        // print('builder $index');
        final page = index ~/ widget.pageSize;
        final pageResult = map[page];
        final value = pageResult?.items?.elementAt(index % widget.pageSize);
        if (value != null) {
          return widget.itemBuilder(context, index, value);
        }

        // print('$index ${Scrollable.of(context).position.activity.velocity}');
        if (!Scrollable.recommendDeferredLoadingForContext(context)) {
          cache.get(page, ifAbsent: _loadPage).then(_reload).catchError(_error);
        } else if (!_frameCallbackInProgress) {
          _frameCallbackInProgress = true;
          SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context));
        }
        return widget.placeholderBuilder(context, index);
      },
      separatorBuilder: widget.separatorBuilder,
    );
  }

  Future<PageResult<T>> _loadPage(int index) async {
    print('load $index');
    var list = await widget.pageFuture(index);
    return PageResult(index, list);
  }

  void _initCache() {
    map = LruMap<int, PageResult<T>>(maximumSize: 512 ~/ widget.pageSize);
    cache = MapCache<int, PageResult<T>>(map: map);
  }

  void _error(dynamic e, StackTrace stackTrace) {
    if (widget.errorBuilder == null) {
      throw e;
    }
    if (this.mounted) {
      setState(() => error = e);
    }
  }

  void _reload(PageResult<T> value) => _doReload(value.index);

  void _deferredReload(BuildContext context) {
    print('_deferredReload');
    if (!Scrollable.recommendDeferredLoadingForContext(context)) {
      _frameCallbackInProgress = false;
      _doReload(-1);
    } else {
      SchedulerBinding.instance.scheduleFrameCallback((d) => _deferredReload(context), rescheduling: true);
    }
  }

  void _doReload(int index) {
    print('reload $index');
    if (this.mounted) {
      setState(() {});
    }
  }
}

class PageResult<T> {
  /// Page index of this data.
  final int index;
  final List<T> items;

  PageResult(this.index, this.items);
}

class _LazyListViewPhysics extends AlwaysScrollableScrollPhysics {
  final double velocityThreshold;

  _LazyListViewPhysics({
    @required this.velocityThreshold,
    ScrollPhysics parent,
  }) : super(parent: parent);

  @override
  recommendDeferredLoading(double velocity, ScrollMetrics metrics, BuildContext context) {
    // print('velocityThreshold: $velocityThreshold');
    return velocity.abs() > velocityThreshold;
  }

  @override
  _LazyListViewPhysics applyTo(ScrollPhysics ancestor) {
    // print('applyTo($ancestor)');
    return _LazyListViewPhysics(velocityThreshold: velocityThreshold, parent: buildParent(ancestor));
  }
}

I can't imagine how to make grouping possible together with pagination for the problem I mentioned at the beginning of the issue.

Also:

the case here that Header, in order to define edges (min and max position) requires height and height is calculated against content. Header can't leave as a standalone Sliver item since Viewport eventually will destroy it as soon as it goes outside of allowed offset

Is possible that the scroll view can be in charge of this calculations? And not the item themselves?