美文网首页
从源码分析TabBar的文字抖动问题

从源码分析TabBar的文字抖动问题

作者: e618211d6873 | 来源:发表于2018-12-23 21:29 被阅读26次

    引言

    app开发中总是会遇到使用TabBar的情况,不管是原生还是混合,在TabBar的使用上都会稍显复杂,那在Flutter中TabBar又是怎样的呢?本文将从以下几个方面讲解TabBar

    • Flutter中如何使用TabBar
    • 使用TabBar的问题
    • 从源码分析问题
    • 如何解决问题
    • 思考与后续

    Flutter中如何使用TabBar

    Flutter使用TabBar,主要还是考虑controller的实现。通常使用默认的DefaultTabController就可以达到效果,也可以自定义TabController。

    • 使用DefaultTabController
      @override
      Widget build(BuildContext context) {
        return DefaultTabController(
          length: 4,
          child: Scaffold(
            appBar: AppBar(
                title: Text('TabBar'),
                bottom: TabBar(
                    indicatorSize: TabBarIndicatorSize.label,
                    indicatorColor: Colors.white,
                    indicatorWeight: 2.0,
                    isScrollable: true,
                    labelColor: Colors.white,
                    labelStyle: TextStyle(fontSize: 16.0),
                    unselectedLabelColor: Colors.white.withOpacity(0.5),
                    unselectedLabelStyle: TextStyle(fontSize: 12.0),
                    tabs: _titleList.map((text) => Tab(text: text)).toList())),
            body: TabBarView(
              children: <Widget>[ TestScreen1(), TestScreen2(),  TestScreen3(),  TestScreen4()
              ])));
      }
    
    • 使用TabController
    const List<String> _titleList = ['test 1', 'test 2', 'test 3', 'test 4'];
    
    class _DataScreenState extends State<DataPresentation> with SingleTickerProviderStateMixin {
      TabController _tabController;
    
      @override
      void dispose() {
        _tabController.dispose();
        super.dispose();
      }
    
      @override
      void initState() {
        super.initState();
        _tabController = TabController(length: _titleList.length, vsync: this);
      }
    
      @override
      Widget build(BuildContext context) {
        return Scaffold(
            appBar: AppBar(title: Text('TabBar')),
            body: _buildDataScreenBody(context));
      }
    
      Widget _buildDataScreenBody(BuildContext context) {
        return Column(children: <Widget>[
          Container(
              width: double.infinity,
              child: Align(
                  alignment: Alignment.center,
                  child: TabBar(
                      controller: _tabController,
                      indicatorSize: TabBarIndicatorSize.label,
                      indicatorColor: Colors.white,
                      indicatorWeight: 2.0,
                      isScrollable: true,
                      labelColor: Colors.white,
                      labelStyle: TextStyle(fontSize: 16.0),
                      unselectedLabelColor: Colors.white.withOpacity(0.5),
                      unselectedLabelStyle: TextStyle(fontSize: 12.0),
                      tabs: _titleList.map((text) => Tab(text: text)).toList()))),
          Expanded(
              child: TabBarView(controller: _tabController, children: [ TestScreen1(), TestScreen2(), TestScreen3(), TestScreen4()
          ]))
        ]);
      }
    }
    

    通常为了更好的控制TabBar,监听事件等才使用TabController,否则DefaultTabController足够日常使用,二者效果无明显差别。
    看下效果


    tabbar-test.gif

    使用TabBar的问题

    仔细看下可以发现上面的动画效果有文字颤动的问题,而如果不使用labelStyle和unselectedLabelStyle,我们无法感知到TabBar的文字在颤动,但是当你一旦使用的时候,你会明显的感受到问题的存在,难道Flutter的动画实现有问题?Flutter应该不会有这么大的失误,毕竟都release了。问题出在哪呢,此时得去看看TabBar的具体实现才能知晓。

    从源码分析问题根源

    看下源码,TabBar是继承自StatefulWidget,所以得看_TabBarState的build方法。

      @override
      Widget build(BuildContext context) {
        final MaterialLocalizations localizations = MaterialLocalizations.of(context);
        if (_controller.length == 0) {
          // 没有tab的时候,直接返回一个高度为TabBar的默认高度加导航指示器的高度的Container
          return Container(height: _kTabHeight + widget.indicatorWeight);
        }
    
        // 声明一个存储tab的集合
        final List<Widget> wrappedTabs = List<Widget>(widget.tabs.length);
        // 为widget.tabs中的tab添加padding,存放于wrappedTabs中
        for (int i = 0; i < widget.tabs.length; i += 1) {
          wrappedTabs[i] = Center(
            heightFactor: 1.0,
            child: Padding(
              padding: widget.labelPadding ?? kTabLabelPadding,
              child: KeyedSubtree(
                key: _tabKeys[i],
                child: widget.tabs[i])));
        }
        // 这个_controller是在_updateTabController()方法里赋值的,一般不会为null,而这里的逻辑就是动画效果,每次执行什么动画。
        if (_controller != null) {
          final int previousIndex = _controller.previousIndex;
          // _controller.indexIsChanging一般是手动点击或者通过 _tabController.index赋值,所以一般手动点击会触发此动画,所以只是_ChangeAnimation做一次size的变化
          if (_controller.indexIsChanging) {
            assert(_currentIndex != previousIndex);
            final Animation<double> animation = _ChangeAnimation(_controller);
            wrappedTabs[_currentIndex] = _buildStyledTab(wrappedTabs[_currentIndex], true, animation);
            wrappedTabs[previousIndex] = _buildStyledTab(wrappedTabs[previousIndex], false, animation);
          } else {
           // 做偏移动画,主要是滑动以及点击状态的tab缩放的过程动画
            final int tabIndex = _currentIndex;
            final Animation<double> centerAnimation = _DragAnimation(_controller, tabIndex);
            wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], true, centerAnimation);
            if (_currentIndex > 0) {
              final int tabIndex = _currentIndex - 1;
              final Animation<double> previousAnimation = ReverseAnimation(_DragAnimation(_controller, tabIndex));
              wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, previousAnimation);
            }
            if (_currentIndex < widget.tabs.length - 1) {
              final int tabIndex = _currentIndex + 1;
              final Animation<double> nextAnimation = ReverseAnimation(_DragAnimation(_controller, tabIndex));
              wrappedTabs[tabIndex] = _buildStyledTab(wrappedTabs[tabIndex], false, nextAnimation);
            }
          }
        }
    
        // 为每个tab设置点击事件,并设置底部外边距为widget.indicatorWeight
        final int tabCount = widget.tabs.length;
        for (int index = 0; index < tabCount; index += 1) {
          wrappedTabs[index] = InkWell(
            onTap: () { _handleTap(index); },
            child: Padding(
              padding: EdgeInsets.only(bottom: widget.indicatorWeight),
              child: Stack(
                children: <Widget>[
                  wrappedTabs[index],
                  Semantics(
                    selected: index == _currentIndex,
                    label: localizations.tabLabel(tabIndex: index + 1, tabCount: tabCount))
                ])));
          // TabBar不支持水平滑动,让TabBar中的tab均分父空间
          if (!widget.isScrollable)
            wrappedTabs[index] = Expanded(child: wrappedTabs[index]);
        }
    
        // _TabStyle稍后分析,这里的作用是绘制指示器以及执行每个TabBar的动画效果
        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)));
        
        // 如果TabBar支持水平滑动,让其在SingleChildScrollView中,使其可以由滑动效果,方向为水平方向
        if (widget.isScrollable) {
          _scrollController ??= _TabBarScrollController(this);
          tabBar = SingleChildScrollView(
            scrollDirection: Axis.horizontal,
            controller: _scrollController,
            child: tabBar)
        }
        return tabBar;
      }
    

    从上面的代码注释中,我们可以了解到以下两点

    • TabBar的各种操作对应的动画
    • TabBar的点击事件及动画执行的位置

    所以下面重点讲解_TabStyle,它的作用是执行动画以达到效果,_TabStyle继承自AnimatedWidget,同样的只关注build的实现

    class _TabStyle extends AnimatedWidget {
      ...省略代码 ...
    
      @override
      Widget build(BuildContext context) {
        final ThemeData themeData = Theme.of(context);
        final TabBarTheme tabBarTheme = TabBarTheme.of(context);
    
        final TextStyle defaultStyle = labelStyle ?? themeData.primaryTextTheme.body2;
        final TextStyle defaultUnselectedStyle = unselectedLabelStyle ?? labelStyle ?? themeData.primaryTextTheme.body2;
        final Animation<double> animation = listenable;
        / lerp是计算两个数之间的线性插值的方法,可以参考lerpDouble方法
        final TextStyle textStyle = selected
          ? TextStyle.lerp(defaultStyle, defaultUnselectedStyle, animation.value)
          : TextStyle.lerp(defaultUnselectedStyle, defaultStyle, animation.value);
        final Color selectedColor =
            labelColor
             ?? tabBarTheme.labelColor
             ?? themeData.primaryTextTheme.body2.color;
        final Color unselectedColor =
            unselectedLabelColor
            ?? tabBarTheme.unselectedLabelColor
            ?? selectedColor.withAlpha(0xB2); // 70% alpha
        final Color color = selected
          ? Color.lerp(selectedColor, unselectedColor, animation.value)
          : Color.lerp(unselectedColor, selectedColor, animation.value);
    
        return DefaultTextStyle(
          style: textStyle.copyWith(color: color),
          child: IconTheme.merge(
            data: IconThemeData(
              size: 24.0,
              color: color)
            child: child ));
      }
    }
    

    可以看到_TabStyle实际上所做的事就是根据animation.value的值计算出textStyle以及color,并使用DefaultTextStyle赋值给child的所有text,达到切换tab时文字大小改变而图片等其他Widget大小不变的效果。但是这样的效果看似没问题,为什么会颤动呢?这可能是由于线性改变文字大小时,字体的baseline与上一次的大小并未对齐,从视觉上看起来在颤动。
    那么能不能把baseline对齐验证下呢,遗憾的是目前来看,从widget层面是做不到的。那么我们就得换一个思路了。由于Flutter提供Matrix4动画,所以我们可以尝试下这样的方案。

    如何解决问题

    • 首先,得了解下Matrix4
      这不是Flutter特有的,本文主题不在于此,限于篇幅,感兴趣的可以参考Matrix4矩阵变换了解Matrix4
    • 然后,确定使用Matrix4的哪种实现方法以及在哪里使用
      通过分析TabBar原先的效果,明显我们只需要使用缩放的方法就可以了。而且之前也分析了TabBar的 动画实现过程是在_TabStyle中实现,所以我们完全可以使用Matrix4来代替原先的实现
    • 最后,看下_TabStyle的build实现
    
      @override
      Widget build(BuildContext context) {
        final ThemeData themeData = Theme.of(context);
        final TabBarTheme tabBarTheme = TabBarTheme.of(context);
    
        final TextStyle defaultStyle =
            labelStyle ?? themeData.primaryTextTheme.body2;
        final TextStyle defaultUnselectedStyle =
            unselectedLabelStyle ?? labelStyle ?? themeData.primaryTextTheme.body2;
        final Animation<double> animation = listenable;
        final TextStyle textStyle =
            selected ? defaultStyle : defaultUnselectedStyle;
        final Color selectedColor = labelColor ??
            tabBarTheme.labelColor ??
            themeData.primaryTextTheme.body2.color;
        final Color unselectedColor = unselectedLabelColor ??
            tabBarTheme.unselectedLabelColor ??
            selectedColor.withAlpha(0xB2); // 70% alpha
        final Color color = selected
            ? Color.lerp(selectedColor, unselectedColor, animation.value)
            : Color.lerp(unselectedColor, selectedColor, animation.value);
        final double fontSize = selected
            ? lerpDouble(defaultStyle.fontSize, defaultUnselectedStyle.fontSize,
                animation.value)
            : lerpDouble(defaultUnselectedStyle.fontSize, defaultStyle.fontSize,
                animation.value);
        final double beginPercent = textStyle.fontSize /
            (selected ? defaultStyle.fontSize : defaultUnselectedStyle.fontSize);
        final double endPercent =
            (selected ? defaultUnselectedStyle.fontSize : defaultStyle.fontSize) /
                textStyle.fontSize;
    
        return IconTheme.merge(
          data: IconThemeData(
            size: 24.0,
            color: color,
          ),
          child: DefaultTextStyle.merge(
            textAlign: TextAlign.center,
            style: textStyle.copyWith(color: color),
            child: Transform(
                transform: Matrix4.diagonal3(
                  Vector3.all(
                    Tween<double>(
                      end: endPercent,
                      begin: beginPercent,
                    ).evaluate(animation),
                  ),
                ),
                alignment: Alignment.center,
                child: child),
          ),
        );
      }
    

    可以看到基本没有很大的变化,只是在最终build的时候使用Matrix4的动画,看下效果。


    效果图.gif

    基本可以达到理想的效果,但是好像tab有跳动的嫌疑。这又是为啥呢。分析这个的原因就得回到_TabBarState的build方法里看了,可以看到在使用_TabStyle时,并没有给他设任何的size限制,所以当_TabStyle的size更改时,必然会影响到其父Widget分size,使其一起绘制。也就是说之前没有跳动,是由于_TabStyle的size是在一点点的变化着,并达到最终效果。而Matrix4动画是把child当作一个整体做缩放,并不更改size,所以使用Matrix4以后,在做动画时,_TabStyle的size根本没有变化,而是在最终完成动画时,瞬间缩放,真的是这样吗?我们打开toggle paint看下。


    toggle paint.gif

    很清楚的看到从test1滑倒test2的时候,在结束时,test1和test2有明显的size变化痕迹。那么问题就变成了如何让Matrix4动画结束后不会发生跳动现象。虽然很遗憾的说做不到,但是我们可以换个思路来考虑并实现效果。

    我们已经知道Matrix4动画结束后tab大小跳动的原因是由于size的瞬间改变导致的,那么如果size一开始就确定好会怎样。稍微改动_TabBarState,新增List<TextPainter> _textPainters, 在initState的时候,调用_initTextPainterList为其初始化。_textPainters是用来存储每一个tab对应Painter的,通过Painter就可以获取text的size,这样在_TabBarState的build的时候,可以提前设置size,使其size固定而不管_TabStyle的size如何变化都不会重新绘制其父控件,这部分知识可以参考Flutter视图的Layout与Paint

      void _initTextPainterList() {
        final bool isOnlyTabText = widget.tabs
            .map<bool>((Widget tab) =>
                tab is Tab && tab.icon == null && tab.child == null)
            .toList()
            .reduce((bool value, bool element) => value && element);
        // isOnlyTabText 是当且仅当tab为Text的时候,_textPainters才会有值,因为动画只对text做缩放
        if (isOnlyTabText) {
          final TextStyle defaultLabelStyle = widget.labelStyle ?? Theme.of(context).primaryTextTheme.body2;
          final TextStyle defaultUnselectedLabelStyle =  widget.unselectedLabelStyle ?? Theme.of(context).primaryTextTheme.body2;
          final TextStyle defaultStyle = defaultLabelStyle.fontSize >= defaultUnselectedLabelStyle.fontSize ? defaultLabelStyle : defaultUnselectedLabelStyle;
    
          _textPainters = widget.tabs.map<TextPainter>((Widget tab) {
            return TextPainter(
              textDirection: TextDirection.ltr,
              text: TextSpan(
                text: tab is Tab ? tab.text ?? '' : '',
                style: defalutStyle));
          }).toList();
        } else
          _textPainters = null;
      }
    
    

    然后在_TabBarState的build方法里使用_textPainters

     @override
      Widget build(BuildContext context) {
       ... 省略代码...
        for (int i = 0; i < widget.tabs.length; i += 1) {
          wrappedTabs[i] = Center(
            heightFactor: 1.0,
            child: Padding(
              padding: padding,
              child: KeyedSubtree(
                key: _tabKeys[i],
                child: widget.tabs[i]))
          );
          if (isOnlyTabText) {
            _textPainters[i].layout();
            wrappedTabs[i] = Container(
                width: _textPainters[i].width + padding.horizontal,
                child: wrappedTabs[i]);
          }
        }
       ... 省略代码...
    }
    

    这样再看下最终的效果,还是可以接受的。


    最终效果.gif

    思考与后续

    虽然通过上面的一步步分析,改进,最终我们达到了我们想要的效果,但是这样修改有瑕疵的(对比官方)

    • 如何保证Text以外的Widget不会被放大缩小
    • 有多个Text的时候,该怎么实现

    所以如果TabBar只有Text,这是一个非常完美的方案,可惜现实并非如此。
    当我还不熟悉源码的时候,看到官方的这样颤动的效果实现,就忍不住问下难道他们不会用Matrix4动画吗?在考虑TabBar广泛实用性和更多的扩展性上,原先的设计无疑是最佳的。我想Flutter的开发者肯定也注意到了这些,而毫无疑问他们放弃了使用Matrix4。虽然实现不是很困难,但是正如上面分析的,我们已经知道它的瑕疵,并且是无法或者说需要大力气才能改变的现状,所以我认为在这里放弃Matrix4是合理的。

    如果一定要修复颤动的问题,目前来看重构TabBar是更好的选择。

    本文源码

    相关文章

      网友评论

          本文标题:从源码分析TabBar的文字抖动问题

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