iOS- Flutter 可滚动组件- CustomScrollView&Slivers

747 阅读7分钟

开启掘金成长之旅!这是我参与「掘金日新计划 · 2 月更文挑战」的第 33 天,点击查看活动详情

CustomScrollView

ListView、PageView、GridView都是一个完整的可滚动组件,它们都包括Scrollable、Viewport和Sliver。如果需要多个滚动组件联动,比如想将已有的两个垂直方向滚动的ListView合成一个ListView,在第一个ListView滑动到底部,第二个ListView能自动接上滑动,这是一种常见的场景。

Widget buildTwoListView() {
    var listView = ListView.builder(
      itemCount: 20,
      itemBuilder: (_, index) => ListTile(title: Text('$index')),
    );
    return Column(
      children: [
        Expanded(child: listView),
        Divider(color: Colors.grey),
        Expanded(child: listView),
      ],
    );
  }
}

这样虽然能够显示出来,但是每个ListView只会响应自己可视区域中的滑动,实现不了联动效果。原因是两个ListView都有自己独立的Scrollable、Viewport、Sliver。那么如果自己创建一个共有的Scrollable和Viewport对象,然后将两个ListView对应的Sliver添加到这个共用的Viewport对象中是不是就可以实现效果了?

CustomScrollView组件就是帮助创建一个公共的Scrollable和Viewport,然后它的Slivers参数接受一个Sliver数组:

Widget buildTwoSliverList() {
  // SliverFixedExtentList 是一个 Sliver,它可以生成高度相同的列表项。
  // 再次提醒,如果列表项高度相同,我们应该优先使用SliverFixedExtentList 
  // 和 SliverPrototypeExtentList,如果不同,使用 SliverList.
  var listView = SliverFixedExtentList(
    itemExtent: 56, //列表项高度固定
    delegate: SliverChildBuilderDelegate(
      (_, index) => ListTile(title: Text('$index')),
      childCount: 10,
    ),
  );
  // 使用
  return CustomScrollView(
    slivers: [
      listView,
      listView,
    ],
  );
}

可以看出CustomScrollView的主要功能就是提供一个公共的Scrollable和Viewport,来组合多个Sliver。如下: image.png

常用的Sliver

Sliver 名称功能对应的可滚动组件
SliverList列表ListView
SliverFixedExtentList高度固定的列表ListView,指定itemExtent时
SliverAnimatedList添加/删除列表项可以执行动画AnimatedList
SliverGrid网格GridView
SliverPrototypeExtentList根据原型生成高度固定的列表ListView,指定prototypeItem时
SliverFillViewport包含多个子组件,每个都可以填满屏幕PageView

除来和列表对应的Sliver之外,还有一些用于对Sliver进行布局、装饰的组件,它们的子组件必须是Sliver:

Sliver名称对应RenderBox
SliverPaddingPadding
SliverVisibility、SliverOpacityVisibility、Opacity
SliverFadeTransitionFadeTransition
SliverLayoutBuilderLayoutBuilder

其他一些常用的Sliver:

Sliver名称说明
SliverAppBar对应AppBar,主要是为了在CunstomScrollView中使用。
SliverToBoxAdapter一个适配器,可以将RenderBox适配为Sliver
SlivePersistentHeader滑动到顶部时可以固定值

大多数Sliver都和可滚动组件对应,还有一些如SliverPadding、SliverAppBar等是可可滚动组件无关的,它们主要是为了结合CustomScrollView一起使用,这是因为CustomScrollView的子组件必须是Sliver。 实例:

// 因为本路由没有使用 Scaffold,为了让子级Widget(如Text)使用
// Material Design 默认的样式风格,我们使用 Material 作为本路由的根。
Material(
  child: CustomScrollView(
    slivers: <Widget>[
      // AppBar,包含一个导航栏.
      SliverAppBar(
        pinned: true, // 滑动到顶端时会固定住
        expandedHeight: 250.0,
        flexibleSpace: FlexibleSpaceBar(
          title: const Text('Demo'),
          background: Image.asset(
            "./imgs/sea.png",
            fit: BoxFit.cover,
          ),
        ),
      ),
      SliverPadding(
        padding: const EdgeInsets.all(8.0),
        sliver: SliverGrid(
          //Grid
          gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
            crossAxisCount: 2, //Grid按两列显示
            mainAxisSpacing: 10.0,
            crossAxisSpacing: 10.0,
            childAspectRatio: 4.0,
          ),
          delegate: SliverChildBuilderDelegate(
            (BuildContext context, int index) {
              //创建子widget
              return Container(
                alignment: Alignment.center,
                color: Colors.cyan[100 * (index % 9)],
                child: Text('grid item $index'),
              );
            },
            childCount: 20,
          ),
        ),
      ),
      SliverFixedExtentList(
        itemExtent: 50.0,
        delegate: SliverChildBuilderDelegate(
          (BuildContext context, int index) {
            //创建列表项
            return Container(
              alignment: Alignment.center,
              color: Colors.lightBlue[100 * (index % 9)],
              child: Text('list item $index'),
            );
          },
          childCount: 20,
        ),
      ),
    ],
  ),
);

代码分为三部分:

  • 头部SliverAppBar:SliverAppBar对应AppBar,两者不同之处在于SliverAppBar可以集成到CustomScrollView。SliverAppBar可以结合FlexibleSpcaeBar实现Material Design中头部伸缩的模型。
  • 中间的SliverGrid:它用SliverPadding包裹以给SliverGrid添加补白。SliverGrid是一个两列,宽高比为4的网格。
  • 底部SliverFixedExtentList:它是一个所有子元素高度都为50像素的列表。

SliverToBoxAdapter

在实际布局中,经常需要在CustomScrollView中添加一些自定义的组件,而这些组件有些不是Sliver,为此,Flutter提供了一个SliverToBoxAdapter组件,它可以将RenderBox适配为Sliver:

CustomScrollView(
  slivers: [
    SliverToBoxAdapter(
      child: SizedBox(
        height: 300,
        child: PageView(
          children: [Text("1"), Text("2")],
        ),
      ),
    ),
    buildSliverFixedList(),
  ],
);

注意,代码可以正常运行,但是PageView换一个滑动方向和CustomScrollView一致的ListView则不会正常工作,原因是CustomScrollView组合Sliver的原理是为所有子Sliver提供一个共享的Scrollable,然后统一处理指定滑动方向的滑动事件,如果Sliver中引入了其他的Scrollable,则滑动事件会冲突。上例中PageView可以正常工作是因为PageView的Scrollable只处理水平滑动,而CustomScrollView是处理垂直方向的滑动,两者不干扰,如果PageView换成一个垂直方向的ListView则不能正常工作,原因是事件先被ListView的Scrollable消费,CustomScrollView的Scrollable便接收不到滑动事件。

Flutter中手势冲突时,默认的策略是子元素生效。 结论:如果CustomScrollView存在子元素也是一个完整的可滚动组件且它们滑动的方向一致,则CustomScrollView不能正常工作。解决这个问题使用NestedScrollView。

SliverPersistentHeader

SliverPersistentHeader的功能是当滑动到CustomScrollView的顶部时,可以将组件固定在顶部。Flutter设计SliverPersistentHeader组件的初衷时为了实现SliverAppBar,所以它的属性和回调在SliverAppBar中会用到。

const SliverPersistentHeader({
  Key? key,
  // 构造 header 组件的委托
  required SliverPersistentHeaderDelegate delegate,
  this.pinned = false, // header 滑动到可视区域顶部时是否固定在顶部
  this.floating = false, // 正文部分介绍
})
  • floating的作用是:pinned为false时,则header可以滑出可视区域(CustomScrollView的Viewport)而不会固定到顶部,当用户再次下滑时,此时不管header已经滑出多远,都会立即出现在可视区域顶部并固定住,直到继续下滑到header在列表中原来的位置时,header才会重新回到原来的位置(不再固定在顶部)。
  • delegate 用于生成header的委托,类型为SliverPersistentHeaderDelegate,是一个抽象类,需要我们自己实现对应的函数。
abstract class SliverPersistentHeaderDelegate {

  // header 最大高度;pined为 true 时,当 header 刚刚固定到顶部时高度为最大高度。
  double get maxExtent;
  
  // header 的最小高度;pined为true时,当header固定到顶部,用户继续往上滑动时,header
  // 的高度会随着用户继续上滑从 maxExtent 逐渐减小到 minExtent
  double get minExtent;

  // 构建 header。
  // shrinkOffset取值范围[0,maxExtent],当header刚刚到达顶部时,shrinkOffset 值为0,
  // 如果用户继续向上滑动列表,shrinkOffset的值会随着用户滑动的偏移减小,直到减到0时。
  //
  // overlapsContent:一般不建议使用,在使用时一定要小心,后面会解释。
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent);
  
  // header 是否需要重新构建;通常当父级的 StatefulWidget 更新状态时会触发。
  // 一般来说只有当 Delegate 的配置发生变化时,应该返回false,比如新旧的 minExtent、maxExtent
  // 等其他配置不同时需要返回 true,其余情况返回 false 即可。
  bool shouldRebuild(covariant SliverPersistentHeaderDelegate oldDelegate);

  // 下面这几个属性是SliverPersistentHeader在SliverAppBar中时实现floating、snap 
  // 效果时会用到,平时开发过程很少使用到,读者可以先不用理会。
  TickerProvider? get vsync => null;
  FloatingHeaderSnapConfiguration? get snapConfiguration => null;
  OverScrollHeaderStretchConfiguration? get stretchConfiguration => null;
  PersistentHeaderShowOnScreenConfiguration? get showOnScreenConfiguration => null;

}

需要关注的是maxExtent和minExtent;pined为True时,当header刚刚固定到顶部,此时会对它应用maxExtent(最大高度);当用户继续往上滑动时,header的高度会随着用户继续上滑从MaxExtent逐渐减小到minExtent。如果我们想让header高度固定,则将maxExtent和minExtent指定为同样的值。

构建header需要定义一个类,让它继承自SliverpersistentHeaderDelegate,这样会增加使用成本,通过封装一个通用的委托构造器SliverHeaderDelegate,可以快速构建SliverPersistentHeaderDelegate:

typedef SliverHeaderBuilder = Widget Function(
    BuildContext context, double shrinkOffset, bool overlapsContent);

class SliverHeaderDelegate extends SliverPersistentHeaderDelegate {
  // child 为 header
  SliverHeaderDelegate({
    required this.maxHeight,
    this.minHeight = 0,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        assert(minHeight <= maxHeight && minHeight >= 0);

  //最大和最小高度相同
  SliverHeaderDelegate.fixedHeight({
    required double height,
    required Widget child,
  })  : builder = ((a, b, c) => child),
        maxHeight = height,
        minHeight = height;

  //需要自定义builder时使用
  SliverHeaderDelegate.builder({
    required this.maxHeight,
    this.minHeight = 0,
    required this.builder,
  });

  final double maxHeight;
  final double minHeight;
  final SliverHeaderBuilder builder;

  @override
  Widget build(
    BuildContext context,
    double shrinkOffset,
    bool overlapsContent,
  ) {
    Widget child = builder(context, shrinkOffset, overlapsContent);
    //测试代码:如果在调试模式,且子组件设置了key,则打印日志
    assert(() {
      if (child.key != null) {
        print('${child.key}: shrink: $shrinkOffset,overlaps:$overlapsContent');
      }
      return true;
    }());
    // 让 header 尽可能充满限制的空间;宽度为 Viewport 宽度,
    // 高度随着用户滑动在[minHeight,maxHeight]之间变化。
    return SizedBox.expand(child: child);
  }

  @override
  double get maxExtent => maxHeight;

  @override
  double get minExtent => minHeight;

  @override
  bool shouldRebuild(SliverHeaderDelegate old) {
    return old.maxExtent != maxExtent || old.minExtent != minExtent;
  }
}

实现代码:

class PersistentHeaderRoute extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return CustomScrollView(
      slivers: [
        buildSliverList(),
        SliverPersistentHeader(
          pinned: true,
          delegate: SliverHeaderDelegate(//有最大和最小高度
            maxHeight: 80,
            minHeight: 50,
            child: buildHeader(1),
          ),
        ),
        buildSliverList(),
        SliverPersistentHeader(
          pinned: true,
          delegate: SliverHeaderDelegate.fixedHeight( //固定高度
            height: 50,
            child: buildHeader(2),
          ),
        ),
        buildSliverList(20),
      ],
    );
  }

  // 构建固定高度的SliverList,count为列表项属相
  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      itemExtent: 50,
      delegate: SliverChildBuilderDelegate(
        (context, index) {
          return ListTile(title: Text('$index'));
        },
        childCount: count,
      ),
    );
  }

  // 构建 header
  Widget buildHeader(int i) {
    return Container(
      color: Colors.lightBlue.shade200,
      alignment: Alignment.centerLeft,
      child: Text("PersistentHeader $i"),
    );
  }
}

SliverPersistentHeader的builder参数overlapsContent一般不建议使用,使用时要注意,因为按照overlapsContent变量名的字面意思,只要有内容和Sliver重叠时就应该为true,但是我们在builder中打印一下overlapsContent的值就会发现,PersistentHeader 1的overlapstentContent值一直都是false,PersistentHeader 2则正常,如果再添加几个SliverPersistentHeader,发现也是正常。总结:当多个SliverPersistentHeader时,需要注意第一个SliverPersistentHeader的overlapsContent值一直为false。 约定:在使用SliverPersistentHeader构建子组件时,需要依赖overlapsContent参数,则必须保证之前至少还有一个SliverPersistentHeader或SliverAppBar(SliverAppBar在当前Flutter版本实现中都包含SliverPersistentHeader)。

总结

  1. CustomScrollView组合Sliver的原理是为所有子Sliver提供一个共享的Scrollable,然后统一处理指定的滑动方向事件。
  2. CustomScrollView和ListView、GridView、PageView一样,都是完整的可滚动组件(同时拥有Scrollable、Viewport、Sliver)。
  3. CustomScrollView只能组合Sliver,如果子元素也是一个完整的可滚动组件()通过SliverToBoxAdapter嵌入 且它们的滑动方向一致时不能正常工作。