Flutter實現酷狗流暢Tabbar效果
「這是我參與2022首次更文挑戰的第1天,活動詳情檢視:2022首次更文挑戰」。
在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
,通過GlobalKey
的currentContext
物件獲取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
// ...此處省略部分程式碼...
// 可以看到指示器是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
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
_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- 通過Tabbar中的
didChangeDependencies和
didUpdateWidget生命週期,更新指示器;
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- 然後重點就在指示器
_IndicatorPainter```如何進行繪製了。
實現步驟
通過理解Flutter Tabbar的實現思路,大體跟我們預想的差不多。不過官方繼承了Flex來計算Offset和size,實現起來很優雅。所以我也不班門弄斧了,直接改動官方的Tabbar就可以了。
- 建立KuGouTabbar,複製官方程式碼,修改引用,刪除無關的類,只保留Tabbar相關的程式碼。
- 重點修改
_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
// _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);
} } ```
- 如上,指示器的寬度我們根據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痛點問題為主。 希望一起共勉!!!