superlistapp / super_sliver_list

Drop-in replacement for SliverList and ListView that can handle large amount of items with variable extents and reliably jump / animate to any item.
https://superlistapp.github.io/super_sliver_list/
MIT License
277 stars 15 forks source link

NestedScrollView: animateToItem/jumpToItem not working #54

Closed perret123 closed 4 months ago

perret123 commented 5 months ago

Thank you for this package. It works quite well, but I found a Use-Case which doesn't seem to work.

I use a NestedScrollView with a SliverHeader and a TabBar with two TabBarViews inside. Inside the TabBarViews I use a CustomScrollView as per the example available here: https://api.flutter.dev/flutter/widgets/NestedScrollView-class.html

So far so good, now inside the CustomScrollView I wanted to use the cool functionalities you offer; Checking the visibleRange with the ListController listener (this works great!) and jump or animate to the index inside the SuperSliverList.

I made an example app which shows what I want to achieve; Inside the Products-Tab, I want to click on a category and animate to the index in the SuperSliverList. When I click on the first two categories, something still works. You can check if you press on "Snacks" it still scrolls down, but pressing on "Chocolates" doesn't do anything.

Thank you for looking at this, if you make this work it would be spectacular because it seems the "godfather" of indexed lists also has its problems when it comes to NestedScrollViews; https://github.com/google/flutter.widgets/issues/32. Thank you.

Video: https://github.com/superlistapp/super_sliver_list/assets/1687252/19a38da4-ada2-43f1-b14b-76717f2d0a4c

Sourcecode:

import 'dart:developer';

import 'package:flutter/material.dart';
import 'package:sliver_tools/sliver_tools.dart';
import 'package:super_sliver_list/super_sliver_list.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatelessWidget {
  const MyApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        colorScheme: ColorScheme.fromSeed(seedColor: Colors.deepPurple),
        useMaterial3: true,
      ),
      home: const MyHomePage(),
    );
  }
}

class MyHomePage extends StatefulWidget {
  const MyHomePage({super.key});

  @override
  State<MyHomePage> createState() => _MyHomePageState();
}

class _MyHomePageState extends State<MyHomePage> {
  late final ScrollController verticalController;
  late final ScrollController horizontalController;
  late final ListController listController;

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

    verticalController = ScrollController();
    horizontalController = ScrollController();
    listController = ListController();
    listController.addListener(() {
      log('ListController visibleRange: ${listController.visibleRange?.$1} - ${listController.visibleRange?.$2}');
    });
  }

  @override
  void dispose() {
    verticalController.dispose();
    horizontalController.dispose();
    listController.dispose();
    super.dispose();
  }

  final List<String> tabs = <String>['Products', 'Checkout'];
  final Map<String, List<String>> categoriesProducts = {
    'Drinks': [
      'Coke',
      'Pepsi',
      'Fanta',
      'Sprite',
      'Mountain Dew',
      'Dr. Pepper',
      '7UP'
    ],
    'Snacks': [
      'Lays',
      'Doritos',
      'Cheetos',
      'Pringles',
      'Ruffles',
      'Tostitos',
      'Fritos'
    ],
    'Chocolates': [
      'Snickers',
      'Mars',
      'Twix',
      'KitKat',
      'Hershey',
      'Cadbury',
      'Milky Way'
    ],
    'Ice Creams': [
      'Vanilla',
      'Chocolate',
      'Strawberry',
      'Mint',
      'Butter Pecan',
      'Cookies & Cream',
      'Rocky Road'
    ],
    'Candies': [
      'Skittles',
      'M&M',
      'Jelly Beans',
      'Gummy Bears',
      'Sour Patch Kids',
      'Swedish Fish',
      'Twizzlers'
    ],
    'Cookies': [
      'Oreo',
      'Chips Ahoy',
      'Nutter Butter',
      'Milano',
      'Famous Amos',
      'Keebler',
      'Lorna Doone'
    ],
    'Chips': [
      'Ruffles',
      'Lays',
      'Doritos',
      'Cheetos',
      'Pringles',
      'Tostitos',
      'Fritos'
    ],
    'Biscuits': [
      'Digestive',
      'Marie',
      'Oreo',
      'Parle-G',
      'Good Day',
      'Hide & Seek',
      'Britannia'
    ],
    'Cakes': [
      'Chocolate',
      'Vanilla',
      'Strawberry',
      'Red Velvet',
      'Carrot',
      'Cheese',
      'Pound'
    ],
  };

  void verticalScrollToIndex(int index) {
    listController.animateToItem(
      index: index,
      scrollController: verticalController,
      alignment: 0.5,
      duration: (e) => const Duration(milliseconds: 500),
      curve: (e) => Curves.easeInOut,
    );
  }

  @override
  Widget build(BuildContext context) {
    return DefaultTabController(
      length: tabs.length, // This is the number of tabs.
      child: Scaffold(
        body: SafeArea(
          child: NestedScrollView(
            controller: verticalController,
            headerSliverBuilder:
                (BuildContext context, bool innerBoxIsScrolled) {
              // These are the slivers that show up in the "outer" scroll view.
              return <Widget>[
                SliverOverlapAbsorber(
                  // This widget takes the overlapping behavior of the SliverAppBar,
                  // and redirects it to the SliverOverlapInjector below. If it is
                  // missing, then it is possible for the nested "inner" scroll view
                  // below to end up under the SliverAppBar even when the inner
                  // scroll view thinks it has not been scrolled.
                  // This is not necessary if the "headerSliverBuilder" only builds
                  // widgets that do not overlap the next sliver.
                  handle:
                      NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                  sliver: SliverAppBar(
                    title: const Text(
                        'Restaurant'), // This is the title in the app bar.
                    pinned: false,

                    expandedHeight: 150.0,
                    collapsedHeight: 80,
                    forceElevated: innerBoxIsScrolled,
                  ),
                ),
              ];
            },
            body: Column(
              children: [
                TabBar(
                  // These are the widgets to put in each tab in the tab bar.
                  tabs: tabs.map((String name) => Tab(text: name)).toList(),
                ),
                Expanded(
                  child: TabBarView(
                      // These are the contents of the tab views, below the tabs.
                      children: [
                        SafeArea(
                          top: false,
                          bottom: false,
                          child: Builder(
                            builder: (BuildContext context) {
                              return CustomScrollView(
                                key: PageStorageKey<String>('Products'),
                                slivers: <Widget>[
                                  SliverOverlapInjector(
                                    // This is the flip side of the SliverOverlapAbsorber
                                    // above.
                                    handle: NestedScrollView
                                        .sliverOverlapAbsorberHandleFor(
                                            context),
                                  ),
                                  SliverPinnedHeader(
                                    child: Container(
                                      color:
                                          Theme.of(context).colorScheme.surface,
                                      child: SingleChildScrollView(
                                        scrollDirection: Axis.horizontal,
                                        controller: horizontalController,
                                        child: Padding(
                                          padding: const EdgeInsets.all(8.0),
                                          child: Row(
                                            mainAxisSize: MainAxisSize.min,
                                            children: [
                                              ...categoriesProducts.keys.map(
                                                (category) => Padding(
                                                  padding:
                                                      const EdgeInsets.only(
                                                          right: 4.0),
                                                  child: TextButton(
                                                    onPressed: () {
                                                      verticalScrollToIndex(
                                                          categoriesProducts
                                                              .keys
                                                              .toList()
                                                              .indexOf(
                                                                  category));
                                                    },
                                                    child: Text(category),
                                                  ),
                                                ),
                                              )
                                            ],
                                          ),
                                        ),
                                      ),
                                    ),
                                  ),
                                  SuperSliverList(
                                    listController: listController,
                                    delegate: SliverChildBuilderDelegate(
                                        (BuildContext context, int index) {
                                      final category = categoriesProducts.keys
                                          .elementAt(index);
                                      final List<String> products =
                                          categoriesProducts[category] ?? [];

                                      return Card(
                                        elevation: 1,
                                        child: Column(
                                          mainAxisSize: MainAxisSize.min,
                                          children: [
                                            Padding(
                                              padding:
                                                  const EdgeInsets.all(8.0),
                                              child: Row(
                                                mainAxisAlignment:
                                                    MainAxisAlignment.center,
                                                children: [
                                                  Text(category,
                                                      style: Theme.of(context)
                                                          .textTheme
                                                          .headlineSmall),
                                                ],
                                              ),
                                            ),
                                            ...products.map((String product) {
                                              return ListTile(
                                                title: Text(product),
                                              );
                                            }).toList(),
                                          ],
                                        ),
                                      );
                                    },
                                        childCount:
                                            categoriesProducts.keys.length),
                                  ),
                                ],
                              );
                            },
                          ),
                        ),
                        SafeArea(
                          top: false,
                          bottom: false,
                          child: Builder(
                            builder: (BuildContext context) {
                              return CustomScrollView(
                                key: PageStorageKey<String>('Checkout'),
                                slivers: <Widget>[
                                  SliverOverlapInjector(
                                    // This is the flip side of the SliverOverlapAbsorber
                                    // above.
                                    handle: NestedScrollView
                                        .sliverOverlapAbsorberHandleFor(
                                            context),
                                  ),
                                  SliverPinnedHeader(
                                      child: Padding(
                                    padding: const EdgeInsets.all(8.0),
                                    child: Row(
                                      mainAxisSize: MainAxisSize.min,
                                      mainAxisAlignment: MainAxisAlignment.end,
                                      children: [
                                        ElevatedButton(
                                            child: Text('Buy'),
                                            onPressed: () {
                                              // ...
                                            })
                                      ],
                                    ),
                                  )),
                                  SliverPadding(
                                    padding: const EdgeInsets.all(8.0),
                                    sliver: SuperSliverList(
                                      delegate: SliverChildBuilderDelegate(
                                          (BuildContext context, int index) {
                                        return ListTile(
                                            title: Text(
                                                'Product ' + index.toString()));
                                      }, childCount: 30),
                                    ),
                                  ),
                                ],
                              );
                            },
                          ),
                        )
                      ]),
                ),
              ],
            ),
          ),
        ),
      ),
    );
  }
}
knopp commented 5 months ago

I don't think this can work easily. NestedScrollView is bit of a hack. First of all, the controller you're passing to animateTo only controlls the nested scroll view has only has extent range of 0 - 150. You would need to access the inner controller from the build context right above SuperSliverList through PrimaryScrollController.of(context). The problem is, that this scroll controller has no idea about the overlap, which is handled by nested scroll view and forwarded down to the first sliver.

perret123 commented 4 months ago

Okidoki, I made a hacky solution that scrolls the parent ScrollController (from the NestedScrollView) when the inner ScrollController of the CustomScrollView with the SuperSliverList is scrolled - it may not be the most performant or beautiful, but I guess it is good enough until we get even better out-of-the-box support for nested slivers.