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函数中,将实现改为下面的方案。
///根据前后字体大小计算缩放倍率
final double _magnification = labelStyle!.fontSize! / unselectedLabelStyle!.fontSize!;
final double _scale = (selected ? lerpDouble(_magnification, 1, animation.value) : lerpDouble(1, _magnification, animation.value))!;
return DefaultTextStyle(
style: textStyle.copyWith(
color: color,
fontSize: unselectedLabelStyle!.fontSize,
),
child: IconTheme.merge(
data: IconThemeData(
size: 24.0,
color: color,
),
child: Transform.scale(
scale: _scale,
child: child,
),
),
);
这个方案的确可以修复这个问题,不过却需要修改源码,所以,有一些使用成本,那么有没有其它方案呢,其实,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的继承关系如下所示。
Ftab-bar-view-06-20220301160241981
Shape indicator
UnderlineTabIndicator是Tab的默认实现,我们可以修改为ShapeDecoration,这样就可以实现一个简单的方框indicator。
indicator: ShapeDecoration(
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
color: Colors.cyan.shade200,
)
实现效果如下。
image-20220301160841929
这样我们可以实现很多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,
);
}
}
效果如图所示。
image-20220301170202632
再来一个:
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,
);
效果如图所示。
image-20220301170842559
有内味儿了。所以说你发现没,东西都给你了,怎么画,就看你自己的功底了。
不过要注意的是,这一类的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,那么需要将这些参数抽出去,作为配置选项,我就不做了,因为懒。
material_tab
图中还顺带演示了前一个固定的indicator的实现效果。
< END >
作者:徐宜生
更文不易,点个“三连”支持一下????