從源碼分析TabBar的文字抖動問題

引言

app開發中老是會遇到使用TabBar的狀況,不論是原生仍是混合,在TabBar的使用上都會稍顯複雜,那在Flutter中TabBar又是怎樣的呢?本文將從如下幾個方面講解TabBargit

  • Flutter中如何使用TabBar
  • 使用TabBar的問題
  • 從源碼分析問題
  • 如何解決問題
  • 思考與後續

Flutter中如何使用TabBar

Flutter使用TabBar,主要仍是考慮controller的實現。一般使用默認的DefaultTabController就能夠達到效果,也能夠自定義TabController。github

  • 使用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足夠平常使用,兩者效果無明顯差異。 看下效果 bash

tabbar-test.gif

使用TabBar的問題

仔細看下能夠發現上面的動畫效果有文字顫動的問題,而若是不使用labelStyle和unselectedLabelStyle,咱們沒法感知到TabBar的文字在顫動,可是當你一旦使用的時候,你會明顯的感覺到問題的存在,難道Flutter的動畫實現有問題?Flutter應該不會有這麼大的失誤,畢竟都release了。問題出在哪呢,此時得去看看TabBar的具體實現才能知曉。app

從源碼分析問題根源

看下源碼,TabBar是繼承自StatefulWidget,因此得看_TabBarState的build方法。ide

@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的實現post

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看下。 ui

toggle paint.gif

很清楚的看到從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]);
      }
    }
   ... 省略代碼...
}
複製代碼

這樣再看下最終的效果,仍是能夠接受的。

最終效果

思考與後續

雖然經過上面的一步步分析,改進,最終咱們達到了咱們想要的效果,可是這樣修改有瑕疵的(對比官方)

  • 如何保證Text之外的Widget不會被放大縮小
  • 有多個Text的時候,該怎麼實現

因此若是TabBar只有Text,這是一個很是完美的方案,惋惜現實並不是如此。 當我還不熟悉源碼的時候,看到官方的這樣顫動的效果實現,就忍不住問下難道他們不會用Matrix4動畫嗎?在考慮TabBar普遍實用性和更多的擴展性上,原先的設計無疑是最佳的。我想Flutter的開發者確定也注意到了這些,而毫無疑問他們放棄了使用Matrix4。雖然實現不是很困難,可是正如上面分析的,咱們已經知道它的瑕疵,而且是沒法或者說須要大力氣才能改變的現狀,因此我認爲在這裏放棄Matrix4是合理的。

若是必定要修復顫動的問題,目前來看重構TabBar是更好的選擇。

[本文源碼](https://github.com/Dpuntu/TabBar)

本文版權屬於再惠研發團隊,歡迎轉載,轉載請保留出處。@Dpuntu

相關文章
相關標籤/搜索