Flutter Sliver 锁住你的美

4,709 阅读6分钟

前言

离上一篇Sliver相关文章 Flutter Sliver 你要的瀑布流小姐姐 居然有8个月了,写bug真费时间。Sliver虽然说肥肠好用,但是还是有那么一些小缺陷。

  • SliverPersistentHeader

使用过这个的人,应该都会碰到一个问题,那就是为啥必须要设置 minExtentmaxExtent ? 如果Widget里面的高度我没法提前知道,比如里面是一段文字,不知道长短,没法提前知道高度(当然你可以用TextPainter)。这种情况真的是很恶心,反正我一年前写代码的时候只能靠提前计算,low。

  • SliverToBoxAdapter

我想在CustomScrollView里面pinned一个Widget,我想每个人的第一反应是使用SliverPersistentHeader,然后 minExtent 和 maxExtent 设置成相同的。那么问题问题一样,我没法提前知道Widget的高度怎么办?

  • SliverAppbar

里面是用 SliverPersistentHeader 制作的,暴露出来一个expandedHeight。又来了,你又要我提前设置高度。

怎么样才能优美地锁住你呢

老规矩,第一步看源码。

SliverPinnedPersistentHeader

看源码 SliverPersistentHeader

这里官方根据 pinnedfloating 的不同,分为下面4种情况。

    if (floating && pinned)
      return _SliverFloatingPinnedPersistentHeader(delegate: delegate);
    if (pinned)
      return _SliverPinnedPersistentHeader(delegate: delegate);
    if (floating)
      return _SliverFloatingPersistentHeader(delegate: delegate);
    return _SliverScrollingPersistentHeader(delegate: delegate);

我这里默认你们看过之前的文章了,我就直接到最终影响布局的地方了,具体到影响布局的地方分为

  • RenderSliverScrollingPersistentHeader extends RenderSliverPersistentHeader
  • RenderSliverPinnedPersistentHeader extends RenderSliverPersistentHeader
  • RenderSliverFloatingPersistentHeader extends RenderSliverPersistentHeader
  • RenderSliverFloatingPinnedPersistentHeader extends RenderSliverFloatingPersistentHeader

可以看到的是最终它们都继承 RenderSliverPersistentHeader,会有相同的paint 过程,而他们的不同主要在于 performLayoutchildMainAxisPosition 2个方法。

我们这里只关注一下pinned:true的情况。

RenderSliverPinnedPersistentHeader 我这里只留下关键的代码。

  @override
  void performLayout() {
    final SliverConstraints constraints = this.constraints;
    // delegate 的 maxExtent
    final double maxExtent = this.maxExtent;
    // 是否与其他Sliver重叠
    final bool overlapsContent = constraints.overlap > 0.0;
    // layoutChild 这里会调用 delegate.build方法
    layoutChild(constraints.scrollOffset, maxExtent, overlapsContent: overlapsContent);
    // 剩余绘制区域
    final double effectiveRemainingPaintExtent = math.max(0, constraints.remainingPaintExtent - constraints.overlap);
    // layout区域
    final double layoutExtent = (maxExtent - constraints.scrollOffset).clamp(0.0, effectiveRemainingPaintExtent) as double;
    final double stretchOffset = stretchConfiguration != null ?
      constraints.overlap.abs() :
      0.0;
    geometry = SliverGeometry(
      scrollExtent: maxExtent,
      paintOrigin: constraints.overlap,
      paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
      layoutExtent: layoutExtent,
      maxPaintExtent: maxExtent + stretchOffset,
      maxScrollObstructionExtent: minExtent,
      cacheExtent: layoutExtent > 0.0 ? -constraints.cacheOrigin + layoutExtent : layoutExtent,
      hasVisualOverflow: true, // Conservatively say we do have overflow to avoid complexity.
    );
  }
  
  // 会影响paint方法中最终child绘制位置。
  @override
  double childMainAxisPosition(RenderBox child) => 0.0;

上面的东西应该比较熟悉了,Sliver系列里面必考知识。其实我们这里只需要想办法,将 minExtentmaxExtentperformLayout 的过程中计算出来就好了。

怎么通过 Widget 计算出 minExtentmaxExtent

将 Widget 转换成 RenderBox

我们都知道 Widget <=> Element <=> RenderOjbect ,Element 在 mount 中讲自己跟 parent 关联,这个时候我们就可以通过 updateChild 方法将 Widget转换成对应的 Element, 并且在 insertChildRenderObject 获取到 Widget 对应的 RenderOjbect(RenderBox).

重要代码如下:

  Element _minExtentPrototype;
  static final Object _minExtentPrototypeSlot = Object();
  Element _maxExtentPrototype;
  static final Object _maxExtentPrototypeSlot = Object();

  @override
  void mount(Element parent, dynamic newSlot) {
    super.mount(parent, newSlot);
    renderObject.element = this;
    _minExtentPrototype = updateChild(_minExtentPrototype,
        // minExtent对应的Widget
        widget.delegate.minExtentProtoType, _minExtentPrototypeSlot);
    _maxExtentPrototype = updateChild(_maxExtentPrototype,
        // maxExtent对应的Widget
        widget.delegate.maxExtentProtoType, _maxExtentPrototypeSlot);
  }
  
  @override
  void insertChildRenderObject(covariant RenderBox child, dynamic slot) {
    assert(renderObject.debugValidateChild(child));

    assert(child is RenderBox);
    // 根据 slot 给 RenderOject 赋值对应的 child
    if (slot == _minExtentPrototypeSlot) {
      renderObject.minProtoType = child;
    } else if (slot == _maxExtentPrototypeSlot) {
      renderObject.maxProtoType = child;
    } else {
      renderObject.child = child;
    }
  }  

其堆栈信息如下图

通过 RenderBox.layout 计算 minExtentmaxExtent

这部分就相对简单了,代码如下: 逻辑跟官方没有区别,只是 minExtentmaxExtent 是通过 minProtoTypemaxProtoType 计算而来


  RenderBox _minProtoType;
  RenderBox get minProtoType => _minProtoType;
  set minProtoType(RenderBox value) {
    if (_minProtoType != null) {
      dropChild(_minProtoType);
    }
    _minProtoType = value;
    if (_minProtoType != null) {
      adoptChild(_minProtoType);
    }
    markNeedsLayout();
  }

  RenderBox _maxProtoType;
  RenderBox get maxProtoType => _maxProtoType;
  set maxProtoType(RenderBox value) {
    if (_maxProtoType != null) {
      dropChild(_maxProtoType);
    }
    _maxProtoType = value;
    if (_maxProtoType != null) {
      adoptChild(_maxProtoType);
    }
    markNeedsLayout();
  }

  double get minExtent => getChildExtend(minProtoType, constraints);
  double get maxExtent => getChildExtend(maxProtoType, constraints);

double getChildExtend(RenderBox child, SliverConstraints constraints) {
  if (child == null) {
    return 0.0;
  }
  assert(child.hasSize);
  assert(constraints.axis != null);
  switch (constraints.axis) {
    case Axis.vertical:
      return child.size.height;
    case Axis.horizontal:
      return child.size.width;
  }
  return null;
}

  @override
  void performLayout() {
    final SliverConstraints constraints = this.constraints;
    minProtoType.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    maxProtoType.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    final bool overlapsContent = constraints.overlap > 0.0;
    excludeFromSemanticsScrolling =
        overlapsContent || (constraints.scrollOffset > maxExtent - minExtent);
    layoutChild(constraints.scrollOffset, maxExtent,
        overlapsContent: overlapsContent);
    final double effectiveRemainingPaintExtent =
        math.max(0, constraints.remainingPaintExtent - constraints.overlap);
    final double layoutExtent = (maxExtent - constraints.scrollOffset)
        .clamp(0.0, effectiveRemainingPaintExtent) as double;

    geometry = SliverGeometry(
      scrollExtent: maxExtent,
      paintOrigin: constraints.overlap,
      paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
      layoutExtent: layoutExtent,
      maxPaintExtent: maxExtent,
      maxScrollObstructionExtent: minExtent,
      cacheExtent: layoutExtent > 0.0
          ? -constraints.cacheOrigin + layoutExtent
          : layoutExtent,
      hasVisualOverflow:
          true, // Conservatively say we do have overflow to avoid complexity.
    );
  }

SliverPinnedToBoxAdapter

看源码 SliverToBoxAdapter

SliverToBoxAdapter 的源码相对简单,它是通过 ParentData 设置绘制开始点,在 paint 方法中进行绘制的

class RenderSliverToBoxAdapter extends RenderSliverSingleBoxAdapter {
  /// Creates a [RenderSliver] that wraps a [RenderBox].
  RenderSliverToBoxAdapter({
    RenderBox child,
  }) : super(child: child);

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    double childExtent;
    switch (constraints.axis) {
      case Axis.horizontal:
        childExtent = child.size.width;
        break;
      case Axis.vertical:
        childExtent = child.size.height;
        break;
    }
    assert(childExtent != null);
    final double paintedChildSize = calculatePaintOffset(constraints, from: 0.0, to: childExtent);
    final double cacheExtent = calculateCacheOffset(constraints, from: 0.0, to: childExtent);

    assert(paintedChildSize.isFinite);
    assert(paintedChildSize >= 0.0);
    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      maxPaintExtent: childExtent,
      hitTestExtent: paintedChildSize,
      hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
    );
    setChildParentData(child, constraints, geometry);
  }
}

其实我们开始说过,一个 pinned: true 的 SliverToBoxAdapter, 其实可以转换成为 SliverPersistentHeader(pinned: true) 并且 minExtent = maxExtent = child 的 extent。那么一切都好解决了,我们可以把 RenderSliverPinnedPersistentHeader 中的 performLayout 代码直接拿过来用, 并且把计算好的绘制开始点赋值给 ParentData 即可。

  @override
  void performLayout() {
    if (child == null) {
      geometry = SliverGeometry.zero;
      return;
    }
    child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    assert(childExtent != null);
    final double effectiveRemainingPaintExtent =
        math.max(0, constraints.remainingPaintExtent - constraints.overlap);
    final double layoutExtent = (childExtent - constraints.scrollOffset)
        .clamp(0.0, effectiveRemainingPaintExtent) as double;

    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintOrigin: constraints.overlap,
      paintExtent: math.min(childExtent, effectiveRemainingPaintExtent),
      layoutExtent: layoutExtent,
      maxPaintExtent: childExtent,
      maxScrollObstructionExtent: childExtent,
      cacheExtent: layoutExtent > 0.0
          ? -constraints.cacheOrigin + layoutExtent
          : layoutExtent,
      hasVisualOverflow:
          true, // Conservatively say we do have overflow to avoid complexity.
    );
    setChildParentData(child, constraints, geometry);
  }

  @override
  void setChildParentData(RenderObject child, SliverConstraints constraints,
      SliverGeometry geometry) {
    final SliverPhysicalParentData childParentData =
        child.parentData as SliverPhysicalParentData;
    assert(constraints.axisDirection != null);
    assert(constraints.growthDirection != null);
    Offset offset = Offset.zero;
    switch (applyGrowthDirectionToAxisDirection(
        constraints.axisDirection, constraints.growthDirection)) {
      case AxisDirection.up:
        offset += Offset(
            0.0,
            geometry.paintExtent -
                childMainAxisPosition(child as RenderBox) -
                childExtent);
        break;
      case AxisDirection.down:
        offset += Offset(0.0, childMainAxisPosition(child as RenderBox));
        break;
      case AxisDirection.left:
        offset += Offset(
            geometry.paintExtent -
                childMainAxisPosition(child as RenderBox) -
                childExtent,
            0.0);
        break;
      case AxisDirection.right:
        offset += Offset(childMainAxisPosition(child as RenderBox), 0.0);
        break;
    }
    childParentData.paintOffset = offset;
    assert(childParentData.paintOffset != null);
  }

  @override
  double childMainAxisPosition(RenderBox child) => 0.0;

ExtendedSliverAppbar

这个就不带看代码了,蛮简单的,里面是用 SliverPinnedPersistentHeader做的, 当然也是参考了官方的SliverAppbar`, 只是没有官方那么复杂,相信看过我前面几篇Sliver相关文章的人,都可以随便魔改。

怎么使用Sliver扩展库

添加引用

添加引用到 pubspec.yaml 下面的 dependencies

dependencies:
  extended_sliver: latest-version

执行 flutter packages get 下载

SliverPinnedPersistentHeader

跟官方的SliverPersistentHeader(pinned: true)一样, 不同的是你不需要去设置 minExtent 和 maxExtent。

它是通过设置 minExtentProtoTypemaxExtentProtoType 来计算 minExtent 和 maxExtent。

当Widget没有layout之前,你没法知道Widget的实际大小,这将是非常有用的组件。

    SliverPinnedPersistentHeader(
      delegate: MySliverPinnedPersistentHeaderDelegate(
        minExtentProtoType: Container(
          height: 120.0,
          color: Colors.red.withOpacity(0.5),
          child: FlatButton(
            child: const Text('minProtoType'),
            onPressed: () {
              print('minProtoType');
            },
          ),
          alignment: Alignment.topCenter,
        ),
        maxExtentProtoType: Container(
          height: 200.0,
          color: Colors.blue,
          child: FlatButton(
            child: const Text('maxProtoType'),
            onPressed: () {
              print('maxProtoType');
            },
          ),
          alignment: Alignment.bottomCenter,
        ),
      ),
    )

SliverPinnedToBoxAdapter

你可以轻松创建一个锁定的Sliver。

当child没有layout之前,你没法知道child的实际大小,这将是非常有用的组件。

    SliverPinnedToBoxAdapter(
      child: Container(
        padding: const EdgeInsets.all(20),
        color: Colors.blue.withOpacity(0.5),
        child: Column(
          children: <Widget>[
            const Text(
                '[love]Extended text help you to build rich text quickly. any special text you will have with extended text. '
                '\n\nIt\'s my pleasure to invite you to join \$FlutterCandies\$ if you want to improve flutter .[love]'
                '\n\nif you meet any problem, please let me konw @zmtzawqlp .[sun_glasses]'),
            FlatButton(
              child: const Text('I\'m button. click me!'),
              onPressed: () {
                debugPrint('click');
              },
            ),
          ],
        ),
      ),
    )

ExtendedSliverAppbar

你可以创建一个SliverAppbar,不用去设置expandedHeight。

return CustomScrollView(
  slivers: <Widget>[
    ExtendedSliverAppbar(
      title: const Text(
        'ExtendedSliverAppbar',
        style: TextStyle(color: Colors.white),
      ),
      leading: const BackButton(
        onPressed: null,
        color: Colors.white,
      ),
      background: Image.asset(
        'assets/cypridina.jpeg',
        fit: BoxFit.cover,
      ),
      actions: Padding(
        padding: const EdgeInsets.all(10.0),
        child: Icon(
          Icons.more_horiz,
          color: Colors.white,
        ),
      ),
    ),
  ],
);

复杂的例子

例子地址, 包括下面的功能。

  • 文字随机长度,不用写死 maxExtent
  • 下拉刷新
  • 根据按钮的位置来控制Toolbar里面按钮的显隐

image转存失败,建议直接上传图片文件

结语

2020年是一个忙碌的一年,也是Flutter快速发展的一年,web 的性能得到了提高,macos,linux 都已经 beta, uwp 也在路上。就差你了,看什么看,就是说你呢?! 放上extended_sliver,如果你有什么好的Sliver效果,欢迎pr;如果你有什么新需求,欢迎氪金。

欢迎加入Flutter Candies,一起生产可爱的Flutter小糖果

最最后放上Flutter Candies全家桶,真香。