Flutter-可滚动组件-NestedScrollView

2,470 阅读6分钟

NestedScrollView

CustomScrollView只能组合Sliver,如果有子组件也是一个可滚动(通过SliverToBoxAdapter嵌入)且它们的滑动方向一致时便不能工作。为了解决这个问题,Flutter提供了NestedScrollView组件,它的功能是协调两个可滚动组件。

const NestedScrollView({
  ... //省略可滚动组件的通用属性
  //header,sliver构造器
  required this.headerSliverBuilder,
  //可以接受任意的可滚动组件
  required this.body,
  this.floatHeaderSlivers = false,
}) 

image.png

上面效果有三个部分组成:

  1. 最上面的一个AppBar,实现导航,需要固定在顶部。
  2. AppBar下面是一个SliverList,可以有任意多个列表项
  3. 最下面的ListView。

预期效果是SliverList和下面的ListView的滑动能够统一,而不是下面ListView上滑动时只有ListView响应滑动,整个页面在垂直方向是一个整体。

Material(
  child: NestedScrollView(
    headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
      // 返回一个 Sliver 数组给外部可滚动组件。
      return <Widget>[
        SliverAppBar(
          title: const Text('嵌套ListView'),
          pinned: true, // 固定在顶部
          forceElevated: innerBoxIsScrolled,
        ),
        buildSliverList(5), //构建一个 sliverList
      ];
    },
    body: ListView.builder(
      padding: const EdgeInsets.all(8),
      physics: const ClampingScrollPhysics(), //重要
      itemCount: 30,
      itemBuilder: (BuildContext context, int index) {
        return SizedBox(
          height: 50,
          child: Center(child: Text('Item $index')),
        );
      },
    ),
  ),
);

NestedScrollView在逻辑上将可滚动组件分为了header和body两部分,header部分可以认为外部可滚动组件(outer scroll view),可以认为这个可滚动组件就是CustomScrollView,所以它只能接收Sliver,通过headerSliverBuilder来构建一个Sliver列表给外部的可滚动组件;而body部分可以接收任意的可滚动组件,该可滚动组件称为内部可滚动组件(inner scroll view)。

NestedScrollView原理

image.png

  1. NestedScrollView整体就是一个CunstomScrollView,实际上继承自CustomScrollView。
  2. header和body都是CustomScrollView的子Sliver,注意,虽然body是一个RenderBox,但是会被包装为Sliver。
  3. CustomScrollView将所有子Sliver在逻辑上分为header和body两部分,header是前部分,body是后部分。
  4. 当body是一个可滚动组件时,它和CustomScrollView分别有一个Scrollable,由于body在CustomScrollView的内部,所以称其为内部可滚动组件,称header为外部可滚动组件。同时因为header部分是Sliver,所以没有独立的Scrollable,滑动时受CustomScrollView的Scrollable控制。
  5. NestedScrollView核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以便使滑动效果连贯统一。协调器的实现原理就是分别给内外可滚动组件分别设置一个controller,然后通过这两个controller来协调控制它们的滚动。

综上:

  1. 要确认内部的可滚动组件(body)的physics是否需要设置为ClampingScrollPhysics。比如,当ListView没有设置为ClampingScrollPhysics,则用户快速滑动到顶部时,会执行一个弹性效果,此时ListView就会与header显得割裂(滑动效果不统一)。所以需要设置。但是如果header中只有一个SliverAppBar则不应该加,因为SliverAppBar是固定在顶部的,ListView滑动到顶部时上面已经没有要继续往下滑动的元素来,所以此时出现弹性效果是符合预期的。
  2. 内部的可滚动组件(body)不能设置controller和primary,这是因为NestedScrollView的协调器中已经指定来它的Controller,如果重新设定则协调器会失效。

SliverAppBar

SliverAppBar是AppBar的Sliver版,但多数的参数相同,但是SliverAppBar有一些特有的功能:

const SliverAppBar({
  this.collapsedHeight, // 收缩起来的高度
  this.expandedHeight,// 展开时的高度
  this.pinned = false, // 是否固定
  this.floating = false, //是否漂浮
  this.snap = false, // 当漂浮时,此参数才有效
  bool forceElevated //导航栏下面是否一直显示阴影
  ...
})
  • SliverAppBar在NestedScrollView中随着用户的滑动可以收缩和展开,因此需要分别指定收缩和展开时的高度。
  • pinned为True时SliverAppBar会固定在NestedScrollView的顶部,行为和SliverPersistentHeader的pinned功能一致。
  • floating和snap:floating为true时,SliverAppbar不会固定到顶部,当用户向上滑动到顶部时,SliverAppbar也会滑出可视窗口。当用户反向滑动时,SliverAppBar的snap为true时,此时无论SliverAppbar已经滑出屏幕多远,都会立即回到屏幕顶部;但是snap为false,则SliverAppBar只有当向下滑到边界时才会重新回到屏幕顶部。这一点和SliverPersistentHeader的floating相似,但不同的是SliverPersistentHeader没有snap参数,当它的floating为true时,效果时等同于SliverAppBar的floating和snap同时为true时效果。

SliverAppBar的一些参数和SliverPersistentHeader很像,因为SliverAppBar内部包含一个SliverPersistentHeader,用于实现顶部固定和漂浮效果。

class SnapAppBar extends StatelessWidget {
  const SnapAppBar({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
          //如果此处不使用SliverOverlapAbsorber头部会有部分列表被遮挡
            SliverOverlapAbsorber(
            //传递重叠长度
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                floating: true,
                snap: true,
                expandedHeight: 200,
                flexibleSpace: FlexibleSpaceBar(
                  background: Image.asset(
                    "./imgs/sea.png",
                    fit: BoxFit.cover,
                  ),
                ),
                forceElevated: innerBoxIsScrolled,
              ),
            ),
          ];
        },
        body: Builder(builder: (BuildContext context) {
          return CustomScrollView(
            slivers: <Widget>[
              SliverOverlapInjector(
              //传递重叠长度
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              ),
              buildSliverList(100)
            ],
          );
        }),
      ),
    );
  }
}

注意:

  1. SliverAppBar用SliverOverlapAbsorber包裹起来的作用是,获取SliverAppbar返回是遮住内部可滚动组件的部分长度,这个长度就是overlap(重叠)的长度。
  2. 在body中往CustomScrollView的Sliver列表的最前面插入了一个SliverOverlapInjector,它会将SliverOVerlapAbsorber中获取的overlap长度应用到内部可滚动组件中。这样在SliverAppBar返回时,内部可滚动组件也会相应的同步滑动相应的距离。

SliverOverlapAbsorber和SliverOverlapInjector都接收一个handle,给它传入的是NestedScrollView.sliverOverlapAbsorberHandleFor(context)。handle就是SliverOverlapAbsorber和SliverOverlapInjector的通信桥梁,即传递overlap长度。

当snap为true时,只需要给SliverAppBar包裹一个SliverOverlapAbsorber即可,而无需再给CustomScrollView添加SliverOverlapinjector,因为这种情况SliverOverlapAbsorber会自动吸收Overlap,以调整自身的布局高度为SliverAppBar的实际高度,这样的话header的高度变化后就会自动将body向下撑(header和body属于同一个CustomScrollView),同时handle中的overlap长度始终0。而只有当SliverAppBar被SliverOverlapAbsorber包裹且为固定模式时(pinned为true),CustomScrollView中添加SliverOverlapInjector才有意义,handle中的overlap长度不为0。

验证:

class SnapAppBar2 extends StatefulWidget {
  const SnapAppBar2({Key? key}) : super(key: key);

  @override
  State<SnapAppBar2> createState() => _SnapAppBar2State();
}

class _SnapAppBar2State extends State<SnapAppBar2> {
  // 将handle 缓存
  late SliverOverlapAbsorberHandle handle;

  void onOverlapChanged(){
    // 打印 overlap length
    print(handle.layoutExtent);
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          handle = NestedScrollView.sliverOverlapAbsorberHandleFor(context);
          //添加监听前先移除旧的
          handle.removeListener(onOverlapChanged);
          //overlap长度发生变化时打印
          handle.addListener(onOverlapChanged);
          return <Widget>[
            SliverOverlapAbsorber(
              handle: handle,
              sliver: SliverAppBar(
                floating: true,
                snap: true,
                // pinned: true,  // 放开注释,然后看日志
                expandedHeight: 200,
                flexibleSpace: FlexibleSpaceBar(
                  background: Image.asset(
                    "./imgs/sea.png",
                    fit: BoxFit.cover,
                  ),
                ),
                forceElevated: innerBoxIsScrolled,
              ),
            ),
          ];
        },
        body: LayoutBuilder(builder: (BuildContext context,cons) {
          return CustomScrollView(
            slivers: <Widget>[
              SliverOverlapInjector(handle: handle),
              buildSliverList(100)
            ],
          );
        }),
      ),
    );
  }

  @override
  void dispose() {
    // 移除监听器
    handle.removeListener(onOverlapChanged);
    super.dispose();
  }
}

分别查看snap和pinned模式下控制台的输出即可验证。

综上:建议SLiverOverlapAbsorber和SliverOverlapInjector配对使用,这样可以避免日后将snap模式改为固定模式后忘记添加SliverOverlapInjector而导致bug。

嵌套TabBarView

使用实例:

class NestedTabBarView1 extends StatelessWidget {
  const NestedTabBarView1({Key? key}) : super(key: key);

  @override
  Widget build(BuildContext context) {
    final _tabs = <String>['猜你喜欢', '今日特价', '发现更多'];
    // 构建 tabBar
    return DefaultTabController(
      length: _tabs.length, // tab的数量.
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return <Widget>[
              SliverOverlapAbsorber(
                handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  title: const Text('商城'),
                  floating: true,
                  snap: true,
                  forceElevated: innerBoxIsScrolled,
                  bottom: TabBar(
                    tabs: _tabs.map((String name) => Tab(text: name)).toList(),
                  ),
                ),
              ),
            ];
          },
          body: TabBarView(
            children: _tabs.map((String name) {
              return Builder(
                builder: (BuildContext context) {
                  return CustomScrollView(
                    key: PageStorageKey<String>(name),
                    slivers: <Widget>[
                      SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                      ),
                      SliverPadding(
                        padding: const EdgeInsets.all(8.0),
                        sliver: buildSliverList(50),
                      ),
                    ],
                  );
                },
              );
            }).toList(),
          ),
        ),
      ),
    );
  }
}