Flutter 之 嵌套可滚动组件 (二十五)

1. NestedScrollView

CustomScrollView 只能组合Sliver,有时我们需要组合的组件中有一个是可滚动组件(listView,GridView),那就需要通过SliverToBoxAdapter嵌入,但通过SliverToBoxAdapter 嵌入的组件要求和CustomScrollView滚动方向不一致,否则不能正常工作。
为了解决这个问题,Flutter 中提供了一个NestedScrollView 组件,它的功能时组合(协调)两个可滚动组件,下面我们看看它的定义

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

1.1 示例

image.png

页面有三部分组成:
1.最上面是一个 AppBar,实现导航,要能固定在顶端
2.AppBar 下面是一个 SliverList,可以有任意多个列表项。
3.最下面是一个 ListView。

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

代码如下:

class MSHomePage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    return Scaffold(
      body: NestedScrollView(
        headerSliverBuilder: (context, innerBoxIsScrolled) {
          return [
            SliverAppBar(
              title: Text("嵌套ListView"),
              pinned: true, // 固定在顶部
              forceElevated: innerBoxIsScrolled, // 是否显示阴影
            ),
            buildSliverList(5),
          ];
        },
        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')),
            );
          },
        ),
      ),
    );
  }

  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      delegate: SliverChildBuilderDelegate(
        (ctx, index) {
          return ListTile(
            leading: Text("Item $index"),
          );
        },
        childCount: count,
      ),
      itemExtent: 50,
    );
  }
}

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

运行效果如下


25.gif

2. NestedScrollView 原理

NestedScrollView 的结构图如图6-33所示:

image.png
  • NestedScrollView 整体就是一个 CustomScrollView (实际上是 CustomScrollView 的一个子类)
  • header 和 body 都是 CustomScrollView 的子 Sliver ,注意,虽然 body 是一个 RenderBox,但是它会被包装为 Sliver 。
  • CustomScrollView 将其所有子 Sliver 在逻辑上分为 header 和 body 两部分:header 是前面部分、body 是后面部分。
  • 当 body 是一个可滚动组件时, 它和 CustomScrollView 分别有一个 Scrollable ,由于 body 在 CustomScrollView 的内部,所以称其为内部可滚动组件,称 CustomScrollView 为外部可滚动组件;同时 因为 header 部分是 Sliver,所以没有独立的 Scrollable,滑动时是受 CustomScrollView 的 Scrollable 控制,所以为了区分,可以称 header 为外部可滚动组件(Flutter 文档中是这么约定的)。
  • NestedScrollView 核心功能就是通过一个协调器来协调外部可滚动组件和内部可滚动组件的滚动,以使滑动效果连贯统一,协调器的实现原理就是分别给内外可滚动组件分别设置一个 controller,然后通过这两个controller 来协调控制它们的滚动。

综上,在使用 NestedScrollView 有两点需要注意:

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

3. SliverAppBar

SliverAppBar 是 AppBar 的Sliver 版,大多数参数都相同,但 SliverAppBar 会有一些特有的功能,下面是 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 时的效果。

4. SnapAppBar 示例

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

  @override
  Widget build(BuildContext context) {
    return Material(
      child: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            // 实现 snap 效果
            SliverAppBar(
              floating: true,
              snap: true,
              expandedHeight: 200,
              forceElevated: innerBoxIsScrolled,
              flexibleSpace: FlexibleSpaceBar(
                background: Image.asset(
                  "assets/images/fengjing3.webp",
                  fit: BoxFit.cover,
                ),
              ),
            ),
            buildSliverList(5),
          ];
        },
        body: Builder(builder: (BuildContext context) {
          return CustomScrollView(
            slivers: <Widget>[buildSliverList(100)],
          );
        }),
      ),
    );
  }

  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      delegate: SliverChildBuilderDelegate(
        (ctx, index) {
          return ListTile(
            leading: Text("Item $index"),
          );
        },
        childCount: count,
      ),
      itemExtent: 50,
    );
  }
}

26.gif

4.1 优化

上述代码存在一个问题:
当我们滑动到顶部时,然后反向轻微滑动一点点,这时 SliverAppBar 就会整体回到屏幕顶部,但会遮住部分ListView

27.gif

使用SliverOverlapAbsorber 和 SliverOverlapInjector 进行优化

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

  @override
  Widget build(BuildContext context) {
    return Material(
      child: NestedScrollView(
        headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
          return <Widget>[
            // 实现 snap 效果
            SliverOverlapAbsorber(
              handle: NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              sliver: SliverAppBar(
                floating: true,
                snap: true,
                expandedHeight: 200,
                forceElevated: innerBoxIsScrolled,
                flexibleSpace: FlexibleSpaceBar(
                  background: Image.asset(
                    "assets/images/fengjing3.webp",
                    fit: BoxFit.cover,
                  ),
                ),
              ),
            ),
            buildSliverList(5),
          ];
        },
        body: Builder(builder: (BuildContext context) {
          return CustomScrollView(
            slivers: <Widget>[
              SliverOverlapInjector(
                handle:
                    NestedScrollView.sliverOverlapAbsorberHandleFor(context),
              ),
              buildSliverList(100),
            ],
          );
        }),
      ),
    );
  }

  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      delegate: SliverChildBuilderDelegate(
        (ctx, index) {
          return ListTile(
            leading: Text("Item $index"),
          );
        },
        childCount: count,
      ),
      itemExtent: 50,
    );
  }
}

上面代码运行后问题解决,和之前的代码相比有两个部分发生了变化:

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

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

28.gif

实际上,当 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

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

5. 嵌套 TabBarView

我们实现商城主页,它有三个Tab,为了获得更大的商品显示空间,我们希望用户向上滑动时 导航栏能够滑出屏幕,当用户向下滑动时,导航栏能迅速回到屏幕,因为向下滑动时可能是用户想看之前的商品,也可能是用户向找到导航栏返回

class MSTabBarPage extends StatelessWidget {
  @override
  Widget build(BuildContext context) {
    final List<String> _tabs = ['猜你喜欢', '今日特价', '发现更多'];
    // 构建 tabBar
    return DefaultTabController(
      length: _tabs.length,
      child: Scaffold(
        body: NestedScrollView(
          headerSliverBuilder: (BuildContext context, bool innerBoxIsScrolled) {
            return [
              SliverOverlapAbsorber(
                handle:
                    NestedScrollView.sliverOverlapAbsorberHandleFor(context),
                sliver: SliverAppBar(
                  floating: true,
                  snap: true,
                  forceElevated: innerBoxIsScrolled,
                  title: Text("商城"),
                  bottom: TabBar(
                    tabs: _tabs.map((e) {
                      return Tab(text: e);
                    }).toList(),
                  ),
                ),
              ),
            ];
          },
          body: TabBarView(
            children: _tabs.map((e) {
              return Builder(builder: (context) {
                return CustomScrollView(
                  slivers: [
                    SliverOverlapInjector(
                        handle: NestedScrollView.sliverOverlapAbsorberHandleFor(
                            context)),
                    buildSliverList(20),
                  ],
                );
              });
            }).toList(),
          ),
        ),
      ),
    );
  }

  Widget buildSliverList([int count = 5]) {
    return SliverFixedExtentList(
      delegate: SliverChildBuilderDelegate(
        (ctx, index) {
          return ListTile(
            leading: Text("Item $index"),
          );
        },
        childCount: count,
      ),
      itemExtent: 50,
    );
  }
}

29.gif
最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
【社区内容提示】社区部分内容疑似由AI辅助生成,浏览时请结合常识与多方信息审慎甄别。
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。

相关阅读更多精彩内容

友情链接更多精彩内容