The introduction

Whether native or hybrid, the use of TabBar is a bit complicated. What about TabBar in Flutter? This article explains TabBar from the following perspectives

  • How to use TabBar in Flutter
  • Problems with TabBar
  • Analyze problems from source code
  • How to solve the problem
  • Thinking and follow-up

How to use TabBar in Flutter

Flutter uses TabBar, mainly for controller implementation. You can usually use the default DefaultTabController to do this, or you can customize the TabController.

  • Using 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: Color.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() ]))); }Copy the code
  • Using 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) {
    returnColumn(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: Color.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() ])) ]); }}Copy the code

TabController is usually used for better control of TabBar, listening for events, etc. Otherwise DefaultTabController is sufficient for daily use and the effect of the two is not significantly different. Look at the effect

Problems with TabBar

If you look carefully, you can find that the above animation effect has the problem of text vibration. If labelStyle and unselectedLabelStyle are not used, we cannot perceive the text vibration of TabBar, but once you use it, you will obviously feel the existence of the problem. Is there a problem with Flutter’s animation implementation? The Flutter should not have made such a big mistake, after all it was released. To find out what the problem is, you have to look at TabBar’s implementation.

Analyze the root of the problem from the source code

TabBar inherits from StatefulWidget, so look at the _TabBarState build method.

  @override
  Widget build(BuildContext context) {
    final MaterialLocalizations localizations = MaterialLocalizations.of(context);
    if(_controller.length == 0) {// When there is no TAB, return a Container with the height of the default TabBar plus the height of the navigation indicatorreturnContainer(height: _kTabHeight + widget.indicatorWeight); } final List<Widget> wrappedTabs = List<Widget>(widget.tabs. Length); // Add the padding to the TAB in widget.tabs and store it in wrappedTabsfor(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]))); } // This _controller is assigned in the _updateTabController() method, which is usually not null. The logic here is the animation effect, which is performed each time.if(_controller ! = null) { final int previousIndex = _controller.previousIndex; / / _controller indexIsChanging generally is manually click or by _tabController index assignment, so generally manually click will trigger the animation, so just _ChangeAnimation do a size changeif(_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);
      } elseFinal int tabIndex = _currentIndex; 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); }}} // Set the click event for each TAB and set the bottom margin to 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 does not support horizontal sliding, so that tabs in TabBar divide the parent space equallyif(! widget.isScrollable) wrappedTabs[index] = Expanded(child: wrappedTabs[index]); } // _TabStyle 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))); // If TabBar supports horizontal sliding, place it in SingleChildScrollView so that it can be slid horizontallyif(widget.isScrollable) { _scrollController ?? = _TabBarScrollController(this); tabBar = SingleChildScrollView( scrollDirection: Axis.horizontal, controller: _scrollController, child: tabBar) }return tabBar;
  }
Copy the code

From the code comments above, we can learn two things

  • TabBar’s various operations correspond to animations
  • TabBar click events and animation execution location

So I’ll focus on _TabStyle, which executes animations for effects, and _TabStyle inherits from the AnimatedWidget, again focusing only on the build implementation

class _TabStyle extends AnimatedWidget { ... Omit code... @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? 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);returnDefaultTextStyle( style: textStyle.copyWith(color: color), child: IconTheme.merge( data: IconThemeData( size: 24.0, color: color) child: child); }}Copy the code

You can see that all _TabStyle actually does is calculate the textStyle and color from the value of animation.value, and assign all text values to the child using DefaultTextStyle. The text size changes while images and other widgets remain the same when you switch tabs. But the effect seems to work, so why does it vibrate? This may be due to the fact that the baseline is not aligned with the previous size when the text size is linearly changed, and it looks shaky visually. Would it be possible to validate baseline alignment? Unfortunately, this is not currently possible at the widget level. Then we have to think differently. Since Flutter provides Matrix4 animation, we can try something like this.

How to solve the problem

  • First, you need to know about Matrix4. This is not specific to Flutter. This is not the topic of this article
  • Then, to determine which implementation of Matrix4 to use and where to use it, by analyzing TabBar’s original effects, it is clear that we only need to use the scaling method. TabBar animation implementation is implemented in _TabStyle, so we can use Matrix4 instead of the original implementation
  • Finally, take a look at the build implementation of _TabStyle

  @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;

    returnIconTheme. 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), ), ); }Copy the code

You can see that there are basically no major changes, just use the animations of Matrix4 during the final build to see what the effects look like.

Basic can achieve the desired effect, but it seems that the TAB has the suspicion of jumping. And that’s why. When _TabStyle is used, there is no size limit on it, so when the size of _TabStyle changes, it will affect the size of its parent Widget and cause it to be drawn together. That is to say, it did not jump before, because the size of _TabStyle is changing a little bit, and achieve the final effect. A Matrix4 animation is used to scale the child as a whole, and does not change the size, so when using a Matrix4 animation, the _TabStyle size does not change at all, but when the final animation is completed, the size is immediately scaled. Is this true? So let’s go to Toggle Paint.

Test1 and test2 have obvious size changes at the end of the slide. The problem then becomes how to make the Matrix4 animation complete without bouncing. I’m sorry to say it can’t be done, but we can think about it in a different way and achieve the effect.

We already know that the TAB size jumps when the Matrix4 animation ends due to an instantaneous change in size, so what if the size had been fixed in the first place? Change _TabBarState slightly, add List _textPainters, initialize _initTextPainterList when initState is used. _textPainters are used to store the corresponding Painter of each TAB, through which the size of text can be obtained, so that the size can be set in advance during the build of _TabBarState. The Layout and Paint of the Flutter view will not be redrawn regardless of the size of the _TabStyle.

  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 the _textPainters only have a value if and only if TAB is Text, because the animation only scales Textif (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;
  }

Copy the code

Then use _textPainters in the build method of _TabBarState

@override Widget build(BuildContext context) { ... Omit code...for(int i = 0; i < widget.tabs.length; I += 1) {wrappedTabs[I] = Center(heightFactor: 1.0, Child: Padding(Padding: Padding, Child: KeyedSubtree) _tabKeys[i], child: widget.tabs[i])) );if(isOnlyTabText) { _textPainters[i].layout(); wrappedTabs[i] = Container( width: _textPainters[i].width + padding.horizontal, child: wrappedTabs[i]); }}... Omit code... }Copy the code

This is acceptable if you look at the final result.

Thinking and follow-up

Although through the above step by step analysis and improvement, we finally achieved the desired effect, but such modification has defects (compared with the official).

  • How do I ensure that widgets other than Text don’t get bigger or smaller
  • How do I implement this when I have multiple texts

So if TabBar only had Text, this would be a perfect solution, but it’s not. When I am not familiar with the source code, I can not help but ask if they will not use Matrix4 animation when I see the official effect of such vibration implementation. The original design was definitely the best considering TabBar’s wide availability and greater scalability. I’m sure the developers of Flutter noticed this too, and no doubt they abandoned the use of Matrix4. Although the implementation is not very difficult, as analyzed above, we already know its flaws and it is impossible or requires a lot of effort to change the status quo, so I think it is reasonable to abandon Matrix4 here.

If you have to fix the tremor problem, reconstructing the TabBar is a better choice at this point.

[this paper source] (https://github.com/Dpuntu/TabBar)

The copyright of this article belongs to Zaihui RESEARCH and development team, welcome to reprint, reprint please reserve source. @Dpuntu