Flutter中我們經常使用setState來進行頁面的rebuild。但為了更新某一個widget而在父widget中直接setState,會導致很多不必要的多次build問題。這裡我們就以bottomNavigationBar為例。 Flutter中要實現底部導航欄效果,系統已經提供了現成的方法供我們使用,比如使用Scaffold,他的bottomNavigationBar屬性即是配置底部導航欄的地方,在切換時顯示不同的body內容即可。所以它的一般使用方式如下:body採用PageView,當點選底部tab時切換PageView(這裡假設已經會使用了)
class _BottomNavState extends State<BottomNavPage> {
List<BottomNavigationBarItem> naviItems = [];
PageController _pageController;
int currentIndex=0;
@override
void initState() {
super.initState();
_pageController = PageController();
buildItems();
}
void _pageChanged(int index) {
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView.builder(
itemBuilder: (context, index) {
return PageTest(
title: "Page $index",
index: index,
);
},
controller: _pageController,
itemCount: 5,
physics: NeverScrollableScrollPhysics(),
onPageChanged: _pageChanged,
),
bottomNavigationBar: BottomNavigationBar(
currentIndex: currentIndex,
items: naviItems,
showUnselectedLabels: true,
type: BottomNavigationBarType.fixed,
onTap: (int index){
currentIndex = index;
setState(() {
_pageController.jumpToPage(index);
});
},
),
);
}
void buildItems() {
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_dh.png", "Page1", "assets/xz_dh_icon_dh.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_dt.png", "Page2", "assets/xz_dh_icon_dt.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_xx.png", "Page3", "assets/xz_dh_icon_xx.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_xs.png", "Page4", "assets/xz_dh_icon_xs.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_wd.png", "Page5", "assets/xz_dh_icon_wd.png"));
}
BottomNavigationBarItem buildOriginalNavItem(String labelPath, String title,
String activeLabelPath) {
return BottomNavigationBarItem(
icon: Image.asset(
labelPath,
width: 22,
height: 22,
),
label: title,
activeIcon: Image.asset(
activeLabelPath,
width: 22,
height: 22,
));
}
}
複製程式碼
PageView 的每一頁我們都用了一個StatefullWidget,定義如下(實際中可以定義不同的頁面,這裡主要是為了方便)
class PageTest extends StatefulWidget {
final String title;
final int index;
PageTest({this.title, this.index = 0,Key key}):super(key: key);
@override
State<StatefulWidget> createState() {
print("${title} createState");
return _PageTestState();
}
}
class _PageTestState extends State<PageTest> with AutomaticKeepAliveClientMixin<PageTest>{
List<Color> colors = [
Colors.purple,
Colors.deepOrange,
Colors.orangeAccent,
Colors.lightGreenAccent,
Colors.pink[100]
];
@override
void initState() {
super.initState();
print("${widget.title} initState");
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
print("${widget.title} didChangeDependencies");
}
@override
void didUpdateWidget(covariant PageTest oldWidget) {
super.didUpdateWidget(oldWidget);
print("${widget.title} didUpdateWidget");
}
@override
void dispose() {
super.dispose();
print("${widget.title} dispose");
}
@override
Widget build(BuildContext context) {
super.build(context);
print("${widget.title} build");
return Scaffold(
appBar: AppBar(
leading: GestureDetector(
child: Icon(Icons.arrow_back),
onTap: () => onBackPressed(context),
),
title: Text(widget.title),
),
body: Container(
color: colors[widget.index],
),
);
}
void onBackPressed(BuildContext context) {
NavigatorState navigatorState = Navigator.of(context);
if (navigatorState.canPop()) {
navigatorState.pop();
} else {
SystemNavigator.pop();
}
}
@override
bool get wantKeepAlive => true;
}
複製程式碼
可以看到在它的State類的定義中我們使用了AutomaticKeepAliveClientMixin
,並且讓wantKeepAlive
方法返回了true。這麼做的目的主要是想在tab切換時不重複建立。同時我們在他的createState,initState,didChangeDependencies,didUpdateWidget,build,dispose
方法中都列印了log。那麼這樣到底能不能達到目的,我們可以看一下實現的效果以及從第1頁切換到第5頁的log
從第一頁依次切換到第五頁log
可以看到第一次載入時只是執行了第一頁的createState,initState,didChangeDependencies,build
這四個方法,切換到第二頁時,除了執行第二頁的同第一頁的四個方法之外還執行了第一頁的didUpdateWidget,build
這兩個方法,以後每切換一個新的頁面除了自身執行上述四個方法,已經建立完的頁面都會執行didUpdateWidget,build
。都建立完之後,每次切換tab,每個頁面都會執行這兩個方法。比如從1依次切換到5,然後點選3
看來僅僅讓PageView的頁面儲存狀態,也不能解決多次build的問題。既然多呼叫了didUpdateWidget
和build
方法。那麼我們就先看看上述這6個方法分別在什麼時候會被呼叫。這其實就是通常所說的Statefullwidget的生命週期
可以看到生命週期方法如圖所示,這也是為什麼上面的log會新增在這幾個方法裡。其實這些方法可以分為三個階段:建立(插入檢視樹)、更新(在檢視樹中存在)、銷燬(從檢視樹中移除)。
建立:
State 初始化時會依次執行 :構造方法 -> initState -> didChangeDependencies -> build,隨後完成頁面渲染。
- 構造方法是 State 生命週期的起點,Flutter 會通過呼叫 StatefulWidget.createState() 來建立一個 State。我們可以通過構造方法,來接收父 Widget 傳遞的初始化 UI 配置資料。這些配置資料,決定了 Widget 最初的呈現效果。
- initState,會在 State 物件被插入檢視樹的時候呼叫。這個函式在 State 的生命週期中只會被呼叫一次,所以我們可以在這裡做一些初始化工作,比如為狀態變數設定預設值。
- didChangeDependencies 則用來專門處理 State 物件依賴關係變化,會在 initState() 呼叫結束後,被 Flutter 呼叫。
- build,作用是構建檢視。經過以上步驟,Framework 認為 State 已經準備好了,於是呼叫 build。我們需要在這個函式中,根據父 Widget 傳遞過來的初始化配置資料,以及 State 的當前狀態,建立一個 Widget 然後返回。
更新:
Widget 的狀態更新,主要由 3 個方法觸發:setState、didchangeDependencies 與 didUpdateWidget。
- setState:我們最熟悉的方法之一。當狀態資料發生變化時,我們總是通過呼叫這個方法告訴 Flutter:“我這兒的資料變啦,請使用更新後的資料重建 UI!”
- didChangeDependencies:State 物件的依賴關係發生變化後,Flutter 會回撥這個方法,隨後觸發元件構建。哪些情況下 State 物件的依賴關係會發生變化呢?這個“依賴”指的就是子widget是否使用了父widget中InheritedWidget的資料!如果使用了,則代表子widget依賴有依賴InheritedWidget;如果沒有使用則代表沒有依賴。這種機制可以使子元件在所依賴的InheritedWidget變化時來更新自身!比如當主題、locale(語言)等發生變化時,依賴其的子widget的didChangeDependencies方法將會被呼叫。
- didUpdateWidget:當 Widget 的配置發生變化時,比如,父 Widget 觸發重建(即父 Widget 的狀態發生變化時),熱過載時,系統會呼叫這個函式。一旦這三個方法被呼叫,Flutter 隨後就會銷燬老 Widget,並呼叫 build 方法重建 Widget。
從生命週期的方法呼叫分析可以看出之所以每次切換tab會呼叫didUpdateWidget,build
的原因是因為父widget觸發了重建(因為我們在onTap方法中呼叫了setState)。如果我們不呼叫setState又無法更新tab的選中狀態。所以我們的解決方法就是自定義一個bottomNavigationBar,首先我們看看Scaffold的這個屬性(final Widget? bottomNavigationBar;
)其實就是一個widget,而且是一個StatefullWidget,所以我們仿照這個寫個簡易版的bar,他的構造方法如下
BottomNavigationBar({
Key? key,
required this.items,
this.onTap,
this.currentIndex = 0,
this.elevation,
this.type,
Color? fixedColor,
this.backgroundColor,
this.iconSize = 24.0,
Color? selectedItemColor,
this.unselectedItemColor,
this.selectedIconTheme,
this.unselectedIconTheme,
this.selectedFontSize = 14.0,
this.unselectedFontSize = 12.0,
this.selectedLabelStyle,
this.unselectedLabelStyle,
this.showSelectedLabels,
this.showUnselectedLabels,
this.mouseCursor,
})
複製程式碼
具體的屬性作用基本上可以猜到,我這裡就不詳述了。可以看到這裡的items是必須的,它是一個list(final List<BottomNavigationBarItem> items
),而這裡具體的item為
class BottomNavigationBarItem {
const BottomNavigationBarItem({
required this.icon,
@Deprecated(
'Use "label" instead, as it allows for an improved text-scaling experience. '
'This feature was deprecated after v1.19.0.'
)
this.title,
this.label,
Widget? activeIcon,
this.backgroundColor,
this.tooltip,
}) : activeIcon = activeIcon ?? icon,
assert(label == null || title == null),
assert(icon != null);
...
}
複製程式碼
其實就一個單純的class,定義了每個tab的元素,比如選中的圖示,未選中圖光,背景,用於顯示title的widget。至此係統封裝的BottomNavigationBar元素分析已經結束了。我們也按照這種方式來封裝自己的,比如這裡的Item,我們定義如下
class CustomBottomNavItem {
///圖片下的標題
final String title;
///顯示的圖片或者是自定義的widget(未選中)
final Widget label;
///顯示的圖片或者是自定義的widget(選中)
final Widget activeLabel;
///文字樣式(選中)
final TextStyle selectedStyle;
///文字樣式(未選中)
final TextStyle unselectedStyle;
///當前item在整個itemBar中的位置
int index;
CustomBottomNavItem(
{Key key,
this.title,
this.label,
this.selectedStyle,
this.unselectedStyle,
this.index,
this.activeLabel});
}
複製程式碼
這裡主要宣告瞭我們需要元素,那麼真正的容器也按照系統封裝的方式定義如下
class CustomNavBar extends StatefulWidget {
///所有的底部item
final List<CustomBottomNavItem> items;
///選中時的文字樣式
final TextStyle selectedStyle;
///未選中時的文字樣式
final TextStyle unselectedStyle;
///tab的點選
final ValueChanged<int> onTap;
///底部bar的整體高度
final double height;
///底部bar的背景
final Color bgColor;
CustomNavBar(
{Key key,
this.items,
this.selectedStyle,
this.unselectedStyle,
this.height,
this.bgColor,
this.onTap})
: super(key: key);
@override
State<StatefulWidget> createState() {
return CustomNavBarStatus();
}
}
複製程式碼
接下來的重點是如何在它的父widget中不用呼叫setState來更新這個控制元件。如果不想採用setState更新,那麼一般會有這兩種方式,一種是通過可監聽物件比如ValueNotifier,在物件變化時通過監聽器來重建相應的view;另一種則是為這個Widget設定key,在父Widget中通過這個key拿到子控制元件,呼叫子控制元件的setState方法,從而只重新整理子Widget。這裡我們首先使用ValueNotifier來實現。其實之所以需要重建,是因為當前選中的tab也就是index在不斷變化,所以我們的ValueNotifier修飾的應該是currentIndex,所以它的定義為
ValueNotifier<int> currentIndex = ValueNotifier<int>(0);
複製程式碼
如果需要可配置當前選中的index,在CustomNavBar中定義一個屬性傳遞進來即可,這裡先寫死。我們在看看開篇圖實現的效果,每一個tab是圖片和文字組成,每一個tab整體可以點選。所以我們先來定義這個子Widget,其實就是一個Column,為了點選我們需要用GestureDetector包裹一下。如果想要定義間距的話,直接在CustomNavBar中定義即可,這裡就省略了。在點選的時候更改當前currentIndex的值,他就可以觸發自動重建樣式而不用外部setState
Widget buildChild(CustomBottomNavItem item,int value) {
return GestureDetector(
onTap: () {
if (widget.onTap != null) widget.onTap(item.index);
currentIndex.value = item.index;
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
item.index == value
? item.activeLabel == null
? item.label
: item.activeLabel
: item.label,
Text(
item.title,
style: item.index == currentIndex.value
? item.selectedStyle
: item.unselectedStyle,
),
],
),
);
}
複製程式碼
上面提到,這裡使用註冊監聽器的方式重新整理,所以在點選的時候我們呼叫了currentIndex.value=item.index
他實際上是呼叫了ValueNotifier
的set
方法
set value(T newValue) {
if (_value == newValue)
return;
_value = newValue;
notifyListeners();
}
複製程式碼
也就是傳送了通知。所以我們要接收這個通知,並且建立這個widget。這裡我們採用ValueListenableBuilder
來監聽這個通知。從而這個State中的build方法我們實現如下
@override
Widget build(BuildContext context) {
return Container(
key: barKey,
height: widget.height,
color: widget.bgColor,
child: widget.items != null && widget.items.length > 0
? ValueListenableBuilder(valueListenable: currentIndex, builder: buildChildren)
: Container(),
);
}
複製程式碼
這裡的buildChildren方法如下
Widget buildChildren(BuildContext context, int value, Widget child) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: widget.items.map((item) => buildChild(item,value)).toList(),
);
}
複製程式碼
這裡我們直接寫死了。用row直接平分寬度,也就是原有控制元件的fixed模式,如果想滑動之類的buildChildren也可以採用ListView等。至此整個view就可以用了,我們試試看看是否達到了目的
class _BottomNavState extends State<BottomNavPage> {
List<CustomBottomNavItem> items = [];
PageController _pageController;
@override
void initState() {
super.initState();
_pageController = PageController();
initItems();
}
CustomBottomNavItem buildNavItem(String labelPath, String title, String activeLabelPath,
) {
return CustomBottomNavItem(
label: Image.asset(
labelPath,
width: 22,
height: 22,
),
title: title,
activeLabel: Image.asset(
activeLabelPath,
width: 22,
height: 22,
));
}
void _pageChanged(int index) {
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: PageView.builder(
itemBuilder: (context, index) {
return PageTest(
title: "Page $index",
index: index,
);
},
controller: _pageController,
itemCount: 5,
physics: NeverScrollableScrollPhysics(),
onPageChanged: _pageChanged,
),
bottomNavigationBar: CustomNavBar(
items: items,
height: 67,
onTap: (index) {
_pageController.jumpToPage(index);
},
selectedStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.bold,
color: Color(0xff414344)),
bgColor: Colors.greenAccent,
unselectedStyle: TextStyle(
fontSize: 13,
fontWeight: FontWeight.normal,
color: Color(0xff414344)),
),
);
}
void buildItems() {
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_dh.png", "Page1", "assets/xz_dh_icon_dh.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_dt.png", "Page2", "assets/xz_dh_icon_dt.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_xx.png", "Page3", "assets/xz_dh_icon_xx.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_xs.png", "Page4", "assets/xz_dh_icon_xs.png"));
naviItems.add(buildOriginalNavItem(
"assets/dh_icon_wd.png", "Page5", "assets/xz_dh_icon_wd.png"));
}
void initItem() {
items.add(buildNavItem(
"assets/dh_icon_dh.png", "Page1", "assets/xz_dh_icon_dh.png"));
items.add(buildNavItem(
"assets/dh_icon_dt.png", "Page2", "assets/xz_dh_icon_dt.png"));
items.add(buildNavItem(
"assets/dh_icon_xx.png", "Page3", "assets/xz_dh_icon_xx.png"));
items.add(buildNavItem(
"assets/dh_icon_xs.png", "Page4", "assets/xz_dh_icon_xs.png"));
items.add(buildNavItem(
"assets/dh_icon_wd.png", "Page5", "assets/xz_dh_icon_wd.png"));
}
}
複製程式碼
這會實現同樣的效果,不過們可以看看log
首次載入頁面時只會呼叫建立階段的四個方法,以後無論怎們切換都不會呼叫生命週期方法。也就是說我們成功了。但是這裡還有一點瑕疵,我們的PageView
的physics
屬性設定的是 NeverScrollableScrollPhysics()
也就是隻能點選tab切換嗎,而不能滑動頁面切換tab,如果不設定這個physics上面的程式碼在頁面滑動時候下面的tab並沒有相應的變化。如果想要有變化那麼就得讓他重新整理。我們都知道在PageView滑動的時候會回撥onPageChanged
方法,所以在這裡我們可以更新tab。這就用到了利用key來更新的方法。其實這個很簡單,只需要在需要更新的State中定義一個key,並且把這個key用於build中,同時提供獲取這個state的方法,比如這裡的定義如下
static GlobalKey barKey = GlobalKey();
static currentInstance() {
State<CustomNavBar> state =
CustomNavBarStatus.barKey.currentContext.findAncestorStateOfType();
return state;
}
setCurrentIndex(int index) {
currentIndex.value = index;
}
複製程式碼
只需在onPageChanged方法中呼叫
CustomNavBarStatus.currentInstance().setCurrentIndex(index);
即可更新這個tabbar。所以整個tabBar的完整程式碼為
class CustomNavBar extends StatefulWidget {
///所有的底部item
final List<CustomBottomNavItem> items;
///選中時的文字樣式
final TextStyle selectedStyle;
///未選中時的文字樣式
final TextStyle unselectedStyle;
///tab的點選
final ValueChanged<int> onTap;
///底部bar的整體高度
final double height;
///底部bar的背景
final Color bgColor;
CustomNavBar(
{Key key,
this.items,
this.selectedStyle,
this.unselectedStyle,
this.height,
this.bgColor,
this.onTap})
: super(key: key);
@override
State<StatefulWidget> createState() {
return CustomNavBarStatus();
}
}
class CustomNavBarStatus extends State<CustomNavBar> {
static GlobalKey barKey = GlobalKey();
static currentInstance() {
State<CustomNavBar> state =
CustomNavBarStatus.barKey.currentContext.findAncestorStateOfType();
return state;
}
setCurrentIndex(int index) {
currentIndex.value = index;
}
ValueNotifier<int> currentIndex = ValueNotifier<int>(0);
@override
void initState() {
super.initState();
refreshChild();
}
void refreshChild() {
for (int i = 0; i < widget.items.length; i++) {
widget.items[i].index = i;
}
}
@override
Widget build(BuildContext context) {
return Container(
key: barKey,
height: widget.height,
color: widget.bgColor,
child: widget.items != null && widget.items.length > 0
? ValueListenableBuilder(valueListenable: currentIndex, builder: buildChildren)
: Container(),
);
}
Widget buildChild(CustomBottomNavItem item,int value) {
return GestureDetector(
onTap: () {
if (widget.onTap != null) widget.onTap(item.index);
currentIndex.value = item.index;
},
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
item.index == value
? item.activeLabel == null
? item.label
: item.activeLabel
: item.label,
Text(
item.title,
style: item.index == currentIndex.value
? item.selectedStyle
: item.unselectedStyle,
),
],
),
);
}
Widget buildChildren(BuildContext context, int value, Widget child) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceEvenly,
children: widget.items.map((item) => buildChild(item,value)).toList(),
);
}
}
複製程式碼
至此整個過程就完畢了,歡迎大家指正