内容简介: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的视频统计信息之延迟、抖动与丢包
本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们。