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

引言

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是更好的选择。

本文源码

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 203,456评论 5 477
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,370评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 150,337评论 0 337
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,583评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,596评论 5 365
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,572评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,936评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,595评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,850评论 1 297
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,601评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,685评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,371评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,951评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,934评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,167评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 43,636评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,411评论 2 342

推荐阅读更多精彩内容

  • 发现 关注 消息 iOS 第三方库、插件、知名博客总结 作者大灰狼的小绵羊哥哥关注 2017.06.26 09:4...
    肇东周阅读 12,016评论 4 62
  • 国庆后面两天在家学习整理了一波flutter,基本把能撸过能看到的代码都过了一遍,此文篇幅较长,建议保存(star...
    Nealyang阅读 4,337评论 1 17
  • 1.原味蔬菜汤: 改善便秘,促进伤口恢复 食材: 蔬菜类:黄豆芽、西兰花、彩椒、紫甘蓝、丝瓜、毛豆、西葫芦、西红柿...
    Bonnie21阅读 872评论 0 1
  • 为汝阳常氏宗亲共同解决族胞困难行动喝彩 常氏一家亲 甘苦共担分。 团结互帮助 恩爱暖人心。 二零一八年八月二十八日...
    龙心须言阅读 121评论 0 1
  • 1、大吵大闹潜藏着哪几个关键主题? 三个 依赖与独立 攻击 性 2、对于黏人的孩子,我们应该如何处理?成人的世...
    木木me阅读 472评论 0 0