app開發中老是會遇到使用TabBar的狀況,不論是原生仍是混合,在TabBar的使用上都會稍顯複雜,那在Flutter中TabBar又是怎樣的呢?本文將從如下幾個方面講解TabBargit
Flutter使用TabBar,主要仍是考慮controller的實現。一般使用默認的DefaultTabController就能夠達到效果,也能夠自定義TabController。github
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 4,
child: Scaffold(
appBar: AppBar(
title: Text('TabBar'),
bottom: TabBar(
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: Colors.white,
indicatorWeight: 2.0,
isScrollable: true,
labelColor: Colors.white,
labelStyle: TextStyle(fontSize: 16.0),
unselectedLabelColor: Colors.white.withOpacity(0.5),
unselectedLabelStyle: TextStyle(fontSize: 12.0),
tabs: _titleList.map((text) => Tab(text: text)).toList())),
body: TabBarView(
children: <Widget>[ TestScreen1(), TestScreen2(), TestScreen3(), TestScreen4()
])));
}
複製代碼
const List<String> _titleList = ['test 1', 'test 2', 'test 3', 'test 4'];
class _DataScreenState extends State<DataPresentation> with SingleTickerProviderStateMixin {
TabController _tabController;
@override
void dispose() {
_tabController.dispose();
super.dispose();
}
@override
void initState() {
super.initState();
_tabController = TabController(length: _titleList.length, vsync: this);
}
@override
Widget build(BuildContext context) {
return Scaffold(
appBar: AppBar(title: Text('TabBar')),
body: _buildDataScreenBody(context));
}
Widget _buildDataScreenBody(BuildContext context) {
return Column(children: <Widget>[
Container(
width: double.infinity,
child: Align(
alignment: Alignment.center,
child: TabBar(
controller: _tabController,
indicatorSize: TabBarIndicatorSize.label,
indicatorColor: Colors.white,
indicatorWeight: 2.0,
isScrollable: true,
labelColor: Colors.white,
labelStyle: TextStyle(fontSize: 16.0),
unselectedLabelColor: Colors.white.withOpacity(0.5),
unselectedLabelStyle: TextStyle(fontSize: 12.0),
tabs: _titleList.map((text) => Tab(text: text)).toList()))),
Expanded(
child: TabBarView(controller: _tabController, children: [ TestScreen1(), TestScreen2(), TestScreen3(), TestScreen4()
]))
]);
}
}
複製代碼
一般爲了更好的控制TabBar,監聽事件等才使用TabController,不然DefaultTabController足夠平常使用,兩者效果無明顯差異。 看下效果 bash
仔細看下能夠發現上面的動畫效果有文字顫動的問題,而若是不使用labelStyle和unselectedLabelStyle,咱們沒法感知到TabBar的文字在顫動,可是當你一旦使用的時候,你會明顯的感覺到問題的存在,難道Flutter的動畫實現有問題?Flutter應該不會有這麼大的失誤,畢竟都release了。問題出在哪呢,此時得去看看TabBar的具體實現才能知曉。app
看下源碼,TabBar是繼承自StatefulWidget,因此得看_TabBarState的build方法。ide
@override
Widget build(BuildContext context) {
final MaterialLocalizations localizations = MaterialLocalizations.of(context);
if (_controller.length == 0) {
// 沒有tab的時候,直接返回一個高度爲TabBar的默認高度加導航指示器的高度的Container
return Container(height: _kTabHeight + widget.indicatorWeight);
}
// 聲明一個存儲tab的集合
final List<Widget> wrappedTabs = List<Widget>(widget.tabs.length);
// 爲widget.tabs中的tab添加padding,存放於wrappedTabs中
for (int i = 0; i < widget.tabs.length; i += 1) {
wrappedTabs[i] = Center(
heightFactor: 1.0,
child: Padding(
padding: widget.labelPadding ?? kTabLabelPadding,
child: KeyedSubtree(
key: _tabKeys[i],
child: widget.tabs[i])));
}
// 這個_controller是在_updateTabController()方法裏賦值的,通常不會爲null,而這裏的邏輯就是動畫效果,每次執行什麼動畫。
if (_controller != null) {
final int previousIndex = _controller.previousIndex;
// _controller.indexIsChanging通常是手動點擊或者經過 _tabController.index賦值,因此通常手動點擊會觸發此動畫,因此只是_ChangeAnimation作一次size的變化
if (_controller.indexIsChanging) {
assert(_currentIndex != previousIndex);
final Animation<double> animation = _ChangeAnimation(_controller);
wrappedTabs[_currentIndex] = _buildStyledTab(wrappedTabs[_currentIndex], true, animation);
wrappedTabs[previousIndex] = _buildStyledTab(wrappedTabs[previousIndex], false, animation);
} else {
// 作偏移動畫,主要是滑動以及點擊狀態的tab縮放的過程動畫
final int tabIndex = _currentIndex;
final Animation<double> centerAnimation = _DragAnimation(_controller, tabIndex);
wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation);
if (_currentIndex > 0) {
final int tabIndex = _currentIndex - 1;
final Animation<double> previousAnimation = ReverseAnimation(_DragAnimation(_controller, tabIndex));
wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, previousAnimation);
}
if (_currentIndex < widget.tabs.length - 1) {
final int tabIndex = _currentIndex + 1;
final Animation<double> nextAnimation = ReverseAnimation(_DragAnimation(_controller, tabIndex));
wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, nextAnimation);
}
}
}
// 爲每一個tab設置點擊事件,並設置底部外邊距爲widget.indicatorWeight
final int tabCount = widget.tabs.length;
for (int index = 0; index < tabCount; index += 1) {
wrappedTabs[index] = InkWell(
onTap: () { _handleTap(index); },
child: Padding(
padding: EdgeInsets.only(bottom: widget.indicatorWeight),
child: Stack(
children: <Widget>[
wrappedTabs[index],
Semantics(
selected: index == _currentIndex,
label: localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount))
])));
// TabBar不支持水平滑動,讓TabBar中的tab均分父空間
if (!widget.isScrollable)
wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
}
// _TabStyle稍後分析,這裏的做用是繪製指示器以及執行每一個TabBar的動畫效果
Widget tabBar = CustomPaint(
painter: _indicatorPainter,
child: _TabStyle(
animation: kAlwaysDismissedAnimation,
selected: false,
labelColor: widget.labelColor,
unselectedLabelColor: widget.unselectedLabelColor,
labelStyle: widget.labelStyle,
unselectedLabelStyle: widget.unselectedLabelStyle,
child: _TabLabelBar(
onPerformLayout: _saveTabOffsets,
children: wrappedTabs)));
// 若是TabBar支持水平滑動,讓其在SingleChildScrollView中,使其能夠由滑動效果,方向爲水平方向
if (widget.isScrollable) {
_scrollController ??= _TabBarScrollController(this);
tabBar = SingleChildScrollView(
scrollDirection: Axis.horizontal,
controller: _scrollController,
child: tabBar)
}
return tabBar;
}
複製代碼
從上面的代碼註釋中,咱們能夠了解到如下兩點源碼分析
因此下面重點講解_TabStyle,它的做用是執行動畫以達到效果,_TabStyle繼承自AnimatedWidget,一樣的只關注build的實現post
class _TabStyle extends AnimatedWidget {
...省略代碼 ...
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
final TextStyle defaultStyle = labelStyle ?? themeData.primaryTextTheme.body2;
final TextStyle defaultUnselectedStyle = unselectedLabelStyle ?? labelStyle ?? themeData.primaryTextTheme.body2;
final Animation<double> animation = listenable;
/ lerp是計算兩個數之間的線性插值的方法,能夠參考lerpDouble方法
final TextStyle textStyle = selected
? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)
: TextStyle.lerp(defaultUnselectedStyle, defaultStyle, animation.value);
final Color selectedColor =
labelColor
?? tabBarTheme.labelColor
?? themeData.primaryTextTheme.body2.color;
final Color unselectedColor =
unselectedLabelColor
?? tabBarTheme.unselectedLabelColor
?? selectedColor.withAlpha(0xB2); // 70% alpha
final Color color = selected
? Color.lerp(selectedColor, unselectedColor, animation.value)
: Color.lerp(unselectedColor, selectedColor, animation.value);
return DefaultTextStyle(
style: textStyle.copyWith(color: color),
child: IconTheme.merge(
data: IconThemeData(
size: 24.0,
color: color)
child: child ));
}
}
複製代碼
能夠看到_TabStyle實際上所作的事就是根據animation.value的值計算出textStyle以及color,並使用DefaultTextStyle賦值給child的全部text,達到切換tab時文字大小改變而圖片等其餘Widget大小不變的效果。可是這樣的效果看似沒問題,爲何會顫動呢?這多是因爲線性改變文字大小時,字體的baseline與上一次的大小並未對齊,從視覺上看起來在顫動。 那麼能不能把baseline對齊驗證下呢,遺憾的是目前來看,從widget層面是作不到的。那麼咱們就得換一個思路了。因爲Flutter提供Matrix4動畫,因此咱們能夠嘗試下這樣的方案。字體
@override
Widget build(BuildContext context) {
final ThemeData themeData = Theme.of(context);
final TabBarTheme tabBarTheme = TabBarTheme.of(context);
final TextStyle defaultStyle =
labelStyle ?? themeData.primaryTextTheme.body2;
final TextStyle defaultUnselectedStyle =
unselectedLabelStyle ?? labelStyle ?? themeData.primaryTextTheme.body2;
final Animation<double> animation = listenable;
final TextStyle textStyle =
selected ? defaultStyle : defaultUnselectedStyle;
final Color selectedColor = labelColor ??
tabBarTheme.labelColor ??
themeData.primaryTextTheme.body2.color;
final Color unselectedColor = unselectedLabelColor ??
tabBarTheme.unselectedLabelColor ??
selectedColor.withAlpha(0xB2); // 70% alpha
final Color color = selected
? Color.lerp(selectedColor, unselectedColor, animation.value)
: Color.lerp(unselectedColor, selectedColor, animation.value);
final double fontSize = selected
? lerpDouble(defaultStyle.fontSize, defaultUnselectedStyle.fontSize,
animation.value)
: lerpDouble(defaultUnselectedStyle.fontSize, defaultStyle.fontSize,
animation.value);
final double beginPercent = textStyle.fontSize /
(selected ? defaultStyle.fontSize : defaultUnselectedStyle.fontSize);
final double endPercent =
(selected ? defaultUnselectedStyle.fontSize : defaultStyle.fontSize) /
textStyle.fontSize;
return IconTheme.merge(
data: IconThemeData(
size: 24.0,
color: color,
),
child: DefaultTextStyle.merge(
textAlign: TextAlign.center,
style: textStyle.copyWith(color: color),
child: Transform(
transform: Matrix4.diagonal3(
Vector3.all(
Tween<double>(
end: endPercent,
begin: beginPercent,
).evaluate(animation),
),
),
alignment: Alignment.center,
child: child),
),
);
}
複製代碼
能夠看到基本沒有很大的變化,只是在最終build的時候使用Matrix4的動畫,看下效果。 動畫
基本能夠達到理想的效果,可是好像tab有跳動的嫌疑。這又是爲啥呢。分析這個的緣由就得回到_TabBarState的build方法裏看了,能夠看到在使用_TabStyle時,並無給他設任何的size限制,因此當_TabStyle的size更改時,必然會影響到其父Widget分size,使其一塊兒繪製。也就是說以前沒有跳動,是因爲_TabStyle的size是在一點點的變化着,並達到最終效果。而Matrix4動畫是把child看成一個總體作縮放,並不更改size,因此使用Matrix4之後,在作動畫時,_TabStyle的size根本沒有變化,而是在最終完成動畫時,瞬間縮放,真的是這樣嗎?咱們打開toggle paint看下。 ui
很清楚的看到從test1滑倒test2的時候,在結束時,test1和test2有明顯的size變化痕跡。那麼問題就變成了如何讓Matrix4動畫結束後不會發生跳動現象。雖然很遺憾的說作不到,可是咱們能夠換個思路來考慮並實現效果。
咱們已經知道Matrix4動畫結束後tab大小跳動的緣由是因爲size的瞬間改變致使的,那麼若是size一開始就肯定好會怎樣。稍微改動_TabBarState,新增List _textPainters, 在initState的時候,調用_initTextPainterList爲其初始化。_textPainters是用來存儲每個tab對應Painter的,經過Painter就能夠獲取text的size,這樣在_TabBarState的build的時候,能夠提早設置size,使其size固定而無論_TabStyle的size如何變化都不會從新繪製其父控件,這部分知識能夠參考Flutter視圖的Layout與Paint。
void _initTextPainterList() {
final bool isOnlyTabText = widget.tabs
.map<bool>((Widget tab) =>
tab is Tab && tab.icon == null && tab.child == null)
.toList()
.reduce((bool value, bool element) => value && element);
// isOnlyTabText 是當且僅當tab爲Text的時候,_textPainters纔會有值,由於動畫只對text作縮放
if (isOnlyTabText) {
final TextStyle defaultLabelStyle = widget.labelStyle ?? Theme.of(context).primaryTextTheme.body2;
final TextStyle defaultUnselectedLabelStyle = widget.unselectedLabelStyle ?? Theme.of(context).primaryTextTheme.body2;
final TextStyle defaultStyle = defaultLabelStyle.fontSize >= defaultUnselectedLabelStyle.fontSize ? defaultLabelStyle : defaultUnselectedLabelStyle;
_textPainters = widget.tabs.map<TextPainter>((Widget tab) {
return TextPainter(
textDirection: TextDirection.ltr,
text: TextSpan(
text: tab is Tab ? tab.text ?? '' : '',
style: defalutStyle));
}).toList();
} else
_textPainters = null;
}
複製代碼
而後在_TabBarState的build方法裏使用_textPainters
@override
Widget build(BuildContext context) {
... 省略代碼...
for (int i = 0; i < widget.tabs.length; i += 1) {
wrappedTabs[i] = Center(
heightFactor: 1.0,
child: Padding(
padding: padding,
child: KeyedSubtree(
key: _tabKeys[i],
child: widget.tabs[i]))
);
if (isOnlyTabText) {
_textPainters[i].layout();
wrappedTabs[i] = Container(
width: _textPainters[i].width + padding.horizontal,
child: wrappedTabs[i]);
}
}
... 省略代碼...
}
複製代碼
這樣再看下最終的效果,仍是能夠接受的。
雖然經過上面的一步步分析,改進,最終咱們達到了咱們想要的效果,可是這樣修改有瑕疵的(對比官方)
因此若是TabBar只有Text,這是一個很是完美的方案,惋惜現實並不是如此。 當我還不熟悉源碼的時候,看到官方的這樣顫動的效果實現,就忍不住問下難道他們不會用Matrix4動畫嗎?在考慮TabBar普遍實用性和更多的擴展性上,原先的設計無疑是最佳的。我想Flutter的開發者確定也注意到了這些,而毫無疑問他們放棄了使用Matrix4。雖然實現不是很困難,可是正如上面分析的,咱們已經知道它的瑕疵,而且是沒法或者說須要大力氣才能改變的現狀,因此我認爲在這裏放棄Matrix4是合理的。
若是必定要修復顫動的問題,目前來看重構TabBar是更好的選擇。
[本文源碼](https://github.com/Dpuntu/TabBar)
本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。@Dpuntu