Flutter实现酷狗流畅Tabbar效果

语言: CN / TW / HK

「这是我参与2022首次更文挑战的第1天,活动详情查看:2022首次更文挑战」。

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

效果图

酷狗的.gif

我的.gif

分析效果

研究酷狗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进行绘制; ``` dart Widget build(BuildContext context) {

// ...此处省略部分代码...

final List wrappedTabs = List.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方法; dart class _TabLabelBar extends Flex { _TabLabelBar({ Key? key, List children = const [], 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;

@override RenderFlex createRenderObject(BuildContext context) { // 查看下_TabLabelBarRenderer return _TabLabelBarRenderer( direction: direction, mainAxisAlignment: mainAxisAlignment, mainAxisSize: mainAxisSize, crossAxisAlignment: crossAxisAlignment, textDirection: getEffectiveTextDirection(context)!, verticalDirection: verticalDirection, onPerformLayout: onPerformLayout, ); }

@override void updateRenderObject(BuildContext context, _TabLabelBarRenderer renderObject) { super.updateRenderObject(context, renderObject); renderObject.onPerformLayout = onPerformLayout; } } 查看真实的渲染对象:_TabLabelBarRenderer,在performLayout中返回渲染的size和offset,并通过TabBar传入的_saveTabOffsets方法保存到_indicatorPainter中;_saveTabOffsets尤为重要,把Tabbar的渲染位移通知给Painter,从而让Painter可以轻松算出tab之间的宽度差 dart class _TabLabelBarRenderer extends RenderFlex { _TabLabelBarRenderer({ List? 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;

@override void 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 xOffsets = []; 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生命周期,更新指示器; dart @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.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相关的代码。 image.png
  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作为缩放倍数,不断缩小。

```dart 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的。请看下面代码:

``` dart class _IndicatorPainter extends CustomPainter { ......此处省略部分代码......

void saveTabOffsets(List? 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; }

/// 接收上面代码分析中传入的倍数 scale Rect 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);

/// 改动,算出指示器的最大宽度,记得*2
double 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给出具体的公式,才能百分百还原。】

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

业务使用

在上面我们已经把简单的动画效果改完了,接下来就是传入圆角的indicator、最小宽度indicatorMinWidth,就可以正常使用啦。 - 圆角的指示器,我直接上源码 ``` dart 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;

@override Decoration? 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); }

@override Decoration? 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, ); }

@override Path 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;

@override void 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); } } - 调用非常简单,跟原来官方代码一模一样。 dart 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哦!