Automatically compute `maxExtent` of `SliverPersistentHeaderDelegate`

Viewed 2786

Consider the following example that is intended to render a pinned title bar (with a potentially custom long/short text) inside of a CustomScrollView.

class TitleBar extends StatelessWidget {
  TitleBar(this.text);

  final String text;

  @override
  Widget build(BuildContext context) => Text(
        text,
        style: TextStyle(fontSize: 30),
        maxLines: 3,
        overflow: TextOverflow.ellipsis,
      );
}

class TitleBarDelegate extends SliverPersistentHeaderDelegate {
  final String text;

  TitleBarDelegate(this.text);

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => TitleBar(text);

  @override
  bool shouldRebuild(TitleBarDelegate oldDelegate) => oldDelegate.text != text;

  @override
  double get maxExtent => ???;

  @override
  double get minExtent => maxExtent; // doesn't shrink
}

class MyApp extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      title: 'Flutter Demo',
      theme: ThemeData(
        primarySwatch: Colors.blue,
      ),
      home: Scaffold(
        body: CustomScrollView(
          slivers: [
            SliverPersistentHeader(
              pinned: true,
              delegate: TitleBarDelegate('Potentially very long text'),
            ),
            SliverToBoxAdapter(
              child: Text("Foo " * 1000),
            ),
          ],
        ),
      ),
    );
  }
}

The question is: How can I compute maxExtent based on the actual TitleBar. The problem is that the actual TitleBar's size depends on the text and is hence to generally computable in advance.

Note that TitleBar might also have some more complex layout than it has in the example above. So the general question is how to 'shrink-wrap' an SliverPersistentHeaderDelegate.

3 Answers

I recently faced the same issue and came up with the following "dirty" solution, assuming the header only contains text with a given text style and you provide it with a max width value.

The SliverPersistentHeaderDelegate looks like this:

class SliverPersistentTitleDelegate extends SliverPersistentHeaderDelegate {

SliverPersistentTitleDelegate({
    @required this.width,
    @required this.text,
    this.textStyle,
    this.padding,
    this.extend = 10
}) {
    // create a text painter
    final TextPainter textPainter = TextPainter(
        textDirection: TextDirection.ltr,
    )
    ..text = TextSpan(
        text: text,
        style: _textStyle,
    );

    // layout the text with the provided width, taking the horizontal padding into account
    final double horizontalPadding = _padding.left + _padding.right;
    textPainter.layout(maxWidth: width - horizontalPadding);

    // measure minHeight and maxHeight, taking the vertical padding and text height into account
    final double verticalPadding = _padding.top + _padding.bottom;
    _minHeight = textPainter.height + verticalPadding;
    _maxHeight = minHeight + extend;
}

final double width;
final String text;
final TextStyle textStyle;
final EdgeInsets padding;
final double extend;
double _minHeight;
double _maxHeight;

final core.ThemeProvider _themeProvider = di.get<core.ThemeProvider>();

@override
double get minExtent => _minHeight;

@override
double get maxExtent => maxHeight;

TextStyle get _textStyle => this.textStyle
    ?? _themeProvider
        .defaultTheme
        .appBarTheme
        .textTheme;

EdgeInsets get _padding => padding ?? EdgeInsets.zero;

@override
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent)
{
    return Padding(
        padding: _padding,
        child: Text(
            text,
            style: _textStyle ,
        ),
    );
}

@override
bool shouldRebuild(SliverPersistentTitleDelegate oldDelegate) {

    return width != oldDelegate.width
        || text != oldDelegate.text
        || textStyle != oldDelegate.textStyle
        || padding != oldDelegate.padding
        || extend != oldDelegate.extend
        || _maxHeight != oldDelegate._maxHeight
        || _minHeight != oldDelegate._minHeight;
}

}

Using this class is simple:

return LayoutBuilder(
    builder: (context, constrains) {

    return CustomScrollView(
        slivers: <Widget>[
            SliverPersistentHeader(
                pinned: false,
                floating: true,
                delegate: SliverPersistentTitleDelegate(
                    width: constrains.maxWidth,
                    text: "Some long dynamic title",
                    textStyle: titleTextStyle,
                    padding: EdgeInsets.only(
                        left: 16,
                        right: 16,
                    ),
                ),
            )
        ],
    );
},

);

This is another dirty/hacky solution since it will involve building the thing twice.

The height of a widget can only be retrieved after building it, so the idea is to get its height post-build via its GlobalKey and rebuild the widget again, wrapped within SliverPersistentHeader with its height set as the maxExtent. The rebuilding is invoked by ValueListenableBuilder and its listener.

For this example below, I wish to set the calculated height as the minExtent and maxExtent

import 'package:flutter/material.dart';
import 'package:flutter/scheduler.dart';

class DynamicHeightSliverPinningHeader extends StatefulWidget {
  final Widget widget;

  const DynamicHeightSliverPinningHeader({
    super.key,
    required this.widget,
  });

  @override
  State<DynamicHeightSliverPinningHeader> createState() => _DynamicHeightSliverPinningHeaderState();
}

class _DynamicHeightSliverPinningHeaderState extends State<DynamicHeightSliverPinningHeader> {
  final _widgetKey = GlobalKey<_DynamicHeightSliverPinningHeaderState>();
  final _widgetHeightNotifier = ValueNotifier<double>(-1);

  late Widget _widget;

  @override
  void initState() {
    _widget = Container(
      key: _widgetKey,
      child: widget.widget,
    );

    SchedulerBinding.instance.addPostFrameCallback((_) {
      // This would've been done after the first frame is built
      // This should invoke the ValueListenableBuilder to rebuild 
      _widgetHeightNotifier.value = _widgetKey.currentContext!.size!.height;
    });

    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return ValueListenableBuilder(
      valueListenable: _widgetHeightNotifier,
      builder: (context, _, __) {
        
        // This should be returned from the start.
        if (_widgetHeightNotifier.value == -1) {
          return SliverToBoxAdapter(
            child: _widget,
          );
        }
        
        // This should be returned after the listener detects the change.
        return SliverPersistentHeader(
          pinned: true,
          delegate: SliverPinningHeaderDelegate(
            _widget,
            _widgetHeightNotifier.value,
            _widgetHeightNotifier.value,
          ),
        );
      },
    );
  }
}

class SliverPinningHeaderDelegate extends SliverPersistentHeaderDelegate {
  final Widget widget;

  @override
  final double minExtent, maxExtent;

  const SliverPinningHeaderDelegate(
    this.widget,
    this.maxExtent,
    this.minExtent,
  );

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    return widget;
  }

  @override
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate) => true;
}

To detail out the rendering behaviour by a bit:

  • it first renders the widget itself in SliverToBoxAdapter (bound with a GlobalKey)
  • after the first frame is rendered, it gets calculated height from said GlobalKey and assign that value to the ValueNotifier
  • with that, the ValueNotifier change is listened, and with said height, the ValueListenableBuilder will rebuild the widget that is wrapped within SliverPersistentHeader and its delegate

I think you may be looking for _titleBar.preferredSize.height but I can't be sure

Related