Flutter - 循序渐进 Sliver

11,238 阅读8分钟

列表组件在移动端上尤为重要,Sliver 作为 Flutter 列表组件中重要的一部分,开发者非常有必要了解 Sliver 的原理和用法。

两种类型的布局

Flutter 的布局可以分为两种:

  • Box ( RenderBox ): 2D 绘制布局
  • Sliver ( RenderSliver ):滚动布局

重要的概念

Sliver

Sliver 是 Flutter 中的一个概念,表示可滚动布局中的一部分,它的 child 可以是普通的 Box Widget。

ViewPort

  • ViewPort 是一个显示窗口,它内部可包含多个 Sliver;
  • ViewPort 的宽高是确定的,它内部 Slivers 的宽高之和是可以大于自身的宽高的;
  • ViewPort 为了提高性能采用懒加载机制,它只会绘制可视区域内容 Widget。

ViewPort 有一些重要属性:

class Viewport extends MultiChildRenderObjectWidget {
  /// 主轴方向
  final AxisDirection axisDirection;
  /// 纵轴方向
  final AxisDirection crossAxisDirection;
  /// center 决定 viewport 的 zero 基准线,也就是 viewport 从哪个地方开始绘制,默认是第一个 sliver
  /// center 必须是 viewport slivers 中的一员的 key
  final Key center;
  
/// 锚点,取值[0,1],和 zero 的相对位置,比如 0.5 代表 zero 被放到了 Viewport.height / 2 处
  final double anchor;
  /// 滚动的累计值,确切的说是 viewport 从什么地方开始显示
  final ViewportOffset offset;
  /// 缓存区域,也就是相对有头尾需要预加载的高度
  final double cacheExtent;
  /// children widget
  List<Widget> slivers;
  }

一图胜千言:

上图中假设每个 sliver 的 height 都相等且等于屏幕高度的 ⅕,这样设置 center = sliver1,屏幕的第一个显示的应该是 sliver 1,但是因为 anchor = 0.2,0.2 * viewport.height 正好等于 sliver1 的高度,所以屏幕上显示的第一个是 sliver 2。

ScrollPostion

ScrollPosition 决定了 Viewport 哪些区域是可见的,它包含了Viewport 的滚动信息,它的主要成员变量如下:


abstract class ScrollPosition extends ViewportOffset with ScrollMetrics {
  // 滚动偏移量
  double _pixels;
  // 设置滚动响应效果,比如滑动停止后的动画
  final ScrollPhysics physics;
  // 保存当前的滚动偏移量到 PageStore 中,当 Scrollable 重建后可以恢复到当前偏移量
  final bool keepScrollOffset;
  // 最小滚动值
  double _minScrollExtent;
  // 最大滚动值
  double _maxScrollExtent;
  ...
}

ScrollPosition 的类继承关系如下:

|-- Listenable
|---- ChangeNotifier
|------ ScrollPosition
|-------- ScrollPositionWithSingleContext

所以 ScrollPosition 可以作为被观察者,当数据改变的时候可以通知观察者。

Scrollable

Scrollable 是一个可滚动的 Widget,它主要负责:

  • 监听用户的手势,计算滚动状态发出 Notification
  • 计算 offset 通知 listeners

Scrollable 本身不具有绘制内容的能力,它通过构造注入的 viewportBuilder 来创建一个 Viewport 来显示内容,当滚动状态变化的时候,Scrollable 就会不断的更新 Viewport 的 offset ,Viewport 就会不断的更新显示内容。

Scrollable 主要结构如下:


Widget result = _ScrollableScope(
      scrollable: this,
      position: position,
      child: Listener(
        onPointerSignal: _receivedPointerSignal,
        child: RawGestureDetector(
          gestures: _gestureRecognizers,
          ...,
          child: Semantics(
            ...
            child: IgnorePointer(
			...
              child: widget.viewportBuilder(context, position),
            ),
          ),
        ),
      ),
    );
  • _ScrollableScope 继承自 InheritedWidget,这样它的 children 可以方便的获取 scrollable 和 position;
  • RawGestureDetector 负责手势监听,手势变化时会回调 _gestureRecognizers;
  • viewportBuilder 会生成 viewport;

SliverConstraints

和 Box 布局使用 BoxConstraints 作为约束类似,Sliver 布局采用 SliverConstraints 作为约束,但相对于 Box 要复杂的多,可以理解为 SliverConstraints 描述了 Viewport 和它内部的 Slivers 之间的布局信息:

class SliverConstraints extends Constraints {
  // 主轴方向
  final AxisDirection axisDirection;
  // 窗口增长方向
  final GrowthDirection growthDirection;
  // 如果 Direction 是 AxisDirection.down,scrollOffset 代表 sliver 的 top 滑过 viewport 的 top 的值,没滑过 viewport 的 top 时 scrollOffset 为 0。
  final double scrollOffset;
  // 上一个 sliver 覆盖下一个 sliver 的大小(只有上一个 sliver 是 pinned/floating 才有效)
  final double overlap;
  // 轮到当前 sliver 开始绘制了,需要 viewport 告诉 sliver 当前还剩下多少区域可以绘制,受 viewport 的 size 影响
  final double remainingPaintExtent;
  // viewport 主轴上的大小
  final double viewportMainAxisExtent;
  // 缓存区起点(相对于 scrolloffset),如果 cacheExtent 设置为 0,那么 cacheOrigin 一直为 0
  final double cacheOrigin;
  // 剩余的缓存区大小
  final double remainingCacheExtent;

  ...
}

上图中的 sliver1 会被 SliverAppBar(pinned = true)遮住,遮住的大小就是 overlap,此时 overlap 会一直大于 0,如果设置像 iOS bouncing 那样的滑动效果,那么当 list 滚动到顶部继续滑动的时候 overlap 会小于 0(此刻并没有东西遮盖 sliver1,而是 sliver1 的 top 和 viewport 的 top 有间距)。

SliverGeometry

Viewport 通过 SliverConstraints 告知它内部的 sliver 自己的约束信息,比如还有多少空间可用、offset 等,那么Sliver 则通过 SliverGeometry 反馈给 Viewport 需要占用多少空间量。


class SliverGeometry extends Diagnosticable {
  // sliver 可以滚动的范围,可以认为是 sliver 的高度(如果是 AxisDierction.Down) 
  final double scrollExtent;
  // 绘制起点(默认是 0.0),是相对于 sliver 开始 layout 的起点而言的,不会影响下一个 sliver 的 layoutExtent,会影响下一个 sliver 的paintExtent
  final double paintOrigin;
  // 绘制范围
  final double paintExtent;
  // 布局范围,当前 sliver 的 top 到下一个 sliver 的 top 的距离,范围是[0,paintExtent],默认是 paintExtent,会影响下一个 sliver 的 layout 位置
  final double layoutExtent;
  // 最大绘制大小,必须 >= paintExtent
  final double maxPaintExtent;
  // 如果 sliver 被 pinned 在边界的时候,这个大小为 Sliver 的自身的高度,其他情况为0,比如 pinned app bar
  final double maxScrollObstructionExtent;
  // 点击有效区域的大小,默认为paintExtent
  final double hitTestExtent;
  // 是否可见,visible = (paintExtent > 0)
  final bool visible;
  // 是否需要做clip,免得chidren溢出
  final bool hasVisualOverflow;
  // 当前 sliver 占用了 SliverConstraints.remainingCacheExtent 多少像素值
  final double cacheExtent;
  ...
}

Sliver 布局过程

RenderViewport 在 layout 它内部的 slivers 的过程如下:

这个 layout 过程是一个自上而下的线性过程:

  • 给 sliver1 输入 SliverConstrains1 并且得到输出结果(SliverGeometry1) ,
  • 根据 SliverGeometry1 重新生成一个新的 SliverConstrains2 输入给 sliver2 得到 SliverGeometry2
  • 直至最后一个 sliver 具体的过程可以查看 RenderViewport 的 layoutChildSequence 方法。

ScrollView

以 ScrollView 为例,我们串联上面介绍的几个 Widget 之间的关系。 先来看 ScrollView 的 build 方法:

@override
  Widget build(BuildContext context) {
    final List<Widget> slivers = buildSlivers(context);
    final AxisDirection axisDirection = getDirection(context);

    final ScrollController scrollController = primary
      ? PrimaryScrollController.of(context)
      : controller;
    final Scrollable scrollable = Scrollable(
      ...
      controller: scrollController,
      viewportBuilder: (BuildContext context, ViewportOffset offset) {
        return buildViewport(context, offset, axisDirection, slivers);
      },
    );
    return primary && scrollController != null
      ? PrimaryScrollController.none(child: scrollable)
      : scrollable;
  }

可以看到 ScrollView 创建了一个 Scrollable,并传入了构造 ViewPort 的 buildViewPort 方法。 上面讲过 Scrollable 负责手势监听,通过 buildViewPort 创建视图,在手势变化的时候不停的更新 ViewPort,大概流程如下:

自定义 Sliver

CustomPinnedHeader 光看一些概念会难以理解,最好的方式是 debug 一下,我们可以 copy 一下 SliverToBoxAdapter 的代码自定义一个 Sliver 调试一下各个参数加深理解。


class CustomSliverWidget extends SingleChildRenderObjectWidget {
  const CustomSliverWidget({Key key, Widget child})
      : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return CustomSliver();
  }
}
/// 一个 StickPinWidget
/// 主要讲述 Sliveronstraints 和 SliverGeometry 参数的作用
class CustomSliver extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    ...
    // 将 SliverConstraints 转化为 BoxConstraints 对 child 进行 layout
    child.layout(constraints.asBoxConstraints(), parentUsesSize: true);
    ...
    // 计算绘制大小
    final double paintedChildSize =
        calculatePaintOffset(constraints, from: 0.0, to: childExtent);
    // 计算缓存大小
    final double cacheExtent =
        calculateCacheOffset(constraints, from: 0.0, to: childExtent);
    ...
    // 输出 SliverGeometry 
    geometry = SliverGeometry(
      scrollExtent: childExtent,
      paintExtent: paintedChildSize,
      cacheExtent: cacheExtent,
      maxPaintExtent: childExtent,
      paintOrigin: 0,
      hitTestExtent: paintedChildSize,
      hasVisualOverflow: childExtent > constraints.remainingPaintExtent || constraints.scrollOffset > 0.0,
    );
    setChildParentData(child, constraints, geometry);
  }
}

我们把它放到 CustomScrollView 中:

eturn Scaffold(
        body: CustomScrollView(
          slivers: <Widget>[
        CustomSliverWidget(
            child: Container(
            color: Colors.red,
            height: 100,
            child: Center(
              child: Text("CustomSliver"),
            ),
        )),
        _buildListView(),
      ],
    ));

效果如下:

我们修改 paintOrigin 为 10 的话,发现 CustomSliverWidget 的 layout 位置没有变,但绘制的起始点下移了 10 px,并且它下一个的 Sliver - item0 的 layout 没有被影响,但是 paint 时被遮住了一部分:

再做一个简单的修改,将 sliver 的绘制起始位置改为滑动的偏移量:

 geometry = SliverGeometry(
      ...
      paintOrigin: constraints.scrollOffset,
      visible: true,
     );

此时你会发现 CustomSliver 可以固定在头部:

我们尝试修改 paintExtrent 如下:

geometry = SliverGeometry(
      //将绘制范围改为 sliver 的高度
      paintExtent: childExtent,
      ...
    );

在滑动的过程,CustomSliver 只是绘制变了,layout 没有变,导致下面 item0 没有被滑动,这是因为 layoutExtent 默认等于 paintExtent,我们将 paintExtent 赋值了常量,滑动过程中只有 paintOrigin 在改变,layout 的初始位置和高度并没有改变,它会一直占据着位置。

CustomRefreshWidget

接下来我们再做一个简单的下拉刷新 Widget,效果很简单,下拉的时候显示,释放的时候缩回:


class CustomRefreshWidget extends SingleChildRenderObjectWidget {
  const CustomRefreshWidget({Key key, Widget child})
      : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return SimpleRefreshSliver();
  }
}

/// 一个简单的下拉刷新 Widget
class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter {
  @override
  void performLayout() {
    ...
    final bool active = constraints.overlap < 0.0;
    /// 头部滑动的距离
    final double overscrolledExtent =
        constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
    double layoutExtent = child.size.height;
    print("overscrolledExtent:${overscrolledExtent - layoutExtent}");
    child.layout(
      constraints.asBoxConstraints(
        maxExtent: layoutExtent + overscrolledExtent,
      ),
      parentUsesSize: true,
    );
    if (active) {
      geometry = SliverGeometry(
        scrollExtent: layoutExtent,
        /// 绘制起始位置
        paintOrigin: min(overscrolledExtent - layoutExtent, 0),
        paintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
        maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
        /// 布局占位
        layoutExtent: min(overscrolledExtent, layoutExtent),
      );
    } else {
      /// 如果不想显示可以直接设置为 zero
      geometry = SliverGeometry.zero;
    }
    setChildParentData(child, constraints, geometry);
  }
}

可以看到有3个关键的参数

  • constraints.overlap:List 第一个 Sliver 的 top 距离屏幕 top 的距离
  • paintOrigin:RefreshWidget 的绘制起始位置
  • layoutExtent:RefreshWidget 的高度

items 的 top 与屏幕顶部的距离就是 constraints.overlap,它是一个小于等于 0 的值。

  • 未操作时,overlap == 0,直接返回一个空 Widget(SliverGeometry.zero)
  • 下拉时,overlap < 0, 这时候将 paintOrigin = min(overscrolledExtent - RefreshWidget.height, 0) 就可以让 RefreshWidget 慢慢的拉下来。
  • 处理完 Paint 后,不要忘记处理 layout,前面说过,SliverGeometry 的 layoutExtent 会影响下一个 Sliver 的布局位置,所以 layoutExtent 也需要随着滑动而逐渐变大 layoutExtent = min(-overlap, RefreshWidget.height)

Scrolling Widget

常用的 List 如下,我们按照它包裹的内容分成了 3 类:

ListView


ListView.builder(
        itemCount: 50,
        itemBuilder: (context,index) {
          return Container(
            color: ColorUtils.randomColor(),
            height: 50,
          );
        }

CustomScrollView

CustomScrollView(
      slivers: <Widget>[
        SliverAppBar(...),
        SliverToBoxAdapter(
          child:ListView(...),
        ),
        SliverList(...),
        SliverGrid(...),
      ],
    )

NestedScrollView

NestedScrollView 其实里面是一个CustomScrollView,它的 headers 是 Sliver 的数组,body是被包裹在 SliverFillRemaining 中的,body 可以接受 Box。


NestedScrollView(
      headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
        return <Widget>[
          SliverAppBar(
            expandedHeight: 100,
            pinned: true,
            title: Text("Nest"),
          ),
          SliverToBoxAdapter(
            child: Text("second bar"),
          )
        ];
      },
      body: ListView.builder(
          itemCount: 20,
          itemBuilder: (BuildContext context, int index) {
            return Text("item: $index");
          }),
    );

设计 CustomScrollView 的原因

复杂列表嵌套

如果直接使用 ListView 嵌套 ListView 会报错:

Vertical viewport was given unbounded height.

大致意思是在 layout 阶段父 Listview 不能判断子 Listview 的高度,这个错误可以通过设置内部的 Listview 的 shrinkWrap = true 来修正(shrinkWrap = true 代表 ListView 的高度等于它 content 的高度)。

ListView.builder(
        itemCount: 20,
        itemBuilder: (BuildContext context, int index) {
          return ListView.builder(
              shrinkWrap: true,
              itemCount: 5,
              itemBuilder: (BuildContext context, int index) {
                return Text("item: $index");
              });
        });

但是这样做的话性能会比较差,因为内部的列表每次都要计算出所有 content 的高度,这个时候使用 CustomScrollView 更为合适:

CustomScrollView(
      slivers: <Widget>[
        SliverList(
            delegate: SliverChildBuilderDelegate(
                (context, index) => Container(...),
                childCount: 50)
                ),
        SliverList(
            delegate: SliverChildBuilderDelegate(
                (context, index) => Container(...),
                childCount: 50)
                )
      ],
    );
滑动特效

CustomScrollView 可以让它内部的 Slivers 进行联动,比如做一个可伸缩的 TitleBar 、中间区域可以固定的 header、下拉刷新组件等等。

Slivers

Flutter 提供了很多的 Sliver 组件,下面我们主要说一下它们的作用是什么:

SliverAppBar

类似于 android 中 CollapsingToolbarLayout,可以根据滑动做伸缩布局,并提供了 actions,bottom 等提高效率的属性。

SliverList / SliverGrid

用法和 ListView / GridView 基本一致。 此外,ListView = SliverList + Scrollable,也就是说 SliverList 不具备处理滑动事件的能力,所以它必须配合 CustomScrollView 来使用。

SliverFixedExtentList

它比 SliverList 多了修饰词 FixedExtent,意思是它的 item 在主轴方向上具有固定的高度/宽度。

设计它的原因是在 item 高度/宽度全都一样的场景下使用,它的效率比 SliverList 高,因为它不用通过 item 的 layout 过程就可以知道每个 item 的范围。

在使用的时候必须传入 itemExtent:

SliverFixedExtentList(
  itemExtent: 50.0,
  delegate: SliverChildBuilderDelegate(
	...
        );
    },
  ),
)

SliverPersistentHeader

SliverPersistentHeader 是一个可以固定/悬浮的 header,它可以设置在列表的任意位置,显示的内容需要设置 SliverPersistentHeaderDelegate。

SliverPersistentHeader(
          pinned: true,
          delegate: ...,
)

SliverPersistentHeaderDelegate 是一个抽象类,我们需要自己实现它,它的实现很简单,只有四个必须要实现的成员:


class CustomDelegate extends SliverPersistentHeaderDelegate {
  /// 最大高度
  @override
  double get maxExtent => 100;
  /// 最小高度
  @override
  double get minExtent => 50;

  /// shrinkOffset: 当前 sliver 顶部越过屏幕顶部的距离
  /// overlapsContent: 下方是否还有 content 显示
  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
        return your widget
    );
  }
  /// 是否需要刷新
  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) {
    return maxExtent != oldDelegate.maxExtent ||
        minExtent != oldDelegate.minExtent;
  }
}

在实际运用中沉浸式的设计是很常见的,使用 SliverPersistentHeaderDelegate 可以轻松的实现沉浸式的效果:

它的实现原理就是根据 shrinkOffset 动态调整状态栏的样式和标题栏的颜色,实现代码见下面的 沉浸式 Header。

SliverToBoxAdapter

将 BoxWidget 转变为 Sliver:由于 CustomScrollView 只能接受 Sliver 类型的 child,所以很多常用的 Widget 无法直接添加到 CustomScrollView 中,此时只需要将 Widget 用 SliverToBoxAdapter 包裹一下就可以了。 最常见的使用就是 SliverList 不支持横向模式,但是又无法直接将 ListView 直接添加到 CustomScrollView 中,此时用 SliverToBoxAdapter 包裹一下:


 CustomScrollView(
      slivers: <Widget>[
        SliverToBoxAdapter(
          child: _buildHorizonScrollView(),
        ),
      ],
    ));

 Widget _buildHorizonScrollView() {
    return Container(
      height: 50,
      child: ListView.builder(
          scrollDirection: Axis.horizontal,
          primary: false,
          shrinkWrap: true,
          itemCount: 15,
          itemBuilder: (context, index) {
            return Container(
              color: ColorUtils.randomColor(),
              width: 50,
              height: 50,
            );
          }),
    );
  } 

SliverPadding

可以用在 CustomScrollView 中的 Padding。 需要注意的是不要用它来包裹 SliverPersistentHeader ,因为它会使 SliverPersistentHeader 的 pinned 失效,如果 SliverPersistentHeader 非要使用 Padding 效果,可以在 delegate 内部使用 Padding。

  • wrong code:
SliverPadding(
            padding: EdgeInsets.symmetric(horizontal: 16),
            sliver: SliverPersistentHeader(
              pinned: true,
              floating: false,
              delegate: Delegate(),
            ),
          )
  • correct code:
class Delegate extends SliverPersistentHeaderDelegate {
  @override
  Widget build(
          BuildContext context, double shrinkOffset, bool overlapsContent) =>
      Padding(
        padding: EdgeInsets.symmetric(horizontal: 16),
        child: Container(
          color: Colors.yellow,
        ),
      );
  ...
}

SliverSafeArea

用法和 SafeArea 一致。

SliverFillRemaining

可以填充屏幕剩余控件的 Sliver。

部分实例代码:

沉浸式 Header


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

class GradientSliverHeaderDelegate extends SliverPersistentHeaderDelegate {
  final double collapsedHeight;
  final double expandedHeight;
  final double paddingTop;
  final String coverImgUrl;
  final String title;

  GradientSliverHeaderDelegate({
    this.collapsedHeight,
    this.expandedHeight,
    this.paddingTop,
    this.coverImgUrl,
    this.title,
  });

  @override
  double get minExtent => this.collapsedHeight + this.paddingTop;

  @override
  double get maxExtent => this.expandedHeight;

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

  Color makeStickyHeaderBgColor(shrinkOffset) {
    final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255)
        .clamp(0, 255)
        .toInt();
    return Color.fromARGB(alpha, 255, 255, 255);
  }

  Color makeStickyHeaderTextColor(shrinkOffset) {
    if (shrinkOffset <= 50) {
      return Colors.white;
    } else {
      final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255)
          .clamp(0, 255)
          .toInt();
      return Color.fromARGB(alpha, 0, 0, 0);
    }
  }

  Brightness getStatusBarTheme(shrinkOffset) {
    return shrinkOffset <= 50 ? Brightness.light : Brightness.dark;
  }

  @override
  Widget build(
      BuildContext context, double shrinkOffset, bool overlapsContent) {
    SystemUiOverlayStyle systemUiOverlayStyle = SystemUiOverlayStyle(
        statusBarColor: Colors.transparent,
        statusBarIconBrightness: getStatusBarTheme(shrinkOffset));
    SystemChrome.setSystemUIOverlayStyle(systemUiOverlayStyle);

    return Container(
      height: this.maxExtent,
      width: MediaQuery.of(context).size.width,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          // 背景图
          Container(
              child: Image.asset(
            coverImgUrl,
            fit: BoxFit.cover,
          )),
          // 收起头部
          Positioned(
            left: 0,
            right: 0,
            top: 0,
            child: Container(
              color: this.makeStickyHeaderBgColor(shrinkOffset), // 背景颜色
              child: SafeArea(
                bottom: false,
                child: Container(
                    height: this.collapsedHeight,
                    child: Center(
                      child: Text(
                        this.title,
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.w500,
                          color: this
                              .makeStickyHeaderTextColor(shrinkOffset), // 标题颜色
                        ),
                      ),
                    )),
              ),
            ),
          ),
        ],
      ),
    );
  }
}
class CustomRefreshWidget extends SingleChildRenderObjectWidget {
  const CustomRefreshWidget({Key key, Widget child})
      : super(key: key, child: child);

  @override
  RenderObject createRenderObject(BuildContext context) {
    return SimpleRefreshSliver();
  }
}

/// 一个简单的下拉刷新 Widget
class SimpleRefreshSliver extends RenderSliverSingleBoxAdapter {
  @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);

    assert(paintedChildSize.isFinite);
    assert(paintedChildSize >= 0.0);
    final bool active = constraints.overlap < 0.0;
    final double overscrolledExtent =
        constraints.overlap < 0.0 ? constraints.overlap.abs() : 0.0;
    double layoutExtent = child.size.height;
    print("overscrolledExtent:${overscrolledExtent - layoutExtent}");
    child.layout(
      constraints.asBoxConstraints(
        maxExtent: layoutExtent
            // Plus only the overscrolled portion immediately preceding this
            // sliver.
            +
            overscrolledExtent,
      ),
      parentUsesSize: true,
    );
    if (active) {
      geometry = SliverGeometry(
        scrollExtent: layoutExtent,
        /// 绘制起始位置
        paintOrigin: min(overscrolledExtent - layoutExtent, 0),
        paintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
        maxPaintExtent: max(max(child.size.height, layoutExtent) ,0.0,),
        /// 布局占位
        layoutExtent: min(overscrolledExtent, layoutExtent),
      );
    } else {
      /// 如果不想显示可以直接设置为 zero
      geometry = SliverGeometry.zero;
    }
    setChildParentData(child, constraints, geometry);
  }
}

使用:

@override
  Widget build(BuildContext context) {
    return Scaffold(
        body: CustomScrollView(
        /// android 需要设置弹簧效果 overlap 才会起作用
      physics: const BouncingScrollPhysics(parent: AlwaysScrollableScrollPhysics()),
        CustomRefreshWidget(
          child: Container(
            height: 100,
            color: Colors.purple,
            child: Row(
              mainAxisAlignment: MainAxisAlignment.center,
              crossAxisAlignment: CrossAxisAlignment.center,

              children: <Widget>[
                Text(
                  "RefreshWidget",
                  style: TextStyle(color: Colors.white),
                ),
                Padding(
                  padding: EdgeInsets.only(left: 10.0),
                  child: CupertinoActivityIndicator(),
                )
              ],
            ),
          ),
        ),
        ...
        _buildListView(),
      ],
    ));
  }