TabBar是UI中非常常用的一个组件,Flutter提供的TabBar几乎可以满足我们大部分的业务需求,而且实现非常简单,我们可以仅用几行代码,就完成一个Tab滑动效果。

关于TabBar的基本使用,我这里就不讲解了,不熟悉的朋友可以去Dojo里面好好体验一下。

下面我们针对TabBar在平时的开发中遇到的一些问题,来看下如何解决。

抖动问题

首先,我们来看下TabBar的抖动问题,这个问题发生在我们设置labelStyle和unselectedLabelStyle的字体大小不一致时,这个需求其实也很常见,当我们选中一个Tab时,当然希望选中的标题能够放大,突出一些,但是Flutter的TabBar居然会在滑动过程中抖动,开始以为是Debug包的问题,后来发现Release也一样。

Flutter的Issue中,其实已经有这样的问题了,地址如下:

https://github.com/flutter/flutter/issues/24505

不过到目前为止,这个问题也没修复,可能在老外的设计中,重来没有这种设计吧。不过Issue中也提到了很多方案来修复这个问题,其中比较好的一个方案,就是通过修改源码来实现,在TabBar源码的_TabStyle的build函数中,将实现改为下面的方案。

  1. ///根据前后字体大小计算缩放倍率
  2. final double _magnification = labelStyle!.fontSize! / unselectedLabelStyle!.fontSize!;
  3. final double _scale = (selected ? lerpDouble(_magnification, 1, animation.value) : lerpDouble(1, _magnification, animation.value))!;
  4. return DefaultTextStyle(
  5. style: textStyle.copyWith(
  6. color: color,
  7. fontSize: unselectedLabelStyle!.fontSize,
  8. ),
  9. child: IconTheme.merge(
  10. data: IconThemeData(
  11. size: 24.0,
  12. color: color,
  13. ),
  14. child: Transform.scale(
  15. scale: _scale,
  16. child: child,
  17. ),
  18. ),
  19. );

这个方案的确可以修复这个问题,不过却需要修改源码,所以,有一些使用成本,那么有没有其它方案呢,其实,Issue中已经给出了问题的来源,实际上就是Text在计算Scala的过程中,由于Baseline不对齐导致的抖动,所以,我们可以换一种思路,将labelStyle和unselectedLabelStyle的字体大小设置成一样的,这样就不会抖动啦。(MDZZ)

当然,这样的话需求就被我们做没了。。。

其实,我们是将Scala的效果,放到外面来实现,在TabBar的tabs中,我们将滑动百分比传入,借助隐式动画来实现Scala效果,这不就避免了抖动问题吗?

AnimatedScale(
  scale: 1 + progress * 0.3,
  duration: const Duration(milliseconds: 100),
  child: Text(tabName),
),

是不是柳暗花明又一村,很简单就解决了这个问题。

indicator

indicator是TabBar中另一个磨人的小妖精,由于indicator的存在,TabBar成了设计师自由发挥的重灾区,可以效果信手拈来,虽然Flutter提供了很完善的接口来给开发者创建indicator,但是也架不住一些设计师的奇思妙想。

下面我们来看下几种比较常见的indicator实现方案。

indicator是一个Decoration,Flutter中关于Decoration的继承关系如下所示。

FlutterComponent最佳实践之TabbarIndicator - 图1

Shape indicator

UnderlineTabIndicator是Tab的默认实现,我们可以修改为ShapeDecoration,这样就可以实现一个简单的方框indicator。

indicator: ShapeDecoration(
  shape: RoundedRectangleBorder(
    borderRadius: BorderRadius.circular(8),
  ),
  color: Colors.cyan.shade200,
)

实现效果如下。

FlutterComponent最佳实践之TabbarIndicator - 图2

这样我们可以实现很多Shape风格的Indicator,借助ShapeDecoration,可以实现纯色、渐变等多种颜色的风格。

indicatorWeight & indicatorPadding

这两个参数用于控制indicator的尺寸,代码如下所示。

indicatorWeight: 4,
indicatorPadding: EdgeInsets.symmetric(vertical: 8),

如果你想要indicator在垂直距离上更接近,那么可以使用indicatorPadding参数,如果你想让indicator更细,那么可以使用indicatorWeight参数。

自定义Indicator

永远逃不掉自定义。

先把系统默认实现的UnderlineTabIndicator Copy过来,这是我们自定义的模板。

看完源码你就懂了,最后的BoxPainter,就是我们绘制Indicator的核心,在这里根据Offset和ImageConfiguration,就可以拿到当前Indicator的参数,就可以进行绘制了。

例如我们最简单的,把Indicator绘制成一个圆,实际上只需要修改最后的draw函数,代码如下所示。

class _UnderlinePainter extends BoxPainter {
  _UnderlinePainter(this.decoration, VoidCallback? onChanged)
      : assert(decoration != null),
        super(onChanged);

  final QDTabIndicator decoration;
  final Paint _paint = Paint()
    ..color = Colors.red
    ..style = PaintingStyle.fill;
  final radius = 4.0;

  @override
  void paint(Canvas canvas, Offset offset, ImageConfiguration configuration) {
    assert(configuration != null);
    assert(configuration.size != null);
    final Rect rect = offset & configuration.size!;
    canvas.drawCircle(
      Offset(rect.bottomCenter.dx, rect.bottomCenter.dy - radius),
      radius,
      _paint,
    );
  }
}

效果如图所示。

FlutterComponent最佳实践之TabbarIndicator - 图3

再来一个:

var width = 20.0;
var height = 4.0;
var bottomMargin = 10.0;

final centerX = configuration.size!.width/ 2 + offset.dx;
final bottom = configuration.size!.height - bottomMargin;
final halfWidth = width / 2;
canvas.drawRRect(
  RRect.fromLTRBR(
    centerX - halfWidth,
    bottom - height,
    centerX + halfWidth,
    bottom,
    Radius.circular(radius),
  ),
  _paint,
);

效果如图所示。

FlutterComponent最佳实践之TabbarIndicator - 图4

有内味儿了。所以说你发现没,东西都给你了,怎么画,就看你自己的功底了。

不过要注意的是,这一类的indicator,都是基于UnderlineTabIndicator的实现来做的,所以它的样式也有一些限制,例如:必须会实现在Tab下的滑动效果,而且滑动时,无法修改尺寸。

固定的indicator & 滑动系数监听

那么如果我需要去掉indicator的滑动效果呢?有两个办法,一个是修改TabBar的源码,另一个是将固定的indicator放入tabs中实现,而不是indicator。

这两个方法各有利弊,修改源码是釜底抽薪,可以实现一切效果,同样的,成本高,另一方法简单,但是需要拿到滑动系数。

下面我们就通过使用第二种方式来看下怎么实现,同时,也完成下前面介绍的抖动问题的最后实现(它也需要拿到滑动系数)。

首先,我们需要知道从哪获得滑动系数,这个东西,我们可以通过_tabController来获取,这里面包含了TabBar滑动的一切参数,例如:

  • _tabController.animation!.value:滑动变化区间值
  • _tabController.offset:滑动方向
  • _tabController.previousIndex:滑动前的Index
  • _tabController.index:滑动到的Index
  • _tabController.indexIsChanging:是滑动还是点击Tab产生的滑动

这些东西,就是我们的原始数据,通过它们,我们就可以得到当前滑动的滑动系数。

tabs: widget.tabs
    .asMap()
    .entries
    .map(
      (entry) => AnimatedBuilder(
        animation: _tabController.animation!,
        builder: (ctx, snapshot) {
          final forward = _tabController.offset > 0;
          final backward = _tabController.offset < 0;
          int _fromIndex;
          int _toIndex;
          double progress;
          // Tab
          if (_tabController.indexIsChanging) {
            _fromIndex = _tabController.previousIndex;
            _toIndex = _tabController.index;
            progress = (_tabController.animation!.value - _fromIndex).abs() / (_toIndex - _fromIndex).abs();
          } else {
            // Scroll
            _fromIndex = _tabController.index;
            _toIndex = forward
                ? _fromIndex + 1
                : backward
                    ? _fromIndex - 1
                    : _fromIndex;
            progress = (_tabController.animation!.value - _fromIndex).abs();
          }
          var flag = entry.key == _fromIndex
              ? 1 - progress
              : entry.key == _toIndex
                  ? progress
                  : 0.0;
          return buildTabContainer(entry.value, flag);
        },
      ),
    )
    .toList(),

首先,我们通过offset来判断滑动的方向,再通过indexIsChanging来判断当前是点击还是滑动,最后根据animation来获取当前的滑动系数。

有了滑动系数,我们就可以很方便的对Tab中的标题做Scala动画,同时对固定的indicator做动画了。

buildTabContainer(String tabName, double alpha) {
  return Container(
    padding: const EdgeInsets.symmetric(vertical: 2),
    child: Stack(
      children: [
        AnimatedScale(
          scale: 1 + alpha * 0.3,
          duration: const Duration(milliseconds: 100),
          child: Text(tabName),
        ),
        Positioned(
          bottom: 0,
          left: 0,
          child: Transform.translate(
            offset: const Offset(-8, 0),
            child: Opacity(
              opacity: alpha,
              child: Container(
                width: 30,
                height: 8,
                decoration: const BoxDecoration(
                  shape: BoxShape.rectangle,
                  borderRadius: BorderRadius.only(
                    topLeft: Radius.circular(16),
                    bottomRight: Radius.circular(16),
                  ),
                  gradient: LinearGradient(colors: [
                    QDColors.tab_gradient_start,
                    QDColors.tab_gradient_end,
                  ]),
                ),
              ),
            ),
          ),
        ),
      ],
    ),
  );
}

图就不放了,下面还有。

Material效果的indicator

应群友的呼声,增加了Material效果的indicator,很奇怪的是,官方居然没有支持这种Material风格,带伸缩的indicator,Native上都支持了。

原始的Indicator在滑动时,是固定尺寸的,在Tabbar源码中,我们找到_IndicatorPainter,这个CustomPainter负责了对Indicator的绘制,所以,我们要想获得类似Material组件弹性伸缩的效果,那就必须修改绘制时的宽度,显然,我们来到了paint函数,在这里,发现两个rect——fromRect和toRect,它们执行的lerp操作,就成了我们想要的Indicator动画效果。

所以,思路自然就出来了,我们只需要根据当前滑动的进度,修改当前Rect的宽度即可。

那么下面还有一个问题,就是Material风格的伸缩动画,是如何实现的呢?

我们打开CircularProgressIndicator的源码,看下它的动画是怎么实现的就知道了。

static final Animatable<double> _strokeHeadTween = CurveTween(
  curve: const Interval(0.0, 0.5, curve: Curves.fastOutSlowIn),
).chain(CurveTween(
  curve: const SawTooth(_pathCount),
));
static final Animatable<double> _strokeTailTween = CurveTween(
  curve: const Interval(0.5, 1.0, curve: Curves.fastOutSlowIn),
).chain(CurveTween(
  curve: const SawTooth(_pathCount),
));

显而易见,这个动画被分为了两部分,0-0.5时,以缩放系数增加,0.5-1时,按照缩放系数递减。

所以,首先,我们给indicatorRect增加一个参数scale,用来传入当前两个Tab间的滑动比例。

// if (indicatorSize == FixedTabBarIndicatorSize.label) {
  final double tabWidth = tabKeys[tabIndex].currentContext!.size!.width;
  final double delta = ((tabRight - tabLeft) - tabWidth) / 2.0;
  tabLeft += delta;
  tabRight -= delta;
// }
final EdgeInsets insets = indicatorPadding.resolve(_currentTextDirection);
double minWidth = 10.0;
double increment = 40.0;
double currentWidth = minWidth + increment * (scale < 0.5 ? scale : 1 - scale);
// final Rect rect = Rect.fromLTWH(tabLeft, 0.0, tabRight - tabLeft, tabBarSize.height);
final Rect rect = Rect.fromLTWH(tabLeft + tabWidth / 2 - minWidth / 2, 0.0, math.max(currentWidth, minWidth), tabBarSize.height);

注释掉的行,就是源码中的代码,第一个if判断,实际上是偷懒,因为默认FixedTabBarIndicatorSize是tab,所以这里不会走,我们放开判断,第二个改动,就是修改rect的left和width,left是为了让rect居中,width是为了修改宽度做动画,代码很简单。

接下来,就是需要拿到scale了,源代码中其实已经写了一半了。

// final Rect fromRect = indicatorRect(size, from);
final Rect fromRect = indicatorRect(size, from, (index - value).abs());
// final Rect toRect = indicatorRect(size, to);
final Rect toRect = indicatorRect(size, to, (index - value).abs());

同样,注释掉的行是源码,我们增加一个scale,它实际上就是index和value的绝对值,为啥是绝对值呢,因为方向咯。

所以,就这几行代码,我们就修改了这个Material功能,不过,这里只是最基本的修改,如果要形成一个完整的lib,那么需要将这些参数抽出去,作为配置选项,我就不做了,因为懒。

FlutterComponent最佳实践之TabbarIndicator - 图5

图中还顺带演示了前一个固定的indicator的实现效果。

向大家推荐下我的网站 https://xuyisheng.top/ 专注 Android-Kotlin-Flutter 欢迎大家访问

FlutterComponent最佳实践之TabbarIndicator - 图6