内容简介:app开发中总是会遇到使用TabBar的情况,不管是原生还是混合,在TabBar的使用上都会稍显复杂,那在Flutter中TabBar又是怎样的呢?本文将从以下几个方面讲解TabBarFlutter使用TabBar,主要还是考虑controller的实现。通常使用默认的DefaultTabController就可以达到效果,也可以自定义TabController。通常为了更好的控制TabBar,监听事件等才使用TabController,否则DefaultTabController足够日常使用,二者效果无
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 _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是更好的选择。
本文版权属于再惠研发团队,欢迎转载,转载请保留出处。 @Dpuntu
以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网
猜你喜欢:- KeyEvents与输入抖动问题
- 应付网络抖动等临时故障的重试策略
- 服务重启导致的Java服务抖动CPU占用高
- 【拒绝一问就懵】之没听说过内存抖动吧
- WebRTC视频数据统计之延时、抖动与丢包
- 详读webrtc的视频统计信息之延迟、抖动与丢包
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。
Just My Type
Simon Garfield / Profile Books / 2010-10-21 / GBP 14.99
What's your type? Suddenly everyone's obsessed with fonts. Whether you're enraged by Ikea's Verdanagate, want to know what the Beach Boys have in common with easy Jet or why it's okay to like Comic Sa......一起来看看 《Just My Type》 这本书的介绍吧!