在 Flutter 中使用 NestedScrollView 與 CustomScrollView
最近在修改 app 的個人頁,遇到的需求:
- Tab bar 切出圓角
- Tab bar 可以滑動,滑到 app bar 的時候固定住
用文字說不太清楚,下面直接看圖:
頁面的 components 有:
- App bar
- 個人資訊區
- Tab bar
- Tab view
Tab view 裡是可滾動的內容, 這裡放了兩種類型:grid 與 list。 當用戶向上滑動時,app bar 固定不動,tab bar 往上滑動到 tab bar 時固定不動,下方 tab view 保持可滑動。如下圖:
放上我的 demo project 以供參考: demo20220701。
App bar 與個人資訊區
一般在網上看到的範例都是用 SliverAppBar,設定 pinned
為 true
,然後將個人資訊區放到 flexibleSpace
。
但是實作的過程中發現,這個方法非常的不好用。原因在於 flexibleSpace
還需要搭配設定 expandedHeight
去指定高度。而我的個人資訊中包含了非固定長度的自介,因此就需要提前計算自介區的高度然後調整 expandedHeight
。
為了省麻煩,我決定將自介包裝成 sliver 放到 appbar 底下。
謎之音: 如果
flexibleSpace
只放頭像跟統計,那為何不乾脆放棄flexibleSpace
,全部都弄成 sliver,這樣連expandedHeight
都可以省略了?
既然 app bar 與 tab bar 之間有 widgets,那麼 tab bar 就不能放到 app bar 的 bottom
,它必須也是獨立的 sliver。
謎之音:既然 app bar 需要一直固定在上方,那現在還有需要使用 SliverAppBar 嗎?
Sliver Tab bar
Flutter 的 TabBar 並不是一個 sliver widget,再加上我需要讓 tab bar 在滑動的過程中可以固定到上方,因此就需要搭配使用 SliverPersistentHeader
。
SliverPersistentHeader
需要帶入 SliverPersistentHeaderDelegate
類型的 delegate
。這裡我參考網路上的範例,做了一份自己的:
import 'package:flutter/material.dart';
class SliverTabBarDelegate extends SliverPersistentHeaderDelegate {
final Color backgroundColor;
final TabBar tabBar;
final Radius topRadius;
const SliverTabBarDelegate({
required this.tabBar,
required this.backgroundColor,
required this.topRadius,
});
@override
double get minExtent => tabBar.preferredSize.height;
@override
double get maxExtent => tabBar.preferredSize.height;
@override
Widget build(context, shrinkOffset, overlapsContent) {
return Container(
decoration: BoxDecoration(
borderRadius: BorderRadius.only(
topLeft: topRadius,
topRight: topRadius,
),
color: backgroundColor,
),
child: tabBar,
);
}
@override
bool shouldRebuild(SliverTabBarDelegate oldDelegate) {
return tabBar != oldDelegate.tabBar;
}
}
- 在參數中直接帶入
TabBar
widget,然後高度就直接從 widget 中抓就可以了。 - 業務需求需要切 tab bar 的圓角,因此參數額外加上 radius,然後在
build
中設定 border radius。 - 另外帶入 tab bar 本身的底色,以便於區分 scaffold 後面的背景色。
有了 delegate 之後,就可以把 tab bar 製作成 sliver widget:
SliverPersistentHeader(
delegate: SliverTabBarDelegate(
tabBar: TabBar(controller: _tabController, tabs: _tabs),
topRadius: _topRadius,
backgroundColor: Colors.white,
),
pinned: true,
)
CustomScrollView
看一下目前為止的架構:
AppBar 是不是 sliver 已經無所謂了,個人還是喜歡原本的方式,因此不採用 SliverAppBar;Tab view 是不是 sliver 則視實作的方式決定。Profile 與 tab bar 則確定是 sliver widgets。
這種場景一般會直接用 CustomScrollView 建構頁面。例如:
SafeArea(
bottom: false,
child: DefaultTabController(
initialIndex: 0,
length: _tabs.length,
child: CustomScrollView(
slivers: [
SliverToBoxAdapter(
child: Profile(bio: bio, socialStatisticData: statistic),
),
const SliverPersistentHeader(
delegate: SliverTabBarDelegate(
tabBar: TabBar(tabs: _tabs),
topRadius: _topRadius,
backgroundColor: Colors.white,
),
pinned: true,
),
const SliverFillRemaining(
child: ColoredBox(
color: Color.fromRGBO(234, 236, 238, 1),
child: TabBarView(
children: [
ContentGrid(
itemCount: 50,
itemColor: Color.fromRGBO(245, 183, 177, 1),
),
ContentList(
itemCount: 50,
itemColor: Color.fromRGBO(169, 223, 191, 1),
)
],
),
),
),
],
),
),
)
但是這樣做會產生兩個問題:
- Scrolling 被切成了兩部份: profile + tab bar、
TabBarView
。TabBarView
中的 grid 與 list 上下滑動不會一起帶動 tab bar,這樣使用起來體驗很奇怪;但是如果在 grid/list 裡指定physics: NeverScrollableScrollPhysics()
則會造成列表無法捲動到底部。 - 當 grid/list 往上滑時,本應該隱藏在 tab bar 後的內容,會出現在 tab bar 的圓角旁邊,這樣視覺上就很差:
NestedScrollView + ValueListener
上面第一個問題很好解決,就是採用 NestedScrollView
,將 profile 與 tab bar 放到 headerSliverBuilder
;TabBarView
直接放在 body
中,不需要包裝成 sliver。同時,任何 physics
都不需要調整。
但是第二個問題呢? 我本來想到的解決方案是:當 grid/list 內容捲到 tab bar 後面時,就同時一起切圓角:
import 'package:flutter/material.dart';
import 'models/statistics.dart';
import 'widgets/lists.dart';
import 'widgets/grids.dart';
import 'widgets/profiles.dart';
import 'widgets/utils/scaffolds.dart';
import 'widgets/utils/tabbars.dart';
const _tabs = [Tab(text: 'Grid'), Tab(text: 'List')];
const _topRadius = Radius.circular(40);
class MyHomePage extends StatefulWidget {
final String bio;
final String name;
final SocialStatisticData statistic;
const MyHomePage({
required this.name,
required this.bio,
required this.statistic,
Key? key,
}) : super(key: key);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final ValueNotifier<bool> _innerTabScrolled = ValueNotifier(false);
@override
Widget build(BuildContext context) {
return ColorfulScaffold(
backgroundColor: const Color.fromRGBO(250, 250, 250, 0.3),
gradient: const LinearGradient(colors: [
Color.fromRGBO(247, 220, 111, 1),
Color.fromRGBO(243, 156, 18, 1),
]),
appBar: AppBar(
title: Text(widget.name),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SafeArea(
bottom: false,
child: DefaultTabController(
length: _tabs.length,
child: NestedScrollView(
headerSliverBuilder: (context, innerBoxScrolled) {
_innerTabScrolled.value = innerBoxScrolled;
return [
SliverToBoxAdapter(
child: Profile(
bio: widget.bio,
socialStatisticData: widget.statistic,
),
),
const SliverPersistentHeader(
delegate: SliverTabBarDelegate(
tabBar: TabBar(tabs: _tabs),
topRadius: _topRadius,
backgroundColor: Colors.white,
),
pinned: true,
),
];
},
body: ValueListenableBuilder<bool>(
valueListenable: _innerTabScrolled,
builder: (_, value, child) => ClipRRect(
borderRadius: value
? const BorderRadius.only(
topLeft: _topRadius,
topRight: _topRadius,
)
: BorderRadius.zero,
child: child,
),
child: const ColoredBox(
color: Color.fromRGBO(234, 236, 238, 1),
child: TabBarView(
children: [
ContentGrid(
itemCount: 50,
itemColor: Color.fromRGBO(245, 183, 177, 1),
),
ContentList(
itemCount: 50,
itemColor: Color.fromRGBO(169, 223, 191, 1),
)
],
),
),
),
),
),
),
);
}
}
當 innerBoxScrolled
為 true
時,就是 body 上滑到 headerSliverBuilder
的 slivers 裡了。這時候就裁切 TabBarView
。
理論上應該可以完美解決我的需求。但是,夢想很美好,現實很殘酷,跑出來的結果變成:
實際上是 body 觸及頂部的時候,innerBoxScrolled
才變更為 true
。於是就變成這樣瞬間切圓角的搞笑畫面。
這個是慢動作,滑快一點的話就看不出來了?! XD
NestedScrollView + CustomScrollView
在經過一番思考與嘗試之後,我將架構調整如下:
- 外層依然使用
NestedScrollView
,因為我們需要它來協調整體的 scrolling。 headerSliverBuilder
只放 profileTabBar
與TabBarView
同時放到NestedScrollView
的 body 中。但是為了讓TabBar
保留 pinned 到頂層的功能,因此這裡還是得要包裝成 sliver widget,然後連同TabBarView
(sliver) 一起放到CustomScrollView
裡。- 直接剪裁
CustomScrollView
圓角即可,grid/list 都是 child,因此不會跑出去。
這個方式有兩個重點:
NestedScrollView
與CustomScrollView
的 scroll controller 必須要設為同一個CustomScrollView
的physics
必須設為NeverScrollableScrollPhysics
兩者缺一不可,否則依然還是會有上下 scrolling 不同步的問題。
最終的程式碼:
import 'package:flutter/material.dart';
import 'models/statistics.dart';
import 'widgets/lists.dart';
import 'widgets/grids.dart';
import 'widgets/profiles.dart';
import 'widgets/utils/scaffolds.dart';
import 'widgets/utils/tabbars.dart';
const _tabs = [Tab(text: 'Grid'), Tab(text: 'List')];
const _topRadius = Radius.circular(20);
class MyHomePage extends StatefulWidget {
final String bio;
final String name;
final SocialStatisticData statistic;
const MyHomePage({
required this.name,
required this.bio,
required this.statistic,
Key? key,
}) : super(key: key);
@override
State<MyHomePage> createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final _scrollController = ScrollController();
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
return ColorfulScaffold(
backgroundColor: const Color.fromRGBO(250, 250, 250, 0.3),
gradient: const LinearGradient(colors: [
Color.fromRGBO(247, 220, 111, 1),
Color.fromRGBO(243, 156, 18, 1),
]),
appBar: AppBar(
title: Text(widget.name),
backgroundColor: Colors.transparent,
elevation: 0,
),
body: SafeArea(
bottom: false,
child: NestedScrollView(
controller: _scrollController,
headerSliverBuilder: (context, _) => [
SliverToBoxAdapter(
child: Profile(
bio: widget.bio,
socialStatisticData: widget.statistic,
),
),
],
body: ClipRRect(
borderRadius: const BorderRadius.only(
topLeft: _topRadius,
topRight: _topRadius,
),
child: ColoredBox(
color: Colors.white,
child: DefaultTabController(
length: _tabs.length,
child: CustomScrollView(
physics: const NeverScrollableScrollPhysics(),
controller: _scrollController,
slivers: const [
SliverPersistentHeader(
delegate: SliverTabBarDelegate(
tabBar: TabBar(tabs: _tabs),
topRadius: Radius.zero,
backgroundColor: Colors.white,
),
pinned: true,
),
SliverTabBarView(
color: Color.fromRGBO(234, 236, 238, 1),
children: [
ContentGrid(
itemCount: 50,
itemColor: Color.fromRGBO(245, 183, 177, 1),
),
ContentList(
itemCount: 50,
itemColor: Color.fromRGBO(169, 223, 191, 1),
)
],
),
],
),
),
),
),
),
),
);
}
}
最終的成果: