前言:
在 Flutter 應(yīng)用中呢铆,導(dǎo)航欄切換頁(yè)面后默認(rèn)情況下會(huì)丟失原頁(yè)面狀態(tài)颜凯,即每次進(jìn)入頁(yè)面時(shí)都會(huì)重新初始化狀態(tài),如果在initState
中打印日志暑竟,會(huì)發(fā)現(xiàn)每次進(jìn)入時(shí)都會(huì)輸出斋射,顯然這樣增加了額外的開(kāi)銷(xiāo),并且?guī)?lái)了不好的用戶體驗(yàn)但荤。
在正文之前罗岖,先看一些常見(jiàn)的 App 導(dǎo)航,以喜馬拉雅 FM 為例:
它擁有一個(gè)固定的底部導(dǎo)航以及首頁(yè)的頂部導(dǎo)航纱兑,可以看到不管是點(diǎn)擊底部導(dǎo)航切換頁(yè)面還是在首頁(yè)左右側(cè)滑切換頁(yè)面呀闻,之前的頁(yè)面狀態(tài)都是始終維持的,下面就具體介紹下如何在 flutter 中實(shí)現(xiàn)類(lèi)似喜馬拉雅的導(dǎo)航效果
第一步:實(shí)現(xiàn)固定的底部導(dǎo)航
在通過(guò)flutter create
生成的項(xiàng)目模板中,我們先簡(jiǎn)化一下代碼潜慎,將MyHomePage
提取到一個(gè)單獨(dú)的home.dart
文件捡多,并在Scaffold
腳手架中添加bottomNavigationBar
底部導(dǎo)航,在body
中展示當(dāng)前選中的子頁(yè)面铐炫。
/// home.dart
import 'package:flutter/material.dart';
import './pages/first_page.dart';
import './pages/second_page.dart';
import './pages/third_page.dart';
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final items = [
BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('首頁(yè)')),
BottomNavigationBarItem(icon: Icon(Icons.music_video), title: Text('聽(tīng)')),
BottomNavigationBarItem(icon: Icon(Icons.message), title: Text('消息'))
];
final bodyList = [FirstPage(), SecondPage(), ThirdPage()];
int currentIndex = 0;
void onTap(int index) {
setState(() {
currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('demo'),
),
bottomNavigationBar: BottomNavigationBar(
items: items,
currentIndex: currentIndex,
onTap: onTap
),
body: bodyList[currentIndex]
);
}
}
其中的三個(gè)子頁(yè)面結(jié)構(gòu)相同垒手,均顯示一個(gè)計(jì)數(shù)器和一個(gè)加號(hào)按鈕,以first_page.dart
為例:
/// first_page.dart
import 'package:flutter/material.dart';
class FirstPage extends StatefulWidget {
@override
_FirstPageState createState() => _FirstPageState();
}
class _FirstPageState extends State<FirstPage> {
int count = 0;
void add() {
setState(() {
count++;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Center(
child: Text('First: $count', style: TextStyle(fontSize: 30))
),
floatingActionButton: FloatingActionButton(
onPressed: add,
child: Icon(Icons.add),
)
);
}
}
當(dāng)前效果如下:
可以看到倒信,從第二頁(yè)切換回第一頁(yè)時(shí)科贬,第一頁(yè)的狀態(tài)已經(jīng)丟失
第二步:實(shí)現(xiàn)底部導(dǎo)航切換時(shí)保持原頁(yè)面狀態(tài)
可能有些小伙伴在搜索后會(huì)開(kāi)始直接使用官方推薦的AutomaticKeepAliveClientMixin
,通過(guò)在子頁(yè)面的 State 類(lèi)重寫(xiě)wantKeepAlive
為true
鳖悠。 然而榜掌,如果你的代碼和我上面的類(lèi)似,body 中并沒(méi)有使用PageView
或TabBarView
乘综,很不幸的告訴你憎账,踩到坑了,這樣是無(wú)效的卡辰,原因后面再詳述“澹現(xiàn)在我們先來(lái)介紹另外兩種方式:
① 使用IndexedStack
實(shí)現(xiàn)
IndexedStack
繼承自Stack
,它的作用是顯示第index
個(gè)child
九妈,其它child
在頁(yè)面上是不可見(jiàn)的反砌,但所有child
的狀態(tài)都被保持,所以這個(gè)Widget
可以實(shí)現(xiàn)我們的需求萌朱,我們只需要將現(xiàn)在的body
用IndexedStack
包裹一層即可
/// home.dart
class _MyHomePageState extends State<MyHomePage> {
...
...
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('demo'),
),
bottomNavigationBar: BottomNavigationBar(
items: items, currentIndex: currentIndex, onTap: onTap),
// body: bodyList[currentIndex]
body: IndexedStack(
index: currentIndex,
children: bodyList,
));
}
保存后再次測(cè)試一下
② 使用Offstage
實(shí)現(xiàn)
Offstage
的作用十分簡(jiǎn)單宴树,通過(guò)一個(gè)參數(shù)來(lái)控制child
是否顯示,所以我們同樣可以組合使用Offstage
來(lái)實(shí)現(xiàn)該需求嚷兔,其實(shí)現(xiàn)原理與IndexedStack
類(lèi)似
/// home.dart
class _MyHomePageState extends State<MyHomePage> {
...
...
...
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(
title: Text('demo'),
),
bottomNavigationBar: BottomNavigationBar(
items: items, currentIndex: currentIndex, onTap: onTap),
// body: bodyList[currentIndex],
body: Stack(
children: [
Offstage(
offstage: currentIndex != 0,
child: bodyList[0],
),
Offstage(
offstage: currentIndex != 1,
child: bodyList[1],
),
Offstage(
offstage: currentIndex != 2,
child: bodyList[2],
),
],
));
}
}
在上面的兩種方式中都可以實(shí)現(xiàn)保持原頁(yè)面狀態(tài)的需求森渐,但這里有一些開(kāi)銷(xiāo)上的問(wèn)題做入,有經(jīng)驗(yàn)的小伙伴應(yīng)該能發(fā)現(xiàn)當(dāng)應(yīng)用第一次加載的時(shí)候,所有子頁(yè)狀態(tài)都被實(shí)例化了(>這里的細(xì)節(jié)并不是因?yàn)槲抑苯影炎禹?yè)實(shí)例化放在bodyList
里...<)同衣,如果在子頁(yè)State
的initState
中打印日志竟块,可以在終端看到一次性輸出了所有子頁(yè)的日志。下面就介紹另一種通過(guò)繼承AutomaticKeepAliveClientMixin
的方式來(lái)更好的實(shí)現(xiàn)保持狀態(tài)耐齐。
第三步:實(shí)現(xiàn)首頁(yè)的頂部導(dǎo)航
首先我們通過(guò)配合使用TabBar
+TabBarView
+AutomaticKeepAliveClientMixin
來(lái)實(shí)現(xiàn)頂部導(dǎo)航(注意:TabBar
和TabBarView
需要提供controller
浪秘,如果自己沒(méi)有定義,則必須使用DefaultTabController
包裹)埠况。此處也可以選擇使用PageView
耸携,后面會(huì)介紹。
我們先在home.dart
文件移除Scaffold
腳手架中的appBar
頂部工具欄辕翰,然后開(kāi)始重寫(xiě)首頁(yè)first_page.dart
:
/// first_page.dart
import 'package:flutter/material.dart';
import './recommend_page.dart';
import './vip_page.dart';
import './novel_page.dart';
import './live_page.dart';
class _TabData {
final Widget tab;
final Widget body;
_TabData({this.tab, this.body});
}
final _tabDataList = <_TabData>[
_TabData(tab: Text('推薦'), body: RecommendPage()),
_TabData(tab: Text('VIP'), body: VipPage()),
_TabData(tab: Text('小說(shuō)'), body: NovelPage()),
_TabData(tab: Text('直播'), body: LivePage())
];
class FirstPage extends StatefulWidget {
@override
_FirstPageState createState() => _FirstPageState();
}
class _FirstPageState extends State<FirstPage> {
final tabBarList = _tabDataList.map((item) => item.tab).toList();
final tabBarViewList = _tabDataList.map((item) => item.body).toList();
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: tabBarList.length,
child: Column(
children: <Widget>[
Container(
width: double.infinity,
height: 80,
padding: EdgeInsets.fromLTRB(20, 24, 0, 0),
alignment: Alignment.centerLeft,
color: Colors.black,
child: TabBar(
isScrollable: true,
indicatorColor: Colors.red,
indicatorSize: TabBarIndicatorSize.label,
unselectedLabelColor: Colors.white,
unselectedLabelStyle: TextStyle(fontSize: 18),
labelColor: Colors.red,
labelStyle: TextStyle(fontSize: 20),
tabs: tabBarList),
),
Expanded(
child: TabBarView(
children: tabBarViewList,
// physics: NeverScrollableScrollPhysics(), // 禁止滑動(dòng)
))
],
));
}
}
其中推薦頁(yè)夺衍、VIP 頁(yè)、小說(shuō)頁(yè)喜命、直播頁(yè)的結(jié)構(gòu)仍和之前的首頁(yè)結(jié)構(gòu)相同沟沙,僅顯示一個(gè)計(jì)數(shù)器和一個(gè)加號(hào)按鈕,以推薦頁(yè)recommend_page.dart
為例:
/// recommend_page.dart
import 'package:flutter/material.dart';
class RecommendPage extends StatefulWidget {
@override
_RecommendPageState createState() => _RecommendPageState();
}
class _RecommendPageState extends State<RecommendPage> {
int count = 0;
void add() {
setState(() {
count++;
});
}
@override
void initState() {
super.initState();
print('recommend initState');
}
@override
Widget build(BuildContext context) {
return Scaffold(
body:Center(
child: Text('首頁(yè)推薦: $count', style: TextStyle(fontSize: 30))
),
floatingActionButton: FloatingActionButton(
onPressed: add,
child: Icon(Icons.add),
));
}
}
保存后測(cè)試壁榕,
可以看到矛紫,現(xiàn)在添加了首頁(yè)頂部導(dǎo)航,且默認(rèn)支持左右側(cè)滑牌里,接下來(lái)再進(jìn)一步的完善狀態(tài)保持
第四步:實(shí)現(xiàn)首頁(yè)頂部導(dǎo)航切換時(shí)保持原頁(yè)面狀態(tài)
③ 使用AutomaticKeepAliveClientMixin
實(shí)現(xiàn)
寫(xiě)到這里已經(jīng)很簡(jiǎn)單了颊咬,我們只需要在首頁(yè)導(dǎo)航內(nèi)需要保持頁(yè)面狀態(tài)的子頁(yè)State
中,繼承AutomaticKeepAliveClientMixin
并重寫(xiě)wantKeepAlive
為true
即可牡辽。
notes:Subclasses must implement wantKeepAlive, and their build methods must call super.build (the return value will always return null, and should be ignored)
以首頁(yè)推薦recommend_page.dart
為例:
/// recommend_page.dart
import 'package:flutter/material.dart';
class RecommendPage extends StatefulWidget {
@override
_RecommendPageState createState() => _RecommendPageState();
}
class _RecommendPageState extends State<RecommendPage>
with AutomaticKeepAliveClientMixin {
int count = 0;
void add() {
setState(() {
count++;
});
}
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
print('recommend initState');
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body:Center(
child: Text('首頁(yè)推薦: $count', style: TextStyle(fontSize: 30))
),
floatingActionButton: FloatingActionButton(
onPressed: add,
child: Icon(Icons.add),
));
}
}
再次保存測(cè)試喳篇,
現(xiàn)在已經(jīng)可以看到,不管是切換底部導(dǎo)航還是切換首頁(yè)頂部導(dǎo)航态辛,所有的頁(yè)面狀態(tài)都可以被保持杭隙,并且在應(yīng)用第一次加載時(shí),終端只看到recommend initState
的日志因妙,第一次切換首頁(yè)頂部導(dǎo)航至 vip 頁(yè)面時(shí),終端輸出vip initState
票髓,當(dāng)再次返回推薦頁(yè)時(shí)攀涵,不再輸出recommend initState
。
所以洽沟,使用TabBarView
+AutomaticKeepAliveClientMixin
這種方式既實(shí)現(xiàn)了頁(yè)面狀態(tài)的保持以故,又具有類(lèi)似惰性求值的功能,對(duì)于未使用的頁(yè)面狀態(tài)不會(huì)進(jìn)行實(shí)例化裆操,減小了應(yīng)用初始化時(shí)的開(kāi)銷(xiāo)怒详。
更新
前面在底部導(dǎo)航介紹了使用IndexedStack
和Offstage
兩種方式實(shí)現(xiàn)保持頁(yè)面狀態(tài)炉媒,但它們的缺點(diǎn)在于第一次加載時(shí)便實(shí)例化了所有的子頁(yè)面State
。為了進(jìn)一步優(yōu)化昆烁,下面我們使用PageView
+AutomaticKeepAliveClientMixin
重寫(xiě)之前的底部導(dǎo)航吊骤,其中PageView
和TabBarView
的實(shí)現(xiàn)原理類(lèi)似,具體選擇哪一個(gè)并沒(méi)有強(qiáng)制要求静尼。更新后的home.dart
文件如下:
/// home.dart
import 'package:flutter/material.dart';
import './pages/first_page.dart';
import './pages/second_page.dart';
import './pages/third_page.dart';
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final items = [
BottomNavigationBarItem(icon: Icon(Icons.home), title: Text('首頁(yè)')),
BottomNavigationBarItem(icon: Icon(Icons.music_video), title: Text('聽(tīng)')),
BottomNavigationBarItem(icon: Icon(Icons.message), title: Text('消息'))
];
final bodyList = [FirstPage(), SecondPage(), ThirdPage()];
final pageController = PageController();
int currentIndex = 0;
void onTap(int index) {
pageController.jumpToPage(index);
}
void onPageChanged(int index) {
setState(() {
currentIndex = index;
});
}
@override
Widget build(BuildContext context) {
return Scaffold(
bottomNavigationBar: BottomNavigationBar(
items: items, currentIndex: currentIndex, onTap: onTap),
// body: bodyList[currentIndex],
body: PageView(
controller: pageController,
onPageChanged: onPageChanged,
children: bodyList,
physics: NeverScrollableScrollPhysics(), // 禁止滑動(dòng)
));
}
}
然后在bodyList
的子頁(yè)State
中繼承AutomaticKeepAliveClientMixin
并重寫(xiě)wantKeepAlive
白粉,以second_page.dart
為例:
/// second_page.dart
import 'package:flutter/material.dart';
class SecondPage extends StatefulWidget {
@override
_SecondPageState createState() => _SecondPageState();
}
class _SecondPageState extends State<SecondPage>
with AutomaticKeepAliveClientMixin {
int count = 0;
void add() {
setState(() {
count++;
});
}
@override
bool get wantKeepAlive => true;
@override
void initState() {
super.initState();
print('second initState');
}
@override
Widget build(BuildContext context) {
super.build(context);
return Scaffold(
body: Center(
child: Text('Second: $count', style: TextStyle(fontSize: 30))
),
floatingActionButton: FloatingActionButton(
onPressed: add,
child: Icon(Icons.add),
));
}
}
Ok,更新后保存運(yùn)行鼠渺,應(yīng)用第一次加載時(shí)不會(huì)輸出second initState
鸭巴,僅當(dāng)?shù)谝淮吸c(diǎn)擊底部導(dǎo)航切換至該頁(yè)時(shí),該子頁(yè)的State
被實(shí)例化拦盹。
至此鹃祖,如何實(shí)現(xiàn)一個(gè)類(lèi)似的 底部 + 首頁(yè)頂部導(dǎo)航 完結(jié) ~