在Flutter中创建有意思的滚动效果 - Sliver系列
作者:小石头若海 链接:https://www.jianshu.com/p/5aeeb7ea776b
1. 前言
Flutter作为时下最流行的技术之一,凭借其出色的性能以及抹平多端的差异优势,早已引起大批技术爱好者的关注,甚至一些闲鱼
,美团
,腾讯
等大公司均已投入生产使用。虽然目前其生态还没有完全成熟,但身靠背后的Google加持,其发展速度已经足够惊人,可以预见将来对Flutter开发人员的需求也会随之增长。
无论是为了技术尝鲜还是以后可能的工作机会,都9102年了,作为一个前端开发者,似乎没有理由不去尝试它。正是带着这样的心理,笔者也开始学习Flutter,同时建了一个用于练习的仓库,后续所有代码都会托管在上面,欢迎star,一起学习。这是我写的Flutter系列文章:
用Flutter构建漂亮的UI界面 - 基础组件篇
Flutter滚动型容器组件 - ListView篇
Flutter网格型布局 - GridView篇
在Flutter中使用自定义Icon
在之前的文章中,我们学习了如何使用ListView
和GridView
这两个滚动类型组件。今天,我们就来学习另一个滚动组件CustomScrollView
及其搭配使用的Sliver
系列组件。掌握了它们,你就可以做一些有趣的滚动效果啦~
2. 必备知识
在进入今天的正题之前,我们先来简单了解下今天的两个主角CustomScrollView和Sliver:CustomScrollView是Flutter
提供的可以用来自定义滚动效果的组件,它可以像胶水一样将多个Sliver
粘合在一起。
什么意思呢?举个栗子(你也可以点击这里
看youtube上的一个视频):
假如页面中同时存在一个List
和一个Grid
,虽然它们看起来是一个整体,但是由于各自的滚动效果是分离的,所以没法保证一致的滚动效果。
而使用CustomScrollView
组件作为滚动容器,SliverList
和SliverGrid
分别替代List
和Grid
作为CustomScrollView
的子组件,滚动效果再由CustomScrollView
统一控制,这样就可以了。
其中SliverList
和SliverGrid
就是我们前面提到的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.
注意:由于CustomScrollView
的子组件只能是Sliver
系列,所以如果你想将一个普通组件塞进CustomScrollView
,那么务必将该组件用SliverToBoxAdapter
包裹。
3. 热身:SliverList / SliverGrid
前面讲了那么多的概念似乎有些枯燥,接下来就让我们从最简单的一个例子入手来看看如何使用CustomScrollView
和SliverList/SliverGrid
。
其实CustomScrollView
的用法很简单,它有一个slivers
属性,是一个Widget
数组,将子组件都放在里面就可以了,其他的一些滚动相关的属性基本和我们之前学到的ListView
差不多。
CustomScrollView(
slivers: <Widget>[
renderSliverA(),
renderSliverB(),
renderSliverC(),
],
)
再来看看SliverList
,它只有一个delegate
属性,可以用SliverChildListDelegate
或SliverChildBuilderDelegate
这两个类实现。前者将会一次性全部渲染子组件,后者将会根据视窗渲染当前出现的元素,其效果可以和ListView
和ListView.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
效果一定要在float
为true
时才会生效。另外,你也可以将这三者进行组合使用。
5. 花样多变的SliverPersistentHeader
在上一小节中我们见识到了SliverAppBar
的神奇之处,其实它就是基于SliverPersistentHeader
实现的。通过SliverPersistentHeader
,我们还可以实现sticky
吸顶的效果。
SliverPersistentHeader
最重要的一个属性是SliverPersistentHeaderDelegate
,为此我们需要实现一个类继承自SliverPersistentHeaderDelegate
。
class StickyTabBarDelegate extends SliverPersistentHeaderDelegate {
double get minExtent => null;
double get maxExtent => null;
bool shouldRebuild(SliverPersistentHeaderDelegate oldDelegate) => null;
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({ this.child});
Widget build(BuildContext context, double shrinkOffset, bool overlapsContent) {
return this.child;
}
double get maxExtent => this.child.preferredSize.height;
double get minExtent => this.child.preferredSize.height;
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,
});
double get minExtent => this.collapsedHeight + this.paddingTop;
double get maxExtent => this.expandedHeight;
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);
}
}
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
的代码。所以,我们重点关注makeStickyHeaderTextColor
和makeStickyHeaderBgColor
即可。这两个方法都是根据当前的shrinkOffset
值计算过渡过程中的颜色值。另外,这里需要注意头部在iPhoneX及以上的刘海头涉及,可以用SafeArea
组件解决问题。
6. 总结
本文首先介绍了CustomScrollView
和Sliver
系列组件的概念及其关系,接着以SliverList
和SliverGrid
结合的示例说明了其使用方法。然后,又介绍了较常用的SliverAppBar
组件,分别解释了其float/snap/pinned
各自的效果。最后,讲解了SliverPersistentHeader
组件的使用方法,并用实际例子加以说明其自定义过渡效果的用法。希望通过本文的介绍,你可以用CustomScrollView
和Sliver
系列组件创建出更有意思的滚动效果~
本文所有代码托管在这儿:https://github.com/SmallStoneSK/flutter_training_app ,欢迎一起交流学习~
--- end---
推荐阅读: