如何利用Flutter實現(xiàn)酷狗流暢Tabbar效果
前言
在2021年末,酷狗發(fā)布了最新版11.0.0版本,這是一次重大的UI重構(gòu),更新完打開著實讓我耳目一新。在原有風(fēng)格上,整個App變得更加清爽,流暢。其中Tabbar的風(fēng)格讓我非常感興趣,如果用Flutter來實現(xiàn),或許是一個很有趣的事情。
效果圖


分析效果
研究酷狗Tabbar的動畫可以發(fā)現(xiàn),默認狀態(tài)下在當(dāng)前Tab的中心處展示圓點,滑動時的效果拆分成兩個以下部分:
- 從單個Tab A的中心根據(jù)X軸平移到Tab B的中心位置;
- 指示器的長度從圓點變長,再縮短為圓點。其中最大長度是可變的,跟兩個Tab的大小和距離都有關(guān)系;
- 指示器雖然依賴Tab的size和offset來變換,但和Tab卻基本是同一時間渲染的,整個過程非常順滑;
- 總的來說,酷狗的效果就是改變了指示器的渲染動畫而已。
開發(fā)思路
從上面的分析可以明確,指示器的滑動效果一定跟每個Tab的size和offset相關(guān)。那在Flutter中,獲取渲染信息我們馬上能想到GlobalKey,通過GlobalKey的currentContext對象獲取Rander信息,但這必須在視圖渲染完成后才能獲取,也就是說Tab渲染完才能開始計算并渲染指示器。很顯然不符合體驗要求,同時頻繁使用GlobalKey也會導(dǎo)致性能較差。
轉(zhuǎn)變思路,我們需要在Tab渲染的不斷把信息傳給指示器,然后更新指示器,這種方式自然想到了CustomPainter【之前寫了很多Canvas的控件,都是根據(jù)傳入的值進行繪制,從而實現(xiàn)控件的變化了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,以便后續(xù)獲取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跟我們的預(yù)想一致,那如何把繪制的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;
@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之間的寬度差
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;
@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<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中的didChangeDependencies和didUpdateWidget生命周期,更新指示器;
@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如何進行繪制了。
實現(xiàn)步驟
通過理解Flutter Tabbar的實現(xiàn)思路,大體跟我們預(yù)想的差不多。不過官方繼承了Flex來計算Offset和size,實現(xiàn)起來很優(yōu)雅。所以我也不班門弄斧了,直接改動官方的Tabbar就可以了。
- 創(chuàng)建KuGouTabbar,復(fù)制官方代碼,修改引用,刪除無關(guān)的類,只保留Tabbar相關(guān)的代碼。

2. 重點修改_IndicatorPainter,根據(jù)我們的需求來繪制指示器。在painter方法中,我們可以通過controller拿到當(dāng)前tab的index以及animation!.value, 我們模擬下切換的過程,當(dāng)tab從第0個移到第1個,動畫的值從0變成1,然后動畫走到0.5時,tab的index會從0突然變?yōu)?,指示器應(yīng)該是先變長,然后在動畫走到0.5時,再變短。因此動畫0.5之前,我們用動畫的value-index作為指示器縮放的倍數(shù),指示器不斷增大;動畫0.5之后,用index-value作為縮放倍數(shù),不斷縮小。
final double index = controller.index.toDouble();
final double value = controller.animation!.value;
/// 改動 ltr為false,表示索引還是0,動畫執(zhí)行未超過50%;ltr為true,表示索引變?yōu)?,動畫執(zhí)行超過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來決定是放大還是縮小倍數(shù),可以得出公式: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());而指示器接收縮放倍數(shù)的前提還需要計算指示器最大的寬度,并且上面是根據(jù)動畫的0.5作為最大的寬度,也就是移動到一半的時候,指示器應(yīng)該達到最大寬度。因此指示器最大的寬度是需要??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;
}
/// 接收上面代碼分析中傳入的倍數(shù) 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);
}
}- 如上,指示器的寬度我們根據(jù)controller切換時的index和動畫值進行轉(zhuǎn)化,實現(xiàn)寬度的變化。而Offset的最小值和最大值分別是切換前后兩個Tab的中心點,這里應(yīng)該做下相應(yīng)的的限制,然后傳給Rect.fromLTWH。
【由于時間和精力問題,我并沒有去做這一步的實現(xiàn),而且酷狗那邊動畫跟滑動邏輯的關(guān)系需要UI給出具體的公式,才能百分百還原?!?/p>
最后就是加多一個參數(shù),讓業(yè)務(wù)方傳入指示器的最小寬度。
/// 指示器的最小寬度 final double indicatorMinWidth;
業(yè)務(wù)使用
在上面我們已經(jīng)把簡單的動畫效果改完了,接下來就是傳入圓角的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;
@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);
}
}- 調(diào)用非常簡單,跟原來官方代碼一模一樣。
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: "動態(tài)"), 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效果,就分享到這里啦,重點在于實現(xiàn)步驟的第2、3步,涉及到一些簡單的數(shù)學(xué)知識。說說心得吧,F(xiàn)lutter UI層面的問題,其實技術(shù)棧已經(jīng)很單一了。只要跟著官方的實現(xiàn)思路,能寫出跟其類似的代碼,把Rander層理解透徹,筆者認為已經(jīng)足夠了。往深了還是得往原生、混編、解決Flutter痛點問題為主。 希望一起共勉?。?!
實現(xiàn)源碼
https://github.com/WxqKb/KuGouTabbar.git
到此這篇關(guān)于如何利用Flutter實現(xiàn)酷狗流暢Tabbar效果的文章就介紹到這了,更多相關(guān)Flutter實現(xiàn)酷狗Tabbar效果內(nèi)容請搜索腳本之家以前的文章或繼續(xù)瀏覽下面的相關(guān)文章希望大家以后多多支持腳本之家!
相關(guān)文章
Android使用Jetpack WindowManager開發(fā)可折疊設(shè)備(過程分享)
這篇文章主要介紹了Android使用Jetpack WindowManager開發(fā)可折疊設(shè)備,本文給大家介紹的非常詳細,對大家的學(xué)習(xí)或工作具有一定的參考借鑒價值,需要的朋友參考下吧2023-11-11
Android 利用 APT 技術(shù)在編譯期生成代碼
本文主要講解Android 利用 APT 技術(shù)在編譯期生成代碼,這里提供詳細的資料,并講解如何實現(xiàn),有興趣的小伙伴可以參考下2016-08-08
詳解Android獲取系統(tǒng)內(nèi)核版本的方法與實現(xiàn)代碼
這篇文章主要介紹了詳解Android獲取系統(tǒng)內(nèi)核版本的方法與實現(xiàn)代碼的相關(guān)資料,這里提供了具體實現(xiàn)獲取內(nèi)核的方法,需要的朋友可以參考下2017-07-07
Android之Viewpager+Fragment實現(xiàn)懶加載示例
本篇文章主要介紹了Android之Viewpager+Fragment實現(xiàn)懶加載示例,具有一定的參考價值,感興趣的小伙伴們可以參考一下。2017-03-03
淺談Android中關(guān)于靜態(tài)變量(static)的使用問題
本文主要介紹了Android中關(guān)于靜態(tài)變量(static)的使用問題,具有一定的參考作用,下面跟著小編一起來看下吧2017-01-01

