How to get the SliverPersistentHeader to "overgrow"

While looking for a solution for this problem, I came across three different ways to solve it:

  1. Create a Stack that contains the CustomScrollView and a header widget (overlaid on top of the scroll view), provide a ScrollController to the CustomScrollView and pass the controller to the header widget to adjust its size
  2. Use a ScrollController, pass it to the CustomScrollView and use the value of the controller to adjust the maxExtent of the SliverPersistentHeader (this is what Eugene recommended).
  3. Write my own Sliver to do exactly what I want.

I ran into problems with solution 1 & 2:

  1. This solution seemed a bit "hackish" to me. I also had the problem, that "dragging" the header didn't scroll anymore, since the header was not inside the CustomScrollView anymore.
  2. Adjusting the size of the sliver during scrolling results in strange side effects. Notably, the distance between the header and slivers below increases during the scroll.

That's why I opted for solution 3. I'm sure the way I implemented it, is not the best, but it works exactly as I want:

import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'dart:math' as math;

/// The delegate that is provided to [ElSliverPersistentHeader].
abstract class ElSliverPersistentHeaderDelegate {
  double get maxExtent;
  double get minExtent;

  /// This acts exactly like `SliverPersistentHeaderDelegate.build()` but with
  /// the difference that `shrinkOffset` might be negative, in which case,
  /// this widget exceeds `maxExtent`.
  Widget build(BuildContext context, double shrinkOffset);
}

/// Pretty much the same as `SliverPersistentHeader` but when the user
/// continues to drag down, the header grows in size, exceeding `maxExtent`.
class ElSliverPersistentHeader extends SingleChildRenderObjectWidget {
  final ElSliverPersistentHeaderDelegate delegate;
  ElSliverPersistentHeader({
    Key key,
    ElSliverPersistentHeaderDelegate delegate,
  })  : this.delegate = delegate,
        super(
            key: key,
            child:
                _ElSliverPersistentHeaderDelegateWrapper(delegate: delegate));

  @override
  _ElPersistentHeaderRenderSliver createRenderObject(BuildContext context) {
    return _ElPersistentHeaderRenderSliver(
        delegate.maxExtent, delegate.minExtent);
  }
}

class _ElSliverPersistentHeaderDelegateWrapper extends StatelessWidget {
  final ElSliverPersistentHeaderDelegate delegate;

  _ElSliverPersistentHeaderDelegateWrapper({Key key, this.delegate})
      : super(key: key);

  @override
  Widget build(BuildContext context) =>
      LayoutBuilder(builder: (context, constraints) {
        final height = constraints.maxHeight;
        return delegate.build(context, delegate.maxExtent - height);
      });
}

class _ElPersistentHeaderRenderSliver extends RenderSliver
    with RenderObjectWithChildMixin<RenderBox> {
  final double maxExtent;
  final double minExtent;

  _ElPersistentHeaderRenderSliver(this.maxExtent, this.minExtent);

  @override
  bool hitTestChildren(HitTestResult result,
      {@required double mainAxisPosition, @required double crossAxisPosition}) {
    if (child != null) {
      return child.hitTest(result,
          position: Offset(crossAxisPosition, mainAxisPosition));
    }
    return false;
  }

  @override
  void performLayout() {
    /// The amount of scroll that extends the theoretical limit.
    /// I.e.: when the user drags down the list, although it already hit the
    /// top.
    ///
    /// This seems to be a bit of a hack, but I haven't found a way to get this
    /// information in another way.
    final overScroll =
        constraints.viewportMainAxisExtent - constraints.remainingPaintExtent;

    /// The actual Size of the widget is the [maxExtent] minus the amount the
    /// user scrolled, but capped at the [minExtent] (we don't want the widget
    /// to become smaller than that).
    /// Additionally, we add the [overScroll] here, since if there *is*
    /// "over scroll", we want the widget to grow in size and exceed
    /// [maxExtent].
    final actualSize =
        math.max(maxExtent - constraints.scrollOffset + overScroll, minExtent);

    /// Now layout the child with the [actualSize] as `maxExtent`.
    child.layout(constraints.asBoxConstraints(maxExtent: actualSize));

    /// We "clip" the `paintExtent` to the `maxExtent`, otherwise the list
    /// below stops moving when reaching the border.
    ///
    /// Tbh, I'm not entirely sure why that is.
    final paintExtent = math.min(actualSize, maxExtent);

    /// For the layout to work properly (i.e.: the following slivers to
    /// scroll behind this sliver), the `layoutExtent` must not be capped
    /// at [minExtent], otherwise the next sliver will "stop" scrolling when
    /// [minExtent] is reached,
    final layoutExtent = math.max(maxExtent - constraints.scrollOffset, 0.0);

    geometry = SliverGeometry(
      scrollExtent: maxExtent,
      paintExtent: paintExtent,
      layoutExtent: layoutExtent,
      maxPaintExtent: maxExtent,
    );
  }

  @override
  void paint(PaintingContext context, Offset offset) {
    if (child != null) {
      /// This sliver is always displayed at the top.
      context.paintChild(child, Offset(0.0, 0.0));
    }
  }
}

EDIT: I found another way how to stretch an image in AppBar here is minimal reproducible example:

import 'package:flutter/material.dart';

void main() {
  runApp(MaterialApp(
    debugShowCheckedModeBanner: false,
    home: Home(),
  ));
}

class Home extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: CustomScrollView(
        physics: const BouncingScrollPhysics(),
        slivers: [
          SliverAppBar(
            pinned: true,
            expandedHeight: 200,
            title: Text('Title'),
            stretch: true,
            flexibleSpace: FlexibleSpaceBar(
              background: Image.network('https://i.imgur.com/2pQ5qum.jpg', fit: BoxFit.cover),
            ),
          ),
          SliverToBoxAdapter(
            child: Column(
              children: List.generate(50, (index) {
                return Container(
                  height: 72,
                  color: Colors.blue[200],
                  alignment: Alignment.centerLeft,
                  margin: EdgeInsets.all(8),
                  child: Text('Item $index'),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }
}

The magic is in - stretch: true and BouncingScrollPhysics() properties.
There is not complicated listeners, stageful widgets so on. Just FlexibleSpaceBar with an image on background.


Now you can create your own SliverPersistentHeaderDelegate and override this param"

@override
  OverScrollHeaderStretchConfiguration get stretchConfiguration =>
      OverScrollHeaderStretchConfiguration();

By default if null, but once you added it will allow you to stretch the view.

This is the class I use:


class CustomSliverDelegate extends SliverPersistentHeaderDelegate {
  final Widget child;
  final Widget title;
  final Widget background;
  final double topSafeArea;
  final double maxExtent;

  CustomSliverDelegate({
    this.title,
    this.child,
    this.maxExtent = 350,
    this.background,
    this.topSafeArea = 0,
  });

  @override
  Widget build(BuildContext context, double shrinkOffset,
      bool overlapsContent) {
    final appBarSize = maxExtent - shrinkOffset;
    final proportion = 2 - (maxExtent / appBarSize);
    final percent = proportion < 0 || proportion > 1 ? 0.0 : proportion;
    return Theme(
      data: ThemeData.dark(),
      child: ConstrainedBox(
        constraints: BoxConstraints(minHeight: maxExtent),
        child: Stack(
          children: [
            Positioned(
              bottom: 0.0,
              left: 0.0,
              right: 0.0,
              top: 0,
              child: background,
            ),
            Positioned(
              bottom: 0.0,
              left: 0.0,
              right: 0.0,
              child: Opacity(opacity: percent, child: child),
            ),
            Positioned(
              top: 0.0,
              left: 0.0,
              right: 0.0,
              child: AppBar(
                title: Opacity(opacity: 1 - percent, child: title),
                backgroundColor: Colors.transparent,
                elevation: 0,
              ),
            ),
          ],
        ),
      ),
    );
  }

  @override
  OverScrollHeaderStretchConfiguration get stretchConfiguration =>
      OverScrollHeaderStretchConfiguration();

  @override
  double get minExtent => kToolbarHeight + topSafeArea;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return true;
  }
}