在2021年末,酷狗发布了最新版11.0.0版本,这是一次重大的UI重构,更新完打开着实让我耳目一新。在原有风格上,整个App变得更加清爽,流畅。其中Tabbar的风格让我非常感兴趣,如果用Flutter来实现,或许是一个很有趣的事情。

效果图

分析效果

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

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

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

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

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

开发思路

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

转变思路,我们需要在Tab渲染的不断把信息传给指示器,然后更新指示器,这种方式自然想到了CustomPainter之前写了很多Canvas的控件,都是根据传入的值进行绘制,从而实现控件的变化了layout类】。在Tab updateWidget的时候,不断把Rander的信息传给画笔Painter,然后更新绘制,理论上这样做是完全行得通的。

Flutter Tabbar 解析源码

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

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

Widget build(BuildContext context) {// ...此处省略部分代码...final List<Widget> wrappedTabs = List<Widget>.generate(widget.tabs.length, (int index) {const double verticalAdjustment = (_kTextAndIconTabHeight - _kTabHeight)/2.0;EdgeInsetsGeometry? adjustedPadding;// 这里为tab加入Globalkey,以便后续获取Tab的渲染信息if (widget.tabs[index] is PreferredSizeWidget) {final PreferredSizeWidget tab = widget.tabs[index] as PreferredSizeWidget;if (widget.tabHasTextAndIcon && tab.preferredSize.height == _kTabHeight) {if (widget.labelPadding != null || tabBarTheme.labelPadding != null) {adjustedPadding = (widget.labelPadding ?? tabBarTheme.labelPadding!).add(const EdgeInsets.symmetric(vertical: verticalAdjustment));}else {adjustedPadding = const EdgeInsets.symmetric(vertical: verticalAdjustment, horizontal: 16.0);}}}// ...此处省略部分代码...// 可以看到指示器是CustomPaint对象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,),),);
  • 绘制指示器用CustomPaint跟我们的预想一致,那如何把绘制的size和offset传进去呢。我们来看_TabLabelBar继承于Flex,而Flex又继承自MultiChildRenderObjectWidget,重写其createRenderObject方法;

class _TabLabelBar extends Flex {_TabLabelBar({Key? key,List<Widget> children = const <Widget>[],required this.onPerformLayout,}) : super(key: key,children: children,direction: Axis.horizontal,mainAxisSize: MainAxisSize.max,mainAxisAlignment: MainAxisAlignment.start,crossAxisAlignment: CrossAxisAlignment.center,verticalDirection: VerticalDirection.down,);final _LayoutCallback onPerformLayout;@overrideRenderFlex createRenderObject(BuildContext context) {// 查看下_TabLabelBarRendererreturn _TabLabelBarRenderer(direction: direction,mainAxisAlignment: mainAxisAlignment,mainAxisSize: mainAxisSize,crossAxisAlignment: crossAxisAlignment,textDirection: getEffectiveTextDirection(context)!,verticalDirection: verticalDirection,onPerformLayout: onPerformLayout,);}@overridevoid updateRenderObject(BuildContext context, _TabLabelBarRenderer renderObject) {super.updateRenderObject(context, renderObject);renderObject.onPerformLayout = onPerformLayout;}
}

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

class _TabLabelBarRenderer extends RenderFlex {_TabLabelBarRenderer({List<RenderBox>? children,required Axis direction,required MainAxisSize mainAxisSize,required MainAxisAlignment mainAxisAlignment,required CrossAxisAlignment crossAxisAlignment,required TextDirection textDirection,required VerticalDirection verticalDirection,required this.onPerformLayout,}) : assert(onPerformLayout != null),assert(textDirection != null),super(children: children,direction: direction,mainAxisSize: mainAxisSize,mainAxisAlignment: mainAxisAlignment,crossAxisAlignment: crossAxisAlignment,textDirection: textDirection,verticalDirection: verticalDirection,);_LayoutCallback onPerformLayout;@overridevoid performLayout() {super.performLayout();// xOffsets will contain childCount+1 values, giving the offsets of the// leading edge of the first tab as the first value, of the leading edge of// the each subsequent tab as each subsequent value, and of the trailing// edge of the last tab as the last value.RenderBox? child = firstChild;final List<double> xOffsets = <double>[];while (child != null) {final FlexParentData childParentData = child.parentData! as FlexParentData;xOffsets.add(childParentData.offset.dx);assert(child.parentData == childParentData);child = childParentData.nextSibling;}assert(textDirection != null);switch (textDirection!) {case TextDirection.rtl:xOffsets.insert(0, size.width);break;case TextDirection.ltr:xOffsets.add(size.width);break;}onPerformLayout(xOffsets, textDirection!, size.width);}
}
  • 通过Tabbar中的didChangeDependenciesdidUpdateWidget生命周期,更新指示器;

@override
void didChangeDependencies() {super.didChangeDependencies();assert(debugCheckHasMaterial(context));final TabBarTheme tabBarTheme = TabBarTheme.of(context);_updateTabController();_initIndicatorPainter(adjustedPadding, tabBarTheme);
}@override
void didUpdateWidget(KuGouTabBar oldWidget) {super.didUpdateWidget(oldWidget);final TabBarTheme tabBarTheme = TabBarTheme.of(context);if (widget.controller != oldWidget.controller) {_updateTabController();_initIndicatorPainter(adjustedPadding, tabBarTheme);} else if (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) {final int delta = widget.tabs.length - oldWidget.tabs.length;_tabKeys.addAll(List<GlobalKey>.generate(delta, (int n) => GlobalKey()));} else if (widget.tabs.length < oldWidget.tabs.length) {_tabKeys.removeRange(widget.tabs.length, oldWidget.tabs.length);}
}
  • 然后重点就在指示器_IndicatorPainter如何进行绘制了。

实现步骤

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

  1. 创建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作为缩放倍数,不断缩小。

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

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

class _IndicatorPainter extends CustomPainter {......此处省略部分代码......void saveTabOffsets(List<double>? tabOffsets, TextDirection? textDirection) {_currentTabOffsets = tabOffsets;_currentTextDirection = textDirection;}// _currentTabOffsets[index] is the offset of the start edge of the tab at index, and// _currentTabOffsets[_currentTabOffsets.length] is the end edge of the last tab.int get maxTabIndex => _currentTabOffsets!.length - 2;double centerOf(int tabIndex) {assert(_currentTabOffsets != null);assert(_currentTabOffsets!.isNotEmpty);assert(tabIndex >= 0);assert(tabIndex <= maxTabIndex);return (_currentTabOffsets![tabIndex] + _currentTabOffsets![tabIndex + 1]) /2.0;}/// 接收上面代码分析中传入的倍数 scaleRect indicatorRect(Size tabBarSize, int tabIndex, double scale) {assert(_currentTabOffsets != null);assert(_currentTextDirection != null);assert(_currentTabOffsets!.isNotEmpty);assert(tabIndex >= 0);assert(tabIndex <= maxTabIndex);double tabLeft, tabRight, tabWidth = 0;switch (_currentTextDirection!) {case TextDirection.rtl:tabLeft = _currentTabOffsets![tabIndex + 1];tabRight = _currentTabOffsets![tabIndex];break;case TextDirection.ltr:tabLeft = _currentTabOffsets![tabIndex];tabRight = _currentTabOffsets![tabIndex + 1];break;}/// 改动,通过GlobalKey计算出渲染的文本的宽度tabWidth = tabKeys[tabIndex].currentContext!.size!.width;final double delta = ((tabRight - tabLeft) - tabWidth) / 2.0;tabLeft += delta;tabRight -= delta;final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection);/// 改动,算出指示器的最大宽度,记得*2double maxLen = (tabRight - tabLeft + insets.horizontal) * 2;double res =scale == 0 ? minWidth : maxLen * (scale < 0.5 ? scale : 1 - scale);/// 改动final Rect rect = Rect.fromLTWH(tabLeft + tabWidth / 2 - minWidth / 2, 0.0, res > minWidth ? res : minWidth, tabBarSize.height);if (!(rect.size >= insets.collapsedSize)) {throw FlutterError('indicatorPadding insets should be less than Tab Size\n''Rect Size : ${rect.size}, Insets: ${insets.toString()}',);}return insets.deflateRect(rect);}
}
  1. 如上,指示器的宽度我们根据controller切换时的index和动画值进行转化,实现宽度的变化。而Offset的最小值和最大值分别是切换前后两个Tab的中心点,这里应该做下相应的的限制,然后传给Rect.fromLTWH。

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

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

/// 指示器的最小宽度
final double indicatorMinWidth;

业务使用

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

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

import 'package:flutter/material.dart';class RRecTabIndicator extends Decoration {const RRecTabIndicator({this.borderSide = const BorderSide(width: 2.0, color: Colors.white),this.insets = EdgeInsets.zero,this.radius = 0,this.color = Colors.white});final double radius;final Color color;final BorderSide borderSide;final EdgeInsetsGeometry insets;@overrideDecoration? lerpFrom(Decoration? a, double t) {if (a is RRecTabIndicator) {return RRecTabIndicator(borderSide: BorderSide.lerp(a.borderSide, borderSide, t),insets: EdgeInsetsGeometry.lerp(a.insets, insets, t)!,);}return super.lerpFrom(a, t);}@overrideDecoration? lerpTo(Decoration? b, double t) {if (b is RRecTabIndicator) {return RRecTabIndicator(borderSide: BorderSide.lerp(borderSide, b.borderSide, t),insets: EdgeInsetsGeometry.lerp(insets, b.insets, t)!,);}return super.lerpTo(b, t);}@override_UnderlinePainter createBoxPainter([VoidCallback? onChanged]) {return _UnderlinePainter(this, onChanged);}Rect _indicatorRectFor(Rect rect, TextDirection textDirection) {final Rect indicator = insets.resolve(textDirection).deflateRect(rect);return Rect.fromLTWH(indicator.left,indicator.bottom - borderSide.width,indicator.width,borderSide.width,);}@overridePath getClipPath(Rect rect, TextDirection textDirection) {return Path()..addRect(_indicatorRectFor(rect, textDirection));}
}class _UnderlinePainter extends BoxPainter {_UnderlinePainter(this.decoration, VoidCallback? onChanged): super(onChanged);final RRecTabIndicator decoration;@overridevoid paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {final Rect rect = offset & configuration.size!;final TextDirection textDirection = configuration.textDirection!;final Rect indicator = decoration._indicatorRectFor(rect, textDirection);final Paint paint = decoration.borderSide.toPaint()..strokeCap = StrokeCap.square..color = decoration.color;final RRect rRect =RRect.fromRectAndRadius(indicator, Radius.circular(decoration.radius));canvas.drawRRect(rRect, paint);}
}
  • 调用非常简单,跟原来官方代码一模一样。

Scaffold(appBar: AppBar(// Here we take the value from the MyHomePage object that was created by// the App.build method, and use it to set our appbar title.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: const RRecTabIndicator(radius: 4, insets: EdgeInsets.only(bottom: 5)),indicatorMinWidth: 6,),),
);

写在最后

模仿酷狗的Tabbar效果,就分享到这里啦,重点在于实现步骤的第2、3步,涉及到一些简单的数学知识。说说心得吧,Flutter UI层面的问题,其实技术栈已经很单一了。只要跟着官方的实现思路,能写出跟其类似的代码,把Rander层理解透彻,笔者认为已经足够了。往深了还是得往原生、混编、解决Flutter痛点问题为主。希望一起共勉!!!

实现源码

记得给个Star哦!(https://github.com/WxqKb/KuGouTabbar.git)

作者:Karl_wei
链接:https://juejin.cn/post/7057168681943433246

关注我获取更多知识或者投稿

Flutter实现酷狗流畅Tabbar效果相关推荐

  1. Android耳机线控详解,蓝牙耳机按钮监听(仿酷狗线控效果)

    转载请注明出处:http://blog.csdn.net/fengyuzhengfan/article/details/46461253 当耳机的媒体按键被单击后,Android系统会发出一个广播,该 ...

  2. 仿QQ侧滑和酷狗侧滑效果

    今天来写的是关于侧滑的,说道侧滑,我们可以想导用什么来实现呢?首先肯定是系统为我们写好的DrawerLayout来写,或者用ViewGroup再加上一些手势处理,下面我来写的是自定义view继承自我们 ...

  3. 高仿酷狗音乐的卡拉OK的字幕效果

    原创,转载请注明:http://blog.csdn.net/wang9834664/article/details/8441043 最近在用酷狗音乐听音乐的时候发现字幕效果不错,手痒也试下能不能做出这 ...

  4. Android插件化开发指南——实践之Activity转场效果(仿酷狗音乐启动页)

    文章目录 1. 前言 2. Activity退出动画 2.1 简单使用 2.2 overridePendingTransition 3. 后记 1. 前言 在Android插件化开发指南--2.15 ...

  5. 仿酷狗音乐播放器开发日志七——播放列表的实现二

    转载请说明出处,谢谢~~ 一.MusicList控件     今天开始正式做播放列表控件,首先做一个CMusicListUI类作为播放器列表控件,控件继承自CListUI控件,并且添加了可分组,可扩展 ...

  6. android歌词效果,Android仿酷狗动感歌词(支持翻译和音译歌词)显示效果

    引言 该开源依赖库是乐乐音乐播放器里的一个歌词模块功能,现在把该功能模块独立出来进行优化,并弄成了一个开源依赖库,其它音乐播放器项目只要引用该库并调用接口,便可轻松实现与乐乐音乐播放器一样的动感歌词显 ...

  7. Flutter 制作一个具有酷炫液体滑动效果的酷炫入门页面

    本文主要介绍如何使用 Flutter 制作一个具有酷炫液体滑动效果的酷炫入门页面 我将向您展示如何使用 Flutter 制作一个具有酷炫液体滑动效果的酷炫入门页面,所以不用多说,让我们开始吧.在本课程 ...

  8. 实现 酷狗音乐 歌词播放效果

    今天将为大家带来 粗略版 酷狗音乐 歌词播放的效果.我们一步一步来.首先做这个是因为有一次公司项目中需要做一个汽车扫描效果的时候,想到来做这个歌词播放效果的.那么我们这次先上效果图: 好的上面的文字是 ...

  9. php手机端 调用音乐播放器,HTML_仿酷狗html5手机音乐播放器主要部分代码,HTML5作品,经测试,效果确定 - phpStudy...

    仿酷狗html5手机音乐播放器主要部分代码 HTML5作品,经测试,效果确定不错,遗憾的是只支持谷歌和苹果等以webkit核心的浏览器,网友体验的时候请使用谷歌等浏览器来运行,不然的话无法看到效果,不 ...

最新文章

  1. 字节、快手、阿里、腾讯这两年的广告推荐技术进展 | AICon
  2. pico park无法连接至远程服务器,picopark联机失败怎么办 picopark联机进不去问题解决方法...
  3. VTK:图像收缩用法实战
  4. JAVA多线程之扩展ThreadPoolExecutor
  5. 本地无法启动MySQL服务,报的错误:1067,进程意外终止---解决
  6. android+notepad教程,Android Sample学习——NotePad
  7. java的观察模式链式,design-pattern-java
  8. Canvas 通过改变渐变色渐变百分比位置做飞线效果
  9. wordpress审查元素修改php,WordPress 教程:如何通过 PHP 代码修改表结构和索引
  10. 隆重推荐:公民税权手册
  11. 【复合五点高斯-勒让德公式】
  12. 如何将lvm卷移动到另一台服务器中
  13. 代码仓库已移步http://202.119.84.104:8088/Socialbiao/bookdinner
  14. 对于人生道路的些许感慨
  15. xstart下远程报错已拒绝X11转移申请的解决方法
  16. 秒杀年费258的同款APP,微软、联想、Adobe、腾讯的这些良心产品太香了
  17. bat批处理循环执行adb命令,非科班面试之旅
  18. RE-实验吧分道扬镳/Just Click
  19. 微软服务器认证,微软。NET认证
  20. ThinkPhp5源码学习(容器)

热门文章

  1. 浅谈电气设备的绝缘在线监测与状态维修探究
  2. datatables 使用笔记,包含(iCheck.js)
  3. golang的闭包与defer
  4. 城轨交通系统中智能化管理的作用分析
  5. “Input”表示值,但在此处用作类型。是否指“类型 Input”?ts(2749)
  6. python3 识别图片中的文字早点解放眼睛
  7. Plusensor心率传感器简介
  8. RH358提供基于文件的网络存储--自动化调配基于文件的存储
  9. 【学习笔记】java中单引号和双引号有什么区别?
  10. 关于javascript的12种淫思巧技(一)