您好,登錄后才能下訂單哦!
本文小編為大家詳細介紹“怎么用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,然后更新繪制,理論上這樣做是完全行得通的。
為了驗證我的思路,我開始研究官方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; @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如何進行繪制了。
通過理解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作為縮放倍數,不斷縮小。
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; } /// 接收上面代碼分析中傳入的倍數 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給出具體的公式,才能百分百還原。】
最后就是加多一個參數,讓業務方傳入指示器的最小寬度。
/// 指示器的最小寬度 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; @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); } }
調用非常簡單,跟原來官方代碼一模一樣。
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, ), ), );
讀到這里,這篇“怎么用Flutter實現酷狗流暢Tabbar效果”文章已經介紹完畢,想要掌握這篇文章的知識點還需要大家自己動手實踐使用過才能領會,如果想了解更多相關內容的文章,歡迎關注億速云行業資訊頻道。
免責聲明:本站發布的內容(圖片、視頻和文字)以原創、轉載和分享為主,文章觀點不代表本網站立場,如果涉及侵權請聯系站長郵箱:is@yisu.com進行舉報,并提供相關證據,一經查實,將立刻刪除涉嫌侵權內容。