The UI I'm making usually starts with the bottom sliver scrolled all the way in, so that its top is at the top of the view. But also:

  • Needs an extra empty space at the top, in case the user wants to pull the content down so that they can reach it without moving their hand from the bottom of the phone (this is a really basic ergonomic feature and I think we should expect to see it become pretty common soon, first led by apps moving more of their key functionality to the bottom of the screen, EG, Firefox's url bar.) (Currently, I'm using the appBar sliver for this, but I can imagine a full solution not using that)

  • Might need extra empty space at the bottom, whenever the content in that bottom sliver wont be long enough to allow it to be scrolled in all the way. It will seem buggy and irregular otherwise. Ideally I'd impose a minHeight so that the bottom sliver will always at least as tall as the screen, but it's a sliver, so I'm pretty sure that's not possible/ugly-difficult.

The avenue I'm considering right now is, ScrollPhysics wrapper that modifies its ScrollMetrics so that maxExtent and minExtent are larger. As far as I can tell, this will allow the CustomScrollView (given this ScrollPhysics) to overscroll. It feels kinda messy though. It would be nice to know what determines maxExtent and minExtent in the first place and alter that.


Solution 1: mako

Lacking better options, I went ahead with the plan, and made my own custom ScrollPhysics class that allows overscroll by the given amount, extra.

    return CustomScrollView(
        physics: _ExtraScrollPhysics(extra: 100 * MediaQuery.of(context).devicePixelRatio),
        ...

And _ExtraScrollPhysics is basically just an extended AlwaysScrollable with all of the methods that take ScrollMetrics overloaded to copy its contents into a ScrollMetric with a minScrollExtent that has been decreased by -extra, then passing it along to the superclass's version of the method. It turns out that adjusting the maxScrollExtent field wasn't necessary for the usecase I described!

This has one drawback, the overscroll glow indicator, on top, appears at the top of the content, rather than the top of the scroll view, which looks pretty bad. It looks like this might be fixable, but I'd far prefer a method where this wasn't an issue.


Solution 2: Stack Underflow

mako's solution is a good starting point but it does not work for mouse wheel scrolling, only includes overscroll at the top, and did not implement the solution to the glow indicator problem.

A more general solution

For web, use a Listener to detect PointerSignalEvents, and manually scroll the list with a ScrollController.

For mobile, listening for events is not needed.

Extend a ScrollPhysics class as mako suggested but use NeverScrollableScrollPhysics for web to prevent the physics from interfering with the manual scrolling. To fix the glow indicator problem for mobile, wrap your CustomScrollView in a ScrollConfiguration as provided by nioncode.

GestureBinding.instance.pointerSignalResolver.register is used to prevent the scroll event from propogating up the widget tree.

Example

import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
import 'package:my_project/custom_glowing_overscroll_indicator.dart';
import 'package:my_project/overscroll_physics.dart';

class OverscrollList extends StatelessWidget {

  final ScrollController _scrollCtrl = ScrollController();
  final double _topOverscroll = 200;
  final double _bottomOverscroll = 200;

  void _scrollList(Offset offset) {
    _scrollCtrl.jumpTo(
      _scrollCtrl.offset + offset.dy,
    );
  }

  @override
  Widget build(BuildContext context) {
    return Container(
      height: 300,
      decoration: BoxDecoration(border: Border.all(width: 1)),
      child: Listener(
        onPointerSignal: (PointerSignalEvent event) {
          if (kIsWeb) {
            GestureBinding.instance.pointerSignalResolver.register(event, (event) {
              _scrollList((event as PointerScrollEvent).scrollDelta);
            });
          }
        },
        child: ScrollConfiguration(
          behavior: OffsetOverscrollBehavior(
            leadingPaintOffset: -_topOverscroll,
            trailingPaintOffset: -_bottomOverscroll,
          ),
          child: CustomScrollView(
            controller: _scrollCtrl,
            physics: kIsWeb
                ? NeverScrollableOverscrollPhysics(
                    overscrollStart: _topOverscroll,
                    overscrollEnd: _bottomOverscroll,
                  )
                : AlwaysScrollableOverscrollPhysics(
                    overscrollStart: _topOverscroll,
                    overscrollEnd: _bottomOverscroll,
                  ),
            slivers: [
              SliverToBoxAdapter(
                child: Container(width: 400, height: 100, color: Colors.blue),
              ),
              SliverToBoxAdapter(
                child: Container(width: 400, height: 100, color: Colors.yellow),
              ),
              SliverToBoxAdapter(
                child: Container(width: 400, height: 100, color: Colors.red),
              ),
              SliverToBoxAdapter(
                child: Container(width: 400, height: 100, color: Colors.orange),
              ),
            ],
          ),
        ),
      ),
    );
  }
}

dartpad demo

Mobile result:
enter image description here