美文网首页
android技术流:用Flutter来实现酷狗流畅Tabbar

android技术流:用Flutter来实现酷狗流畅Tabbar

作者: android不是安卓 | 来源:发表于2022-05-13 12:38 被阅读0次

    在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进行绘制;
    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就可以了。

    1.创建KuGouTabbar,复制官方代码,修改引用,删除无关的类,只保留Tabbar相关的代码。

    1. 重点修改_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);
       }
    }
    

    3.如上,指示器的宽度我们根据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,
        ),
      ),
    );
    

    写在最后

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

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

    相关文章

      网友评论

          本文标题:android技术流:用Flutter来实现酷狗流畅Tabbar

          本文链接:https://www.haomeiwen.com/subject/ifeeurtx.html