Search code examples
iosflutterdartscroll

Flutter: how to make ios scroll to top gesture for bidirectional custom view with center key?


I'm doing something like a news feed and I'm using CustomScrollView with two slivers (one for initially loaded news and one for new news that should not shift elements when loaded).

Example:

return CustomScrollView(
  center: centerKey,
  slivers: [
    renderOneList(newItems),
    renderOneList(oldItems, centerKey),
  ],
);

Everything works as it should. I use UniqueKey to define the first element where the initial reading starts. Once every X seconds, I load new items and put them into an array of newItems. They are added to the top and the user can scroll to the top.

However, when clicking on the status bar in iOS Primary Scroll Controller scrolls to position 0 (which in my case is the boundary of new news and old news). This is correct from Flutter's point of view, but it's not the behavior I would like to achieve. Any ideas on how to scroll to the top?

What I think about it:

  1. It is possible to move records from oldItems to newItems when scrolling up, but that would cause a lot of redraws and I probably wouldn't be able to guess the timing of the swap.
  2. While tapping on the status bar, scroll up X pixels in Y milliseconds. Doesn't seem like a great option either.

I ended up with a dead end, but I'm sure the community has solved such a basic problem before. Hope for some help.


Solution

  • This is possible using Scrollable.ensureVisible.

    I don't know how renderOneList is implemented so let's assume it's a SliverList on which you optionally set the key (used for centerKey).

    If that's the case, then you can simply set a global key on the first sliver too and use this key with ensureVisible.

    Here's a working example:

    import 'package:flutter/material.dart';
    
    class ScrollToTopExample extends StatelessWidget {
      const ScrollToTopExample({super.key});
    
      @override
      Widget build(BuildContext context) {
        return Stack(
          children: [
            Scaffold(
              appBar: AppBar(
                title: GestureDetector(
                  onTap: scrollToTop,
                  child: Text('Scroll to top example'),
                ),
              ),
              body: CustomScrollView(
                center: GlobalObjectKey('center-key'),
                slivers: [
                  SliverList(
                    key: GlobalObjectKey('top-key'),
                    delegate: SliverChildBuilderDelegate(
                      (context, index) => ListTile(
                        title: Text('Item -${index + 1}'),
                      ),
                      childCount: 100,
                    ),
                  ),
                  SliverList(
                    key: GlobalObjectKey('center-key'),
                    delegate: SliverChildBuilderDelegate(
                      (context, index) => ListTile(
                        title: Text('Item ${index + 1}'),
                      ),
                      childCount: 100,
                    ),
                  ),
                ],
              ),
            ),
            // Status bar tap override
            Positioned(
              top: 0,
              left: 0,
              right: 0,
              height: MediaQuery.of(context).padding.top,
              child: GestureDetector(
                excludeFromSemantics: true,
                onTap: onStatusBarTap,
              ),
            ),
          ],
        );
      }
    
      void scrollToTop() {
        final key = GlobalObjectKey('top-key');
        final context = key.currentContext;
    
        if (context == null) {
          return;
        }
    
        Scrollable.ensureVisible(
          context,
          duration: const Duration(milliseconds: 300),
          curve: Curves.easeInOut,
          alignment: 0.0,
          alignmentPolicy: ScrollPositionAlignmentPolicy.explicit,
        );
      }
    
      void onStatusBarTap() {
        print('onStatusBarTap');
        scrollToTop();
      }
    }
    

    The important part here is the alignment: 0 parameter together with alignmentPolicy: ScrollPositionAlignmentPolicy.explicit which ensures that the top of the target element is aligned with the top of the scrollable container.

    If you need more fine-grained control of the scroll position (eg. scroll to a certain item) you will need to set a key on the item's widget and make sure the widget is in the tree. For this reason, I believe using Scrollable.ensureVisible will not work to scroll to a certain item when the list is built dynamically using ListView.builder, SliverList.builder (or SliverChildBuilderDelegate).