Flutter - 实现列表上下拉切换header
# 背景
这是之前需求需要实现的效果:
- 一进入页面显示的是品牌广告视图(代号:
A
),上拉超过一定的距离后,向上翻到相册视图(代号:B
) - 下拉超过一定距离后切换为
A
,快速下拉回到顶部的情况下不会切换到A
!
这里的 A
和 B
对应的就是下图效果图中的 AAA
和 BBB
视图
# 实现逻辑
定义正在展示的视图类型
enum VerticalFlipShowingType {
aaa,
bbb,
}
添加变量 headerFlipShowingType
用来记录当前 header
正在显示的类型,默认为 aaa
/// 当前header翻页视图显示的视图类型
VerticalFlipShowingType headerFlipShowingType = VerticalFlipShowingType.aaa;
主体结构
Scaffold(
body: CustomScrollView(
slivers: [
SliverToBoxAdapter(child: _buildHeaderWidget()), // header(AAA + BBB)
SliverList( // 正常的列表 (显示:index -- 下标)
delegate: SliverChildBuilderDelegate(
(ctx, index) {
return ListTile(title: Text("index -- $index"));
},
childCount: 40,
),
)
],
),
),
// 右下角的浮动按钮,用来切换 header
floatingActionButton: FloatingActionButton(
onPressed: () {
setState(() {
headerFlipShowingType =
headerFlipShowingType == VerticalFlipShowingType.aaa
? VerticalFlipShowingType.bbb
: VerticalFlipShowingType.aaa;
});
},
...
),
);
从上到下,依次为 header
,正常的列表数据,以及右下角按钮,点击按钮以测试切换效果
接下来是 header
的构建方法内容,为了方便理解,先来看看整体结构
Widget _buildHeaderWidget() {
return SizedBox( // 限制 Stack 的高度,否则报错
height: headerFlipShowingType == VerticalFlipShowingType.aaa
? headerFlipHeightA
: headerFlipHeightB
child: Stack(
clipBehavior: Clip.none, // 子视图超出 Stack 不裁剪,依旧显示
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
height: headerFlipHeight, // AAA 与 BBB 加起来的总高度
child: ListView(
padding: EdgeInsets.zero, // 消除顶部刘海造成的安全区域偏移
physics: const NeverScrollableScrollPhysics(), // 不可滚动
children: [
SizedBox(
height: headerFlipHeightA,
child: const HeaderFlipWidget(
text: "AAA",
color: Color(0xFFFA5252),
),
),
SizedBox(
height: headerFlipHeightB,
child: const HeaderFlipWidget(
text: "BBB",
color: Color(0xFF228BE6),
),
),
],
),
)
],
),
);
}
- 在
header
中,AAA
和BBB
视图都是存放到ListView
- 包裹
Stack
的SizedBox
就是为了控制header
的高度,且header
的高度会跟随当前展示的类型而改变,如:当前展示AAA
,则高度为AAA
视图的高度,展示BBB
时,则高度为BBB
视图的高度 - 使用
Stack
+Positioned
这个组合,将Positioned
的bottom
设置为0
,即ListView
的底部紧贴Stack
的底部,使得ListView
只能向上超出Stack
Stack
设置了clipBehavior
为Clip.none
,因此在正在显示BBB
视图的情况下,下拉列表可以看到AAA
的视图内容- 固定
Positioned
的 高度为AAA
和BBB
视图的总高,目的是为了结合后面提及的透明Container
(或者说AnimatedContainer B
), 方便控制BBB
视图的显示
如果这里还不太理解 AAA
和 BBB
的显示逻辑也不用担心,下面会进行详细说明
我们来看看此时的效果:
为了完整显示 AAA
视图,且不显示 BBB
视图,在 AAA
的前边添加一个透明的 Container
,并把高度设置为 BBB
的高度(显示 BBB
视图时,透明的 Container
的高度设置为 0
)
ListView(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
children: [
Container( // 添加了透明的 Container
height: headerFlipShowingType == VerticalFlipShowingType.aaa
? headerFlipHeightB
: 0,
),
SizedBox(
height: headerFlipHeightA,
child: const HeaderFlipWidget(
text: "AAA",
color: Color(0xFFFA5252),
),
),
SizedBox(
height: headerFlipHeightB,
child: const HeaderFlipWidget(
text: "BBB",
color: Color(0xFF228BE6),
),
),
],
)
我们再来看看此时的效果:
可以,不过变化很僵硬,加点动画就可以了
完整的 header
的构建方法代码如下
改动的地方在 AnimatedContainer A
和 AnimatedContainer B
注释处
Widget _buildHeaderWidget() {
var duration = const Duration(milliseconds: 200);
// AnimatedContainer A: 控制整个header以动画的方式改变高度
return AnimatedContainer(
duration: duration,
height: headerFlipShowingType == VerticalFlipShowingType.aaa
? headerFlipHeightA
: headerFlipHeightB,
child: Stack(
clipBehavior: Clip.none,
children: [
Positioned(
left: 0,
right: 0,
bottom: 0,
height: headerFlipHeight,
child: ListView(
padding: EdgeInsets.zero,
physics: const NeverScrollableScrollPhysics(),
children: [
// AnimatedContainer B:控制 AAA 和 BBB 切换时的互推效果
AnimatedContainer(
duration: duration,
height: headerFlipShowingType == VerticalFlipShowingType.aaa
? headerFlipHeightB
: 0,
),
SizedBox(
height: headerFlipHeightA,
child: const HeaderFlipWidget(
text: "AAA",
color: Color(0xFFFA5252),
),
),
SizedBox(
height: headerFlipHeightB,
child: const HeaderFlipWidget(
text: "BBB",
color: Color(0xFF228BE6),
),
),
],
),
)
],
),
);
}
AnimatedContainer A
: 控制整个header
以动画的方式改变高度AnimatedContainer B
: 控制AAA
和BBB
切换时的互推效果(切到AAA
时,将BBB
往下推,切到BBB
时,将AAA
往上推)
# 加深理解
为了更易于理解 AAA
和 BBB
的显示逻辑,我做了如下一张图
- 从左往右看:便是上拉时,从
AAA
切换到BBB
的过程 - 从右往左看:便是下拉时,从
BBB
切换到AAA
的过程
整个过程最主要的就是 AnimatedContainer A
和 AnimatedContainer B
的高度变化
- 前置条件:
Positioned
的 高度(即ListView
的高度)为AAA
和BBB
视图的总高 - 当显示
AAA
时,即图一,Stack
的高度为AAA
的高度,AnimatedContainer B
的高度为BBB
的高度,此时的BBB
视图超出了ListView
的视窗,所以其不显示,注意看图一半透明的BBB
- 在切换显示
BBB
的过程中,即图二到图三,AnimatedContainer B
的高度不断减小至0
,使得BBB
逐渐进入ListView
的视窗内,直到完全展示,Stack
的高度也从AAA
的高度减小至BBB
的高度,使得AAA
超出ListView
的视窗,这样便只会展示BBB
了
# 完善逻辑
上述的 header
视图切换是靠右下角按钮的点击,实际使用时,是要靠列表滚动时来判断是否进行切换的
/// 偏移量阈值
final double headerFlipThreshold = 50;
/// 可监听滚动,控制偏移量
final ScrollController scrollController = ScrollController();
在 CustomScrollView
中传入 scrollController
,并用 NotificationListener
包裹起来,监听其滚动
Scaffold(
body: NotificationListener<ScrollNotification>( // 监听滚动
onNotification: (ScrollNotification notification) {
_handleListScroll(notification);
return false; // return true 会导致进度条将失效
},
child: CustomScrollView(
controller: scrollController, // 传入 scrollController
...
),
),
floatingActionButton: ...
);
接下来就是处理滚动
void _handleListScroll(ScrollNotification notification) {
var offset = notification.metrics.pixels;
if (headerFlipShowingType == VerticalFlipShowingType.aaa) {
if (offset > headerFlipThreshold) {
setState(() {
headerFlipShowingType = VerticalFlipShowingType.bbb;
});
scrollController.jumpTo(0);
}
} else {
if (offset < -headerFlipThreshold) {
setState(() {
headerFlipShowingType = VerticalFlipShowingType.aaa;
});
scrollController.jumpTo(0);
}
}
}
- 根据偏移量是否超过阈值,来控制是否切换
header
- 当切换
header
后,我们需要使用scrollController
来设置列表的偏移量为0
到这里也就差不多了,不过,现在还有个细节需要处理,请看图
如图操作所示,快速下拉滚动会直接从 BBB
切换到 AAA
,这并不是我们想要的。
我们想要的是:当前正在显示 BBB
,且从最顶部开始下拉才能切到 AAA
代码改动:
/// 是否是从最顶部开始滚动的
bool isStartScrollAtTop = false;
void _handleListScroll(ScrollNotification notification) {
var offset = notification.metrics.pixels;
if (notification is ScrollStartNotification) { // 开始滚动
isStartScrollAtTop = offset == 0; // 记录是否是从最顶部开始滚动
} else {
if (headerFlipShowingType == VerticalFlipShowingType.aaa) {
if (offset > headerFlipThreshold) {
setState(() {
headerFlipShowingType = VerticalFlipShowingType.bbb;
});
scrollController.jumpTo(0);
}
} else {
if (!isStartScrollAtTop) { // 不是从最顶部开始滚动,则不切换到 AAA
return;
}
if (offset < -headerFlipThreshold) {
setState(() {
headerFlipShowingType = VerticalFlipShowingType.aaa;
});
scrollController.jumpTo(0);
}
}
}
}
大功告成,最后附上 Demo
链接:flutter_demo/vertical_flip_page.dart (github.com) (opens new window)
- 01
- Flutter - 危!3.24版本苹果审核被拒!11-13
- 02
- Flutter - 轻松搞定炫酷视差(Parallax)效果09-21
- 03
- Flutter - 轻松实现PageView卡片偏移效果09-08