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 多仓库本地 Monorepo 方案与体验优化10-25
- 02
- Flutter webview 崩溃率上升怎么办?我的分析与解决方案10-19
- 03
- Flutter - Melos Pub workspaces 实践10-12