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

栏目: Android · 发布时间: 5年前

内容简介:app开发中总是会遇到使用TabBar的情况,不管是原生还是混合,在TabBar的使用上都会稍显复杂,那在Flutter中TabBar又是怎样的呢?本文将从以下几个方面讲解TabBarFlutter使用TabBar,主要还是考虑controller的实现。通常使用默认的DefaultTabController就可以达到效果,也可以自定义TabController。通常为了更好的控制TabBar,监听事件等才使用TabController,否则DefaultTabController足够日常使用,二者效果无

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的文字抖动问题

使用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的动画,看下效果。

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

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

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

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

我们已经知道Matrix4动画结束后tab大小跳动的原因是由于size的瞬间改变导致的,那么如果size一开始就确定好会怎样。稍微改动_TabBarState,新增List _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]);
      }
    }
   ... 省略代码...
}
复制代码

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

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

思考与后续

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

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

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

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

本文源码

本文版权属于再惠研发团队,欢迎转载,转载请保留出处。 @Dpuntu


以上就是本文的全部内容,希望本文的内容对大家的学习或者工作能带来一定的帮助,也希望大家多多支持 码农网

查看所有标签

猜你喜欢:

本站部分资源来源于网络,本站转载出于传递更多信息之目的,版权归原作者或者来源机构所有,如转载稿涉及版权问题,请联系我们

苹果的味道

苹果的味道

袁烨 / 2012-1 / 59.80元

《苹果的味道:iPad商务应用每一天》是一本介绍iPad商务应用的书。编者从App Store丰富的应用程序库中选取了MobileRSS、Pocket Informant HD、iWork、MindPad、乐顺笔记本等二十多款经典的应用程序,从商务资讯的获取、商务日程安排、商务文档的制作和演示等八个商务应用方面,以图文并茂的方式介绍了这些应用程序在商务工作中的使用方法。通过《苹果的味道:iPad商......一起来看看 《苹果的味道》 这本书的介绍吧!

随机密码生成器
随机密码生成器

多种字符组合密码

MD5 加密
MD5 加密

MD5 加密工具

正则表达式在线测试
正则表达式在线测试

正则表达式在线测试