乐趣区

在Flutter中创建有意思的滚动效果-Sliver系列

1. 前言

Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些 闲鱼 美团 腾讯 等大公司均已投入生产使用。虽然目前其生态还没有完全成熟,但身靠背后的 Google 加持,其发展速度已经足够惊人,可以预见将来对 Flutter 开发人员的需求也会随之增长。

无论是为了技术尝鲜还是以后可能的工作机会,都 9102 年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎 star,一起学习。这是我写的 Flutter 系列文章:

  • 用 Flutter 构建漂亮的 UI 界面 – 基础组件篇
  • Flutter 滚动型容器组件 – ListView 篇
  • Flutter 网格型布局 – GridView 篇
  • 在 Flutter 中使用自定义 Icon

在之前的文章中,我们学习了如何使用 ListViewGridView这两个滚动类型组件。今天,我们就来学习另一个滚动组件 CustomScrollView 及其搭配使用的 Sliver 系列组件。掌握了它们,你就可以做一些有趣的滚动效果啦~

2. 必备知识

在进入今天的正题之前,我们先来简单了解下今天的两个主角 CustomScrollViewSliverCustomScrollViewFlutter 提供的可以用来自定义滚动效果的组件,它可以像胶水一样将多个 Sliver 粘合在一起。

什么意思呢?举个栗子(你也可以点击这里看 youtube 上的一个视频):

假如页面中同时存在一个 List 和一个Grid,虽然它们看起来是一个整体,但是由于各自的滚动效果是分离的,所以没法保证一致的滚动效果。

而使用 CustomScrollView 组件作为滚动容器,SliverListSliverGrid 分别替代 ListGrid作为 CustomScrollView 的子组件,滚动效果再由 CustomScrollView 统一控制,这样就可以了。

其中 SliverListSliverGrid就是我们前面提到的 Sliver 系列中的两员,除此之外,Sliver家族还有常用的几个:

  • SliverAppBar:Creates a material design app bar that can be placed in a CustomScrollView.
  • SliverPersistentHeader:Creates a sliver that varies its size when it is scrolled to the start of a viewport.
  • SliverFillRemaining:Creates a sliver that fills the remaining space in the viewport.
  • SliverToBoxAdapter:Creates a sliver that contains a single box widget.
  • SliverPadding:Creates a sliver that applies padding on each side of another sliver.

注意:由于 CustomeScrollView 的子组件只能是 Sliver 系列,所以如果你想将一个普通组件塞进 CustomScrollView,那么务必将该组件用SliverToBoxAdapter 包裹。

3. 热身:SliverList / SliverGrid

前面讲了那么多的概念似乎有些枯燥,接下来就让我们从最简单的一个例子入手来看看如何使用 CustomScrollViewSliverList/SliverGrid

其实 CustomScrollView 的用法很简单,它有一个 slivers 属性,是一个 Widget 数组,将子组件都放在里面就可以了,其他的一些滚动相关的属性基本和我们之前学到的 ListView 差不多。

CustomScrollView(
  slivers: <Widget>[renderSliverA(),
    renderSliverB(),
    renderSliverC(),],
)

再来看看 SliverList,它只有一个delegate 属性,可以用 SliverChildListDelegateSliverChildBuilderDelegate这两个类实现。前者将会一次性全部渲染子组件,后者将会根据视窗渲染当前出现的元素,其效果可以和 ListViewListView.build这两个构造函数类比。

SliverList(
  delegate: SliverChildListDelegate(
    <Widget>[renderA(),
      renderB(),
      renderC(),]
  )
)

SliverList(
  delegate: SliverChildBuilderDelegate((context, index) => renderItem(context, index),
    childCount: 10,
  )
)

通过上面的例子我们发现 SliverList 的使用方式和 ListView 大同小异,而 SliverGrid 也是如此,这里就不再过多赘述,来看个两列网格的例子:

SliverGrid.count(
  crossAxisCount: 2,
  children: <Widget>[renderA(),
    renderB(),
    renderC(),
    renderD()]
)

接下来,就让我们通过一个实际例子将上面的三点结合在一起。

代码(完整版看这里)

final List<Color> colorList = [
  Colors.red,
  Colors.orange,
  Colors.green,
  Colors.purple,
  Colors.blue,
  Colors.yellow,
  Colors.pink,
  Colors.teal,
  Colors.deepPurpleAccent
];

// Text 组件需要用 SliverToBoxAdapter 包裹,才能作为 CustomScrollView 的子组件
Widget renderTitle(String title) {
  return SliverToBoxAdapter(
    child: Padding(padding: EdgeInsets.symmetric(vertical: 16),
      child: Text(
        title,
        textAlign: TextAlign.center,
        style: TextStyle(fontSize: 20),
      ),
    ),
  );
}

CustomScrollView(
  slivers: <Widget>[renderTitle('SliverGrid'),
    SliverGrid.count(
      crossAxisCount: 3,
      children: colorList.map((color) => Container(color: color)).toList(),),
    renderTitle('SliverList'),
    SliverFixedExtentList(        // SliverList 的语法糖,用于每个 item 固定高度的 List
      delegate: SliverChildBuilderDelegate((context, index) => Container(color: colorList[index]),
        childCount: colorList.length,
      ),
      itemExtent: 100,
    ),
  ],
)

效果图

上面的例子中还有一点需要注意的是:我们将标题组件放在了 SliverToBoxAdapter 内,因为 CustomScrollView 只接受 Sliver 系列的组件。

4. 眼前一亮的 SliverAppBar

AppBar是常用来构建一个页面头部 Bar 的组件,在 CustomScrollView 中与其对应的是 SliverAppBar 组件。它有什么神奇之处呢?随着页面的滚动,头部 Bar 将会有一个收起过渡的效果。我们先来看下效果:

float 效果 snap 效果 pinned 效果

通过上面的预览图,想必你肯定很好奇 SliverAppBar 中的过渡效果是如何实现的~ 先别急,我们先来看下应该如何使用它:

SliverAppBar(
  floating: true,
  snap: true,
  pinned: true,
  expandedHeight: 250,
  flexibleSpace: FlexibleSpaceBar(title: Text(this.title),
    background: Image.network(
      'http://img1.mukewang.com/5c18cf540001ac8206000338.jpg',
      fit: BoxFit.cover,
    ),
  ),
)

SliverAppBar最重要的几个属性在上面的例子中罗列出来。其中:

  • expandedHeight:展开状态下 appBar 的高度,即图中图片所占空间;
  • flexibleSpace:空间大小可变的组件,Flutter给我们提供了一个现成的 FlexibleSpaceBar 组件,给我们处理好了 title 过渡的效果。

另外,floating/snap/pinned这三个属性可以指定 SliverAppBar 内容滑出屏幕之后的表现形式。

  • float:向下滑动时,即使当前 CustomScrollView 不在顶部,SliverAppBar也会跟着一起向下出现;
  • snap:当手指放开时,SliverAppBar会根据当前的位置进行调整,始终保持 展开 收起 的状态;
  • pinned:不同于 float 效果,当 SliverAppBar 内容滑出屏幕时,将始终渲染一个固定在顶部的收起状态组件。

需要注意的是:snap效果一定要在 floattrue时才会生效。另外,你也可以将这三者进行组合使用。

5. 花样多变的 SliverPersistentHeader

在上一小节中我们见识到了 SliverAppBar 的神奇之处,其实它就是基于 SliverPersistentHeader 实现的。通过 SliverPersistentHeader,我们还可以实现sticky 吸顶的效果。

SliverPersistentHeader最重要的一个属性是SliverPersistentHeaderDelegate,为此我们需要实现一个类继承自SliverPersistentHeaderDelegate

class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {

  @override
  double get minExtent => null;

  @override
  double get maxExtent => null;

  @override
  bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => null;
  
  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) => null;
}

可以看到,SliverPersistentHeaderDelegate的实现类必须实现其 4 个方法。其中:

  • minExtent:收起状态下组件的高度;
  • maxExtent:展开状态下组件的高度;
  • shouldRebuild:类似于 react 中的shouldComponentUpdate
  • build:构建渲染的内容。

接下来,我们就来实现一个 TabBar 吸顶的效果。

代码(完整版看这里)

CustomScrollView(
  slivers: <Widget>[
    SliverAppBar(// ...),
    SliverPersistentHeader(    // 可以吸顶的 TabBar
      pinned: true,
      delegate: StickyTabBarDelegate(
        child: TabBar(
          labelColor: Colors.black,
          controller: this.tabController,
          tabs: <Widget>[Tab(text: 'Home'),
            Tab(text: 'Profile'),
          ],
        ),
      ),
    ),
    SliverFillRemaining(        // 剩余补充内容 TabBarView
      child: TabBarView(
        controller: this.tabController,
        children: <Widget>[Center(child: Text('Content of Home')),
          Center(child: Text('Content of Profile')),
        ],
      ),
    ),
  ],
)

class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
  final TabBar child;

  StickyTabBarDelegate({@required this.child});

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {return this.child;}

  @override
  double get maxExtent => this.child.preferredSize.height;

  @override
  double get minExtent => this.child.preferredSize.height;

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

效果图

根据上面的图我们可以看到,当下方 tab 内容滑出屏幕后,tabBar并没有跟着一起滑走,而是粘在了顶部。可见 SliverPersistentHeader 的确可以满足我们的 sticky 效果。

不过 SliverPersistentHeader 的神奇可远不止如此哦~ 我们可以通过它自定义一些头部的过渡效果,毕竟 SliverAppBar 也是通过它实现的。就比如下方这个电影详情页的头部过渡效果,这在一般的 app 种还是比较常见的。

那么这种效果要如何实现呢?关键就在于 build 方法中的 shrinkOffset 属性,它代表当前头部的滚动偏移量。我们可以根据它计算得到当前收起头部的 背景颜色 以及图标和文案的 字体颜色,这样就能根据当前位置得到过渡效果啦~

代码(完整版看这里)

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

  SliverCustomHeaderDelegate({
    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, isIcon) {if(shrinkOffset <= 50) {return isIcon ? Colors.white : Colors.transparent;} else {final int alpha = (shrinkOffset / (this.maxExtent - this.minExtent) * 255).clamp(0, 255).toInt();
      return Color.fromARGB(alpha, 0, 0, 0);
    }
  }

  @override
  Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
    return Container(
      height: this.maxExtent,
      width: MediaQuery.of(context).size.width,
      child: Stack(
        fit: StackFit.expand,
        children: <Widget>[
          // 背景图
          Container(child: Image.network(this.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: Row(
                    mainAxisAlignment: MainAxisAlignment.spaceBetween,
                    children: <Widget>[
                      IconButton(
                        icon: Icon(
                          Icons.arrow_back_ios,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, true),    // 返回图标颜色
                        ),
                        onPressed: () => Navigator.pop(context),
                      ),
                      Text(
                        this.title,
                        style: TextStyle(
                          fontSize: 20,
                          fontWeight: FontWeight.w500,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, false),    // 标题颜色
                        ),
                      ),
                      IconButton(
                        icon: Icon(
                          Icons.share,
                          color: this.makeStickyHeaderTextColor(shrinkOffset, true),    // 分享图标颜色
                        ),
                        onPressed: () {},
                      ),
                    ],
                  ),
                ),
              ),
            ),
          ),
        ],
      ),
    );
  }
}

上面的代码虽然很长,但大部分是构建 widget 的代码。所以,我们重点关注 makeStickyHeaderTextColormakeStickyHeaderBgColor即可。这两个方法都是根据当前的 shrinkOffset 值计算过渡过程中的颜色值。另外,这里需要注意头部在 iPhoneX 及以上的刘海头涉及,可以用 SafeArea 组件解决问题。

6. 总结

本文首先介绍了 CustomScrollViewSliver系列组件的概念及其关系,接着以 SliverListSliverGrid结合的示例说明了其使用方法。然后,又介绍了较常用的 SliverAppBar 组件,分别解释了其 float/snap/pinned 各自的效果。最后,讲解了 SliverPersistentHeader 组件的使用方法,并用实际例子加以说明其自定义过渡效果的用法。希望通过本文的介绍,你可以用 CustomScrollViewSliver系列组件创建出更有意思的滚动效果~

本文所有代码托管在这儿,也可以关注我的 Blog,欢迎一起交流学习~

退出移动版