引言
app开发中总是会遇到使用TabBar的情况,不管是原生还是混合,在TabBar的使用上都会稍显复杂,那在Flutter中TabBar又是怎样的呢?本文将从以下几个方面讲解TabBar
- Flutter中如何使用TabBar
- 使用TabBar的问题
- 从源码分析问题
- 如何解决问题
- 思考与后续
Flutter中如何使用TabBar
Flutter使用TabBar,主要还是考虑controller的实现。通常使用默认的DefaultTabController就可以达到效果,也可以自定义TabController。
- 使用DefaultTabController
@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()
])));
}
- 使用TabController
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足够日常使用,二者效果无明显差别。
看下效果
使用TabBar的问题
仔细看下可以发现上面的动画效果有文字颤动的问题,而如果不使用labelStyle和unselectedLabelStyle,我们无法感知到TabBar的文字在颤动,但是当你一旦使用的时候,你会明显的感受到问题的存在,难道Flutter的动画实现有问题?Flutter应该不会有这么大的失误,毕竟都release了。问题出在哪呢,此时得去看看TabBar的具体实现才能知晓。
从源码分析问题根源
看下源码,TabBar是继承自StatefulWidget,所以得看_TabBarState的build方法。
@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;
}
从上面的代码注释中,我们可以了解到以下两点
- TabBar的各种操作对应的动画
- TabBar的点击事件及动画执行的位置
所以下面重点讲解_TabStyle,它的作用是执行动画以达到效果,_TabStyle继承自AnimatedWidget,同样的只关注build的实现
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动画,所以我们可以尝试下这样的方案。
如何解决问题
- 首先,得了解下Matrix4
这不是Flutter特有的,本文主题不在于此,限于篇幅,感兴趣的可以参考Matrix4矩阵变换了解Matrix4 - 然后,确定使用Matrix4的哪种实现方法以及在哪里使用
通过分析TabBar原先的效果,明显我们只需要使用缩放的方法就可以了。而且之前也分析了TabBar的 动画实现过程是在_TabStyle中实现,所以我们完全可以使用Matrix4来代替原先的实现 - 最后,看下_TabStyle的build实现
@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看下。
很清楚的看到从test1滑倒test2的时候,在结束时,test1和test2有明显的size变化痕迹。那么问题就变成了如何让Matrix4动画结束后不会发生跳动现象。虽然很遗憾的说做不到,但是我们可以换个思路来考虑并实现效果。
我们已经知道Matrix4动画结束后tab大小跳动的原因是由于size的瞬间改变导致的,那么如果size一开始就确定好会怎样。稍微改动_TabBarState,新增List<TextPainter> _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]);
}
}
... 省略代码...
}
这样再看下最终的效果,还是可以接受的。
思考与后续
虽然通过上面的一步步分析,改进,最终我们达到了我们想要的效果,但是这样修改有瑕疵的(对比官方)
- 如何保证Text以外的Widget不会被放大缩小
- 有多个Text的时候,该怎么实现
所以如果TabBar只有Text,这是一个非常完美的方案,可惜现实并非如此。
当我还不熟悉源码的时候,看到官方的这样颤动的效果实现,就忍不住问下难道他们不会用Matrix4动画吗?在考虑TabBar广泛实用性和更多的扩展性上,原先的设计无疑是最佳的。我想Flutter的开发者肯定也注意到了这些,而毫无疑问他们放弃了使用Matrix4。虽然实现不是很困难,但是正如上面分析的,我们已经知道它的瑕疵,并且是无法或者说需要大力气才能改变的现状,所以我认为在这里放弃Matrix4是合理的。
如果一定要修复颤动的问题,目前来看重构TabBar是更好的选择。