NestedScrollView
CustomScrollView只能组合Sliver,如果有子组件也是一个可滚动(通过SliverToBoxAdapter嵌入)且它们的滑动方向一致时便不能工作。为了解决这个问题,Flutter提供了NestedScrollView组件,它的功能是协调两个可滚动组件。
const NestedScrollView({
... //省略可滚动组件的通用属性
//header,sliver构造器
required this.headerSliverBuilder,
//可以接受任意的可滚动组件
required this.body,
this.floatHeaderSlivers = false,
})
上面效果有三个部分组成:
- 最上面的一个AppBar,实现导航,需要固定在顶部。
- AppBar下面是一个SliverList,可以有任意多个列表项
- 最下面的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原理
- NestedScrollView整体就是一个CunstomScrollView,实际上继承自CustomScrollView。
- header和body都是CustomScrollView的子Sliver,注意,虽然body是一个RenderBox,但是会被包装为Sliver。
- CustomScrollView将所有子Sliver在逻辑上分为header和body两部分,header是前部分,body是后部分。
- 当body是一个可滚动组件时,它和CustomScrollView分别有一个Scrollable,由于body在CustomScrollView的内部,所以称其为内部可滚动组件,称header为外部可滚动组件。同时因为header部分是Sliver,所以没有独立的Scrollable,滑动时受CustomScrollView的Scrollable控制。
- NestedScrollView核心功能就是通过一个协调器来协调外部(outer)可滚动组件和内部(inner)可滚动组件的滚动,以便使滑动效果连贯统一。协调器的实现原理就是分别给内外可滚动组件分别设置一个controller,然后通过这两个controller来协调控制它们的滚动。
综上:
- 要确认内部的可滚动组件(body)的physics是否需要设置为ClampingScrollPhysics。比如,当ListView没有设置为ClampingScrollPhysics,则用户快速滑动到顶部时,会执行一个弹性效果,此时ListView就会与header显得割裂(滑动效果不统一)。所以需要设置。但是如果header中只有一个SliverAppBar则不应该加,因为SliverAppBar是固定在顶部的,ListView滑动到顶部时上面已经没有要继续往下滑动的元素来,所以此时出现弹性效果是符合预期的。
- 内部的可滚动组件(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)
],
);
}),
),
);
}
}
注意:
- SliverAppBar用SliverOverlapAbsorber包裹起来的作用是,获取SliverAppbar返回是遮住内部可滚动组件的部分长度,这个长度就是overlap(重叠)的长度。
- 在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(),
),
),
),
);
}
}