如何让SliverPersistentHeader“过度生长”

18
我在我的CustomScrollView中使用SliverPersistentHeader来创建一个固定的标题栏,当用户滚动时它会缩小和扩展,但是当它达到最大尺寸时,它感觉有点僵硬,因为它不会“过度生长”。

这里是我想要的行为(来自Spotify应用程序)和我目前拥有的行为的视频:

行为视频.

6个回答

15

现在你可以创建自己的SliverPersistentHeaderDelegate并覆盖此参数。

@override
  OverScrollHeaderStretchConfiguration get stretchConfiguration =>
      OverScrollHeaderStretchConfiguration();

默认情况下为null,但一旦添加,它将允许您拉伸视图。

这是我使用的类:


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;
  }
}

14

在寻找解决这个问题的方案时,我找到了三种不同的解决方法:

  1. 创建一个包含CustomScrollView和头部小部件(覆盖在滚动视图顶部)的Stack,为CustomScrollView提供一个ScrollController并将控制器传递给头部小部件以调整其大小。
  2. 使用ScrollController,将其传递给CustomScrollView并使用控制器的值来调整SliverPersistentHeadermaxExtent(这是Eugene推荐的方法)。
  3. 编写自己的Sliver以完全符合我的要求。

我在解决方案1和2中遇到了问题:

  1. 对我来说,这个解决方案似乎有点“hackish”。 我还遇到了一个问题,即由于标题不再位于CustomScrollView内部,“拖动”标题不再滚动。
  2. 在滚动过程中调整sliver大小会导致奇怪的副作用。 特别是,在滚动过程中,标题和下面的sliver之间的距离会增加。

这就是为什么我选择了解决方案3。 我确信我实现的方法不是最好的,但它完全符合我的要求:

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));
    }
  }
}

12

编辑:我找到了另一种在 AppBar 中拉伸图像的方法,这里是最小可复现示例:

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'),
                );
              }),
            ),
          ),
        ],
      ),
    );
  }
}

魔法在于 - stretch: trueBouncingScrollPhysics() 属性。
没有复杂的监听器、有状态的小部件等等。只需在 background 上使用带有图像的 FlexibleSpaceBar


谢谢!但是这并不会在我向下滚动时增加SliverAppBar的大小。我尝试修改您的代码,使其也增加大小,但显然这并不起作用,因为它会干扰其他滚动项。 - enyo
谢谢,我会尝试一下。顺便说一下,我正在使用 SliverPersistentHeader 而不是 SliverAppBar,所以我不确定这个方法是否同样适用。 - enyo
我刚试了一下,使用SliverPersistentHeader也可以。 - user10539074
嗯,在你的屏幕截图中,我认为同样的问题是可见的:当你向下拖动列表(项目0、项目1等...)时,列表与标题之间的距离增加了。你知道这可能是什么原因吗? - enyo

3

我通过创建自定义的SliverPersistentHeaderDelegate来解决了这个问题。

只需重写stretchConfiguration属性的getter方法即可。以下是我的代码,希望对你有用。

class LargeCustomHeader extends SliverPersistentHeaderDelegate {
  LargeCustomHeader(
      {this.children,
      this.title = '',
      this.childrenHeight = 0,
      this.backgroundImage,
      this.titleHeight = 44,
      this.titleMaxLines = 1,
      this.titleTextStyle = const TextStyle(
          fontSize: 30,
          letterSpacing: 0.5,
          fontWeight: FontWeight.bold,
          height: 1.2,
          color: ColorConfig.primaryContrastColor)}) {}

  final List<Widget> children;
  final String title;
  final double childrenHeight;

  final String backgroundImage;

  final int _fadeDuration = 250;
  final double titleHeight;
  final int titleMaxLines;

  final double _navBarHeight = 56;

  final TextStyle titleTextStyle;

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
        constraints: BoxConstraints.expand(),
        decoration: BoxDecoration(
          // borderRadius: BorderRadius.vertical(bottom: Radius.circular(35.0)),
          color: Colors.black,
        ),
        child: Stack(
          fit: StackFit.loose,
          children: <Widget>[
            if (this.backgroundImage != null) ...[
              Positioned(
                top: 0,
                left: 0,
                right: 0,
                bottom: 0,
                child: FadeInImage.assetNetwork(
                  placeholder: "assets/images/image-placeholder.png",
                  image: backgroundImage,
                  placeholderScale: 1,
                  fit: BoxFit.cover,
                  alignment: Alignment.center,
                  imageScale: 0.1,
                  fadeInDuration: const Duration(milliseconds: 500),
                  fadeOutDuration: const Duration(milliseconds: 200),
                ),
              ),
              Positioned(
                top: 0,
                left: 0,
                right: 0,
                bottom: 0,
                child: Container(
                  color: Color.fromRGBO(0, 0, 0, 0.6),
                ),
              ),
            ],
            Positioned(
                bottom: 0,
                left: 0,
                right: 0,
                top: _navBarHeight + titleHeight,
                child: AnimatedOpacity(
                    opacity: (shrinkOffset >= childrenHeight / 3) ? 0 : 1,
                    duration: Duration(milliseconds: _fadeDuration),
                    child: Column(
                      crossAxisAlignment: CrossAxisAlignment.stretch,
                      children: <Widget>[if (children != null) ...children],
                    ))),
            Positioned(
              top: _navBarHeight,
              left: 0,
              right: 0,
              height: titleHeight,
              child: Padding(
                padding: const EdgeInsets.only(
                    right: 30, bottom: 0, left: 30, top: 5),
                child: AnimatedOpacity(
                  opacity: (shrinkOffset >= childrenHeight + (titleHeight / 3))
                      ? 0
                      : 1,
                  duration: Duration(milliseconds: _fadeDuration),
                  child: Text(
                    title,
                    style: titleTextStyle,
                    maxLines: titleMaxLines,
                    overflow: TextOverflow.ellipsis,
                  ),
                ),
              ),
            ),
            Container(
              color: Colors.transparent,
              height: _navBarHeight,
              child: AppBar(
                  elevation: 0.0,
                  backgroundColor: Colors.transparent,
                  title: AnimatedOpacity(
                    opacity:
                        (shrinkOffset >= childrenHeight + (titleHeight / 3))
                            ? 1
                            : 0,
                    duration: Duration(milliseconds: _fadeDuration),
                    child: Text(
                      title,
                    ),
                  )),
            )
          ],
        ));
  }

  @override
  double get maxExtent => _navBarHeight + titleHeight + childrenHeight;

  @override
  double get minExtent => _navBarHeight;

  // @override
  // FloatingHeaderSnapConfiguration get snapConfiguration => FloatingHeaderSnapConfiguration() ;

  @override
  OverScrollHeaderStretchConfiguration get stretchConfiguration =>
      OverScrollHeaderStretchConfiguration(
        stretchTriggerOffset: maxExtent,
        onStretchTrigger: () {},
      );

  double get maxShrinkOffset => maxExtent - minExtent;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    //TODO: implement specific rebuild checks
    return true;
  }
}

1

也许我有一种简单的编码方式。

通过使用SliverAppBar和内部子小部件leadingFlexibleSpaceBar和内部子小部件title

通过LayoutBuilder,我们可以制作一些动画。

gif image

完整代码链接

SliverAppBar(
  toolbarHeight: _appBarHeight,
  collapsedHeight: _appBarHeight,
  backgroundColor: Colors.white.withOpacity(1),
  shadowColor: Colors.white.withOpacity(0),
  expandedHeight: maxWidth,
  /// ========================================
  /// custom your app bar
  /// ========================================
  leading: Container(
    width: 100,
    height: _appBarHeight,
    // color: Colors.blueAccent,
    child: Center(
      child: Icon(Icons.arrow_back_ios, color: Colors.black),
    ),
  ),
  pinned: true,
  stretch: true,
  flexibleSpace: FlexibleSpaceBar(
    stretchModes: [
      StretchMode.fadeTitle,
      StretchMode.blurBackground,
      StretchMode.zoomBackground,
    ],
    titlePadding: EdgeInsets.all(0),
    title: LayoutBuilder(
      builder: (_, __) {
        var height = __.maxHeight;
        /// ========================================
        /// custom animate you want by height change
        /// ========================================
        // Logger.debug(__.maxHeight);
        return Stack(
          children: [
            if (height > 100)
              Container(
                width: double.infinity,
                height: double.infinity,
                color: Colors.black.withOpacity(0.3),
              ),
          ],
        );
      },
    ),
    background: Image.network(
      'https://xx',
      fit: BoxFit.cover,
    ),
  ),
),

0

您可以尝试使用 SliverAppBar,并将stretch:true传递给它,并将您想要在应用栏中显示的小部件作为 flexibleSpace

这是一个例子:

CustomScrollView(
  physics: BouncingScrollPhysics(),
  slivers: <Widget>[
    SliverAppBar(
      stretch: true,
      floating: true,
      backgroundColor: Colors.black,
      expandedHeight: 300,
      centerTitle: true,
      title: Text("My Custom Bar"),
      leading: IconButton(
        onPressed: () {},
        icon: Icon(Icons.menu),
      ),
      actions: <Widget>[
        IconButton(
          onPressed: () {},
          icon: Icon(Icons.search),
        )
      ],
      flexibleSpace: FlexibleSpaceBar(
        collapseMode: CollapseMode.pin,
        stretchModes: 
        [
          StretchMode.zoomBackground,
          StretchMode.blurBackground
        ],
        background: YourCustomWidget(),
      ),
    ),
    SliverList(
      delegate: SliverChildListDelegate(
        [
          Container(color: Colors.red, height: 300.0),
          Container(color: Colors.blue, height: 300.0),
        ],
      ),
    ),
  ],
);

网页内容由stack overflow 提供, 点击上面的
可以查看英文原文,
原文链接