本文小编为大家详细介绍“怎么用Flutter实现酷狗流畅Tabbar效果”,内容详细,步骤清晰,细节处理妥当,希望这篇“怎么用Flutter实现酷狗流畅Tabbar效果”文章能帮助大家解决疑惑,下面跟着小编的思路慢慢深入,一起来学习新知识吧。

分析效果

研究酷狗Tabbar的动画可以发现,默认状态下在当前Tab的中心处展示圆点,滑动时的效果拆分成两个以下部分:

从单个Tab A的中心根据X轴平移到Tab B的中心位置;

指示器的长度从圆点变长,再缩短为圆点。其中最大长度是可变的,跟两个Tab的大小和距离都有关系;

指示器虽然依赖Tab的size和offset来变换,但和Tab却基本是同一时间渲染的,整个过程非常顺滑;

总的来说,酷狗的效果就是改变了指示器的渲染动画而已。

开发思路

从上面的分析可以明确,指示器的滑动效果一定跟每个Tab的size和offset相关。那在Flutter中,获取渲染信息我们马上能想到GlobalKey,通过GlobalKey的currentContext对象获取Rander信息,但这必须在视图渲染完成后才能获取,也就是说Tab渲染完才能开始计算并渲染指示器。很显然不符合体验要求,同时频繁使用GlobalKey也会导致性能较差。

转变思路,我们需要在Tab渲染的不断把信息传给指示器,然后更新指示器,这种方式自然想到了CustomPainter。在Tab updateWidget的时候,不断把Rander的信息传给画笔Painter,然后更新绘制,理论上这样做是完全行得通的。

Flutter Tabbar 解析源码

为了验证我的思路,我开始研究官方Tabbar是如何写的:

进入TabBar类,直接查看build方法,可以看到为每个Tab加入了Globalkey,然后指示器用CustomPaint进行绘制;

Widgetbuild(BuildContextcontext){//...此处省略部分代码...finalList<Widget>wrappedTabs=List<Widget>.generate(widget.tabs.length,(intindex){constdoubleverticalAdjustment=(_kTextAndIconTabHeight-_kTabHeight)/2.0;EdgeInsetsGeometry?adjustedPadding;//这里为tab加入Globalkey,以便后续获取Tab的渲染信息if(widget.tabs[index]isPreferredSizeWidget){finalPreferredSizeWidgettab=widget.tabs[index]asPreferredSizeWidget;if(widget.tabHasTextAndIcon&&tab.preferredSize.height==_kTabHeight){if(widget.labelPadding!=null||tabBarTheme.labelPadding!=null){adjustedPadding=(widget.labelPadding??tabBarTheme.labelPadding!).add(constEdgeInsets.symmetric(vertical:verticalAdjustment));}else{adjustedPadding=constEdgeInsets.symmetric(vertical:verticalAdjustment,horizontal:16.0);}}}//...此处省略部分代码...//可以看到指示器是CustomPaint对象WidgettabBar=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,),),);

绘制指示器用CustomPaint跟我们的预想一致,那如何把绘制的size和offset传进去呢。我们来看_TabLabelBar继承于Flex,而Flex又继承自MultiChildRenderObjectWidget,重写其createRenderObject方法;

class_TabLabelBarextendsFlex{_TabLabelBar({Key?key,List<Widget>children=const<Widget>[],requiredthis.onPerformLayout,}):super(key:key,children:children,direction:Axis.horizontal,mainAxisSize:MainAxisSize.max,mainAxisAlignment:MainAxisAlignment.start,crossAxisAlignment:CrossAxisAlignment.center,verticalDirection:VerticalDirection.down,);final_LayoutCallbackonPerformLayout;@overrideRenderFlexcreateRenderObject(BuildContextcontext){//查看下_TabLabelBarRendererreturn_TabLabelBarRenderer(direction:direction,mainAxisAlignment:mainAxisAlignment,mainAxisSize:mainAxisSize,crossAxisAlignment:crossAxisAlignment,textDirection:getEffectiveTextDirection(context)!,verticalDirection:verticalDirection,onPerformLayout:onPerformLayout,);}@overridevoidupdateRenderObject(BuildContextcontext,_TabLabelBarRendererrenderObject){super.updateRenderObject(context,renderObject);renderObject.onPerformLayout=onPerformLayout;}}

查看真实的渲染对象:_TabLabelBarRenderer,在performLayout中返回渲染的size和offset,并通过TabBar传入的_saveTabOffsets方法保存到_indicatorPainter中;_saveTabOffsets尤为重要,把Tabbar的渲染位移通知给Painter,从而让Painter可以轻松算出tab之间的宽度差

class_TabLabelBarRendererextendsRenderFlex{_TabLabelBarRenderer({List<RenderBox>?children,requiredAxisdirection,requiredMainAxisSizemainAxisSize,requiredMainAxisAlignmentmainAxisAlignment,requiredCrossAxisAlignmentcrossAxisAlignment,requiredTextDirectiontextDirection,requiredVerticalDirectionverticalDirection,requiredthis.onPerformLayout,}):assert(onPerformLayout!=null),assert(textDirection!=null),super(children:children,direction:direction,mainAxisSize:mainAxisSize,mainAxisAlignment:mainAxisAlignment,crossAxisAlignment:crossAxisAlignment,textDirection:textDirection,verticalDirection:verticalDirection,);_LayoutCallbackonPerformLayout;@overridevoidperformLayout(){super.performLayout();//xOffsetswillcontainchildCount+1values,givingtheoffsetsofthe//leadingedgeofthefirsttabasthefirstvalue,oftheleadingedgeof//theeachsubsequenttabaseachsubsequentvalue,andofthetrailing//edgeofthelasttabasthelastvalue.RenderBox?child=firstChild;finalList<double>xOffsets=<double>[];while(child!=null){finalFlexParentDatachildParentData=child.parentData!asFlexParentData;xOffsets.add(childParentData.offset.dx);assert(child.parentData==childParentData);child=childParentData.nextSibling;}assert(textDirection!=null);switch(textDirection!){caseTextDirection.rtl:xOffsets.insert(0,size.width);break;caseTextDirection.ltr:xOffsets.add(size.width);break;}onPerformLayout(xOffsets,textDirection!,size.width);}}

通过Tabbar中的didChangeDependencies和didUpdateWidget生命周期,更新指示器;

@overridevoiddidChangeDependencies(){super.didChangeDependencies();assert(debugCheckHasMaterial(context));finalTabBarThemetabBarTheme=TabBarTheme.of(context);_updateTabController();_initIndicatorPainter(adjustedPadding,tabBarTheme);}@overridevoiddidUpdateWidget(KuGouTabBaroldWidget){super.didUpdateWidget(oldWidget);finalTabBarThemetabBarTheme=TabBarTheme.of(context);if(widget.controller!=oldWidget.controller){_updateTabController();_initIndicatorPainter(adjustedPadding,tabBarTheme);}elseif(widget.indicatorColor!=oldWidget.indicatorColor||widget.indicatorWeight!=oldWidget.indicatorWeight||widget.indicatorSize!=oldWidget.indicatorSize||widget.indicator!=oldWidget.indicator){_initIndicatorPainter(adjustedPadding,tabBarTheme);}if(widget.tabs.length>oldWidget.tabs.length){finalintdelta=widget.tabs.length-oldWidget.tabs.length;_tabKeys.addAll(List<GlobalKey>.generate(delta,(intn)=>GlobalKey()));}elseif(widget.tabs.length<oldWidget.tabs.length){_tabKeys.removeRange(widget.tabs.length,oldWidget.tabs.length);}}

然后重点就在指示器_IndicatorPainter如何进行绘制了。

实现步骤

通过理解Flutter Tabbar的实现思路,大体跟我们预想的差不多。不过官方继承了Flex来计算Offset和size,实现起来很优雅。所以我也不班门弄斧了,直接改动官方的Tabbar就可以了。

创建KuGouTabbar,复制官方代码,修改引用,删除无关的类,只保留Tabbar相关的代码。

2. 重点修改_IndicatorPainter,根据我们的需求来绘制指示器。在painter方法中,我们可以通过controller拿到当前tab的index以及animation!.value, 我们模拟下切换的过程,当tab从第0个移到第1个,动画的值从0变成1,然后动画走到0.5时,tab的index会从0突然变为1,指示器应该是先变长,然后在动画走到0.5时,再变短。因此动画0.5之前,我们用动画的value-index作为指示器缩放的倍数,指示器不断增大;动画0.5之后,用index-value作为缩放倍数,不断缩小。

finaldoubleindex=controller.index.toDouble();finaldoublevalue=controller.animation!.value;///改动ltr为false,表示索引还是0,动画执行未超过50%;ltr为true,表示索引变为1,动画执行超过50%finalboolltr=index>value;finalintfrom=(ltr?value.floor():value.ceil()).clamp(0,maxTabIndex);finalintto=(ltr?from+1:from-1).clamp(0,maxTabIndex);///改动通过ltr来决定是放大还是缩小倍数,可以得出公式:ltr?(index-value):(value-index)finalRectfromRect=indicatorRect(size,from,ltr?(index-value):(value-index));///改动finalRecttoRect=indicatorRect(size,to,ltr?(index-value):(value-index));_currentRect=Rect.lerp(fromRect,toRect,(value-from).abs());

而指示器接收缩放倍数的前提还需要计算指示器最大的宽度,并且上面是根据动画的0.5作为最大的宽度,也就是移动到一半的时候,指示器应该达到最大宽度。因此指示器最大的宽度是需要✖️2的。请看下面代码:

class_IndicatorPainterextendsCustomPainter{......此处省略部分代码......voidsaveTabOffsets(List<double>?tabOffsets,TextDirection?textDirection){_currentTabOffsets=tabOffsets;_currentTextDirection=textDirection;}//_currentTabOffsets[index]istheoffsetofthestartedgeofthetabatindex,and//_currentTabOffsets[_currentTabOffsets.length]istheendedgeofthelasttab.intgetmaxTabIndex=>_currentTabOffsets!.length-2;doublecenterOf(inttabIndex){assert(_currentTabOffsets!=null);assert(_currentTabOffsets!.isNotEmpty);assert(tabIndex>=0);assert(tabIndex<=maxTabIndex);return(_currentTabOffsets![tabIndex]+_currentTabOffsets![tabIndex+1])/2.0;}///接收上面代码分析中传入的倍数scaleRectindicatorRect(SizetabBarSize,inttabIndex,doublescale){assert(_currentTabOffsets!=null);assert(_currentTextDirection!=null);assert(_currentTabOffsets!.isNotEmpty);assert(tabIndex>=0);assert(tabIndex<=maxTabIndex);doubletabLeft,tabRight,tabWidth=0;switch(_currentTextDirection!){caseTextDirection.rtl:tabLeft=_currentTabOffsets![tabIndex+1];tabRight=_currentTabOffsets![tabIndex];break;caseTextDirection.ltr:tabLeft=_currentTabOffsets![tabIndex];tabRight=_currentTabOffsets![tabIndex+1];break;}///改动,通过GlobalKey计算出渲染的文本的宽度tabWidth=tabKeys[tabIndex].currentContext!.size!.width;finaldoubledelta=((tabRight-tabLeft)-tabWidth)/2.0;tabLeft+=delta;tabRight-=delta;finalEdgeInsetsinsets=indicatorPadding.resolve(_currentTextDirection);///改动,算出指示器的最大宽度,记得*2doublemaxLen=(tabRight-tabLeft+insets.horizontal)*2;doubleres=scale==0?minWidth:maxLen*(scale<0.5?scale:1-scale);///改动finalRectrect=Rect.fromLTWH(tabLeft+tabWidth/2-minWidth/2,0.0,res>minWidth?res:minWidth,tabBarSize.height);if(!(rect.size>=insets.collapsedSize)){throwFlutterError('indicatorPaddinginsetsshouldbelessthanTabSize\n''RectSize:${rect.size},Insets:${insets.toString()}',);}returninsets.deflateRect(rect);}}

如上,指示器的宽度我们根据controller切换时的index和动画值进行转化,实现宽度的变化。而Offset的最小值和最大值分别是切换前后两个Tab的中心点,这里应该做下相应的的限制,然后传给Rect.fromLTWH。

【由于时间和精力问题,我并没有去做这一步的实现,而且酷狗那边动画跟滑动逻辑的关系需要UI给出具体的公式,才能百分百还原。】

最后就是加多一个参数,让业务方传入指示器的最小宽度。

///指示器的最小宽度finaldoubleindicatorMinWidth;业务使用

在上面我们已经把简单的动画效果改完了,接下来就是传入圆角的indicator、最小宽度indicatorMinWidth,就可以正常使用啦。

圆角的指示器,我直接上源码

import'package:flutter/material.dart';classRRecTabIndicatorextendsDecoration{constRRecTabIndicator({this.borderSide=constBorderSide(width:2.0,color:Colors.white),this.insets=EdgeInsets.zero,this.radius=0,this.color=Colors.white});finaldoubleradius;finalColorcolor;finalBorderSideborderSide;finalEdgeInsetsGeometryinsets;@overrideDecoration?lerpFrom(Decoration?a,doublet){if(aisRRecTabIndicator){returnRRecTabIndicator(borderSide:BorderSide.lerp(a.borderSide,borderSide,t),insets:EdgeInsetsGeometry.lerp(a.insets,insets,t)!,);}returnsuper.lerpFrom(a,t);}@overrideDecoration?lerpTo(Decoration?b,doublet){if(bisRRecTabIndicator){returnRRecTabIndicator(borderSide:BorderSide.lerp(borderSide,b.borderSide,t),insets:EdgeInsetsGeometry.lerp(insets,b.insets,t)!,);}returnsuper.lerpTo(b,t);}@override_UnderlinePaintercreateBoxPainter([VoidCallback?onChanged]){return_UnderlinePainter(this,onChanged);}Rect_indicatorRectFor(Rectrect,TextDirectiontextDirection){finalRectindicator=insets.resolve(textDirection).deflateRect(rect);returnRect.fromLTWH(indicator.left,indicator.bottom-borderSide.width,indicator.width,borderSide.width,);}@overridePathgetClipPath(Rectrect,TextDirectiontextDirection){returnPath()..addRect(_indicatorRectFor(rect,textDirection));}}class_UnderlinePainterextendsBoxPainter{_UnderlinePainter(this.decoration,VoidCallback?onChanged):super(onChanged);finalRRecTabIndicatordecoration;@overridevoidpaint(Canvascanvas,Offsetoffset,ImageConfigurationconfiguration){finalRectrect=offset&configuration.size!;finalTextDirectiontextDirection=configuration.textDirection!;finalRectindicator=decoration._indicatorRectFor(rect,textDirection);finalPaintpaint=decoration.borderSide.toPaint()..strokeCap=StrokeCap.square..color=decoration.color;finalRRectrRect=RRect.fromRectAndRadius(indicator,Radius.circular(decoration.radius));canvas.drawRRect(rRect,paint);}}

调用非常简单,跟原来官方代码一模一样。

Scaffold(appBar:AppBar(//HerewetakethevaluefromtheMyHomePageobjectthatwascreatedby//theApp.buildmethod,anduseittosetourappbartitle.title:Text(widget.title),bottom:KuGouTabBar(tabs:const[Tab(text:"音乐"),Tab(text:"动态"),Tab(text:"语文")],//labelPadding:EdgeInsets.symmetric(horizontal:8),controller:_tabController,//indicatorSize:TabBarIndicatorSize.label,//isScrollable:true,padding:EdgeInsets.zero,indicator:constRRecTabIndicator(radius:4,insets:EdgeInsets.only(bottom:5)),indicatorMinWidth:6,),),);

读到这里,这篇“怎么用Flutter实现酷狗流畅Tabbar效果”文章已经介绍完毕,想要掌握这篇文章的知识点还需要大家自己动手实践使用过才能领会,如果想了解更多相关内容的文章,欢迎关注亿速云行业资讯频道。