目录
前言
TabLayout是一个遵循Material Design设计规范的官方控件,也是日常工作中最常用的控件之一,手机上随便打开一个APP,都能看到它的身影。TabLayout可以为一个多页面的布局提供页面指示器,使得一个Activity或者Fragment中能够展示更加丰富的内容。结合ViewPage使用更是可以实现丰富的页面切换效果,并能带来流畅的页面切换体验。
TabLayout的功能强大,使用灵活,许多属性都支持使用者自由的定义,甚至可以直接给Tab指定一个布局。然而如此强大的控件却偏偏不支持修改底部指示器的宽度,不得不说,写这个控件的工程师的心思是真的难以捉摸啊!
看看这个效果,是不是有些难以直视。再看看主流的APP,几乎都会对这个指示器宽度做一定的修改。UI设计的时候,这里通常也会带有公司的风格。指示器的宽度这么长,再好看的指示器也会被拉伸到变形的。这里就给出几种常见的修改方案
TabLayout的视图结构
TabLayout继承自HorizontalScrollView,具有横向滚动的功能。它拥有ScrollView的特点,即内部只能有一个子布局。TabLayout初始化时,会调用一次addView()来添加这个布局。
super.addView(slidingTabIndicator, 0, new HorizontalScrollView.LayoutParams(LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT));
同样我们也可以根据index = 0来获取这个布局
View view = tabLayout.getChildAt(0);
它是一个SlidingTabIndicator,继承自LinearLayout。翻译过来就是滑动标签指示器,也是TabView的直接父布局。你可能疑惑了,指示器不是指的TabView底部的横线么,怎么这个TabLayout的唯一子布局也叫指示器呢?
简单理解来说,TabView底部的指示器是视觉上的指示器,其作用是给用户标识当前被选中的Tab。我们常说的改指示器宽度就是修改它的宽度,它并不是一个View,本质是SlidingTabIndicator绘制在特定位置的一个Drawable。
而SlidingTabIndicator是数据上的指示器,其作用是给与TabLayout联动的视图,如ViewPager,标识出当前被选中的位置,以便于联动视图做出相应的操作。因此对于联动视图来讲,它也可以叫指示器。
TabLayout持有一个Lis< Tab >来管理各个Tab,每个Tab会有属于自己的视图TabView。当TabLayout增加Tab时,会调用SlidingTabIndicator的addView方法,将Tab的TabView添加进来
public void addTab(@NonNull Tab tab, int position, boolean setSelected) {
...
configureTab(tab, position);
addTabView(tab);
...
}
private void addTabView(@NonNull Tab tab) {
final TabView tabView = tab.view;
tabView.setSelected(false);
tabView.setActivated(false);
slidingTabIndicator.addView(tabView, tab.getPosition(), createLayoutParamsForTabs());
}
因此TabLayout的视图结构应该如下图所示。其中指示器并不是一个控件,它是绘制在SlidingTabIndicator底部的一个Drawable。
TabLayout绘制指示器的过程
前面提到,所谓的指示器其实只是一个TabLayout中的一个Drawable对象,TabLayout还为它提供了相应的getter和setter。
@Nullable Drawable tabSelectedIndicator;
当Tab切换时,对指示器的操作如下
(1) 获取tabSelectedIndicator的边界
(2) 调用tabSelectedIndicator的draw()方法,将tabSelectedIndicator绘制出来
步骤已经明了,如此就可以通过修改某一步骤的过程来实现指示器宽度的修改。
1.从获取tabSelectedIndicator边界着手
1.1 默认情况下的指示器宽度
默认情况下使用被选中TabView的宽度,即文章开始时图中看到的指示器充满整个TabView的情况。它使用当前TabView的宽度作为指示器宽度
.....
View selectedTitle = getChildAt(this.selectedPosition);
int left;
int right;
if (selectedTitle != null && selectedTitle.getWidth() > 0) {
// 使用当前选中项的边界作为指示器的边界
left = selectedTitle.getLeft();
right = selectedTitle.getRight();
......
}
// 设置指示器的左右边界
setIndicatorPosition(left, right)
1.2 tabIndicatorFullWidth="false"时,指示器宽度
当设置了tabIndicatorFullWidth="false"时,遍历当前TabView的子控件,获取其中left的最小值作为指示器的左边界,right的最大值作为指示器的右边界。在大多数情况下文字才是一个Tab中宽度最大的,因此可以认为这种情况下指示器的宽度与文字宽度相同。
......
if (!tabIndicatorFullWidth && selectedTitle instanceof TabView) {
calculateTabViewContentBounds((TabView) selectedTitle, tabViewContentBounds);
left = (int) tabViewContentBounds.left;
right = (int) tabViewContentBounds.right;
}
......
/**
* 获取tabView内容的边界
*/
private void calculateTabViewContentBounds(
@NonNull TabView tabView, @NonNull RectF contentBounds) {
// 获取tabView的内容宽度
int tabViewContentWidth = tabView.getContentWidth();
int minIndicatorWidth = (int) ViewUtils.dpToPx(getContext(), MIN_INDICATOR_WIDTH);
if (tabViewContentWidth < minIndicatorWidth) {
tabViewContentWidth = minIndicatorWidth;
}
// 边界设置
int tabViewCenter = (tabView.getLeft() + tabView.getRight()) / 2;
int contentLeftBounds = tabViewCenter - (tabViewContentWidth / 2);
int contentRightBounds = tabViewCenter + (tabViewContentWidth / 2);
contentBounds.set(contentLeftBounds, 0, contentRightBounds, 0);
}
看下效果,没有之前充满宽度那么夸张了,但还是难以满足复杂多变的效果,对于需要小于文字宽度的指示器就无能为力了。而且当Tab中的文字字数不同时,切换必然会引起指示器宽度的变化。
综上所述,官方对指示器的宽度定义,是基于TabView的宽度,或者其内容的最大宽度来定义的,使用上跟灵活定制完全不搭边。
1.3 使用反射修改TabView的宽度
原理:1.1中讲过,指示器的宽度在默认情况下是TabView的宽度。那么我们可以通过修改TabView的宽度来间接修改指示器的宽度。根据前面视图结构中所讲的,可以通过以下方法获取SlidingTabIndicator。
LinearLayout mTabViews = (LinearLayout) getChildAt(0);
遍历其子控件即可获取各个TabView,TabView默认左右拥有12dp的Padding,这个是在它的style里定义的。我们获取这个padding值,把这个值设置为margin,然后设置padding为0。即可在保证视觉效果的情况下,修改TabView的宽度,从而修改指示器的宽度效果。
try {
for (int i = 0; i < mTabViews.getChildCount(); i++) {
View tabView = mTabViews.getChildAt(i);
Field mTextView = getContext().getClassLoader().loadClass("com.google.android.material.tabs.TabLayout$TabView").getDeclaredField("textView");
mTextView.setAccessible(true);
TextView textView = (TextView) mTextView.get(tabView);
int textWidth = textView.getMeasuredWidth();
// 将Padding改为Margin
LinearLayout.LayoutParams params = (LinearLayout.LayoutParams)tabView.getLayoutParams();
params.width = textWidth;
params.leftMargin = (int) dpToPx(12);
params.rightMargin = (int) dpToPx(12);
tabView.setPadding(0, 0, 0, 0);
tabView.setLayoutParams(params);
tabView.invalidate();
}
} catch (Exception e) {
e.printStackTrace();
}
效果如下
显然,这种方法是将指示器的宽度缩小到TabView的中TextView的宽度,指示器的最终的效果跟设置了tabIndicatorFullWidth="false"时是一致的,在有了tabIndicatorFullWidth="false"之后这种方法就可以丢弃了。而且由于TabView的宽度被缩小到了适应内容,各个TabView看上去将挤在一起,需要补充margin参数。点击效果也会显得不够饱满,margin处是没有点击效果的。这种方法已经过时,不推荐使用
1.4 修改calculateTabViewContentBounds()方法
1.2中提到,这个方法是tabIndicatorFullWidth="false"时给指示器设置边界时调用的,修改这个方法实现指示器的宽度修改。
(1)在xml中新增下列属性
<declare-styleable name="TabLayout">
<attr name="tabIndicatorWidth" format="dimension"/>
</declare-styleable>
(2)复制TabLayout源码到自定义的TabLayout中,selectedIndicatorWidth新增仿照selectedIndicatorHeight的获取及设置,然后修改calculateTabViewContentBounds()方法如下
private void calculateTabViewContentBounds(TabView tabView, RectF contentBounds) {
if (selectedIndicatorWidth == 0) {
selectedIndicatorWidth = tabView.getContentWidth();
}
int tabViewCenter = (tabView.getLeft() + tabView.getRight()) / 2;
int contentLeftBounds = tabViewCenter - (selectedIndicatorWidth / 2);
int contentRightBounds = tabViewCenter + (selectedIndicatorWidth / 2);
contentBounds.set(contentLeftBounds, 0, contentRightBounds, 0);
}
(3)使用时即可自由定义指示器的宽度
<com.example.view.TabLayout
.....
app:tabIndicatorFullWidth="false"
app:tabIndicatorWidth="12dp"
app:tabMode="scrollable" />
2.从tabSelectedIndicator的draw()方法着手
2.1 重写Drawable的draw()方法
原理:tabSelectedIndicator是一个Drawable,并且TabLayout有提供相应的设置方法setSelectedTabIndicator()。我们重新定义一个Drawable类,重写它的draw()方法。然后设置为tabSelectedIndicator即可。之后就可以在draw()中为所欲为了。
public class IndicatorDrawable extends Drawable {
private Paint mPaint;
private float indicatorLeft;
private float indicatorRight;
private int indicatorWidth;
private float indicatorHeight;
public IndicatorDrawable() {
this(0);
}
public IndicatorDrawable(int indicatorWidth) {
mPaint = new Paint();
this.indicatorWidth = indicatorWidth;
}
@Override
public void draw(@NonNull Canvas canvas) {
// 获取Drawable的真实边界,这个在调用draw之前TabLayout已经设置完毕
indicatorLeft = getBounds().left;
indicatorRight = getBounds().right;
indicatorHeight = getBounds().bottom - getBounds().top;
// 圆角半径
float radius = indicatorHeight / 2f;
// 默认充满
if (indicatorWidth == 0) {
indicatorWidth = (int) (indicatorRight - indicatorLeft);
}
// 指示器绘制中心
float indicatorCenter = (indicatorRight + indicatorLeft) / 2f;
if (indicatorLeft >= 0 && indicatorRight > indicatorLeft
&& indicatorWidth <= indicatorRight - indicatorLeft) {
RectF rectF = new RectF(indicatorCenter - indicatorWidth / 2f, getBounds().top,
indicatorCenter + indicatorWidth / 2f, getBounds().bottom);
canvas.drawRoundRect(rectF, radius, radius, mPaint);
}
}
@Override
public void setAlpha(int alpha) {
}
@Override
public void setColorFilter(@Nullable ColorFilter colorFilter) {
}
@Override
public void setColorFilter(@ColorInt int color, @NonNull PorterDuff.Mode mode) {
super.setColorFilter(color, mode);
// 获取TabLayout传入的画笔颜色
mPaint.setColor(color);
}
@Override
public void setTint(int tintColor) {
super.setTint(tintColor);
// 获取TabLayout传入的画笔颜色
mPaint.setColor(tintColor);
}
@Override
public int getOpacity() {
return PixelFormat.UNKNOWN;
}
}
效果如下:
2.2 重写背景Drawable的draw()方法
原理:在TabLayout中提到,SlidingTabIndicator本质上是一个LinearLayout,它的draw()方法不仅会调用2.1中指示器Drawable的draw()方法,还会在super.draw()中绘制背景。因此,我们可以屏蔽指示器,然后通过绘制背景来模拟指示器的效果。这个不细说,参考这位大佬的博客。
史上最巧妙自定义tablayout指示器
当然,我还是认为2.1中的方式原理更加简单,也不需要用反射技术。
3.其他方式
3.1 自定义TabView的布局模拟指示器
原理:TabLayout允许给TabView指定一个自定义的布局customView。可以通过下列代码来指定这个布局。去掉TabLayout自带的指示器,然后在customView的底部放一个ImageView来模拟指示器的效果。这样使用的布局就比较灵活,每个TabView的样式都尽在掌握之中。
这种方法的缺点是没有指示器切换的动画,尤其是结合ViewPager使用的时候,体验会比较差劲。
for (i in 0 until tabLayout.tabCount) {
tabLayout.getTabAt(i)?.setCustomView(R.layout.test)
}
3.2 使用矢量图作为指示器
原理:在设置了 tabIndicatorFullWidth="false"时,指示器的宽度是跟TexiView的宽度相同的。准备一张宽度与文字一样宽的图片,图片两端留下一定比例的透明的部分,将TabLayout的指示器设置为这张图片,这样显示在屏幕上的效果就是一个比文字宽度小一些的指示器。
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="20dp"
android:height="5dp"
android:viewportWidth="300.0"
android:viewportHeight="50.0"
>
<path android:name="indicator"
android:strokeWidth="50.0"
android:strokeColor="#FF8F3E"
android:strokeLineCap="round"
android:pathData="M 100 25 L 200 25"/>
</vector>
这样做就可以了吗?看下效果
怎么回事,设置的颜色明明是一个黄色,怎么成了黑色了。看下代码
if (selectedIndicatorPaint != null) {
if (VERSION.SDK_INT == VERSION_CODES.LOLLIPOP) {
// Drawable doesn't implement setTint in API 21
selectedIndicator.setColorFilter(selectedIndicatorPaint.getColor(), PorterDuff.Mode.SRC_IN);
} else {
DrawableCompat.setTint(selectedIndicator, selectedIndicatorPaint.getColor());
}
}
在调用Drawable的draw方法之前,先通过图层混合过滤了颜色。这里真正取到的是矢量图的形状(PathData),颜色还是指示器画笔的颜色。因此使用的时候不要忘记指定tabIndicatorColor这个属性。
方便一点,甚至可以直接找UI要一张图,连矢量图都不用绘制了。这种方法使用起来最为简单,但
缺点是需要跟文字的宽度保持一定的比例,当文字变化时,显示的指示器宽度也会按比例变化。比较适用于文字字数固定的TabLayout中。
3.3 使用.9图作为指示器
3.2中提到,在使用矢量图的情况下,当文字变化时,显示的指示器宽度也会按比例变化,因为是整体进行拉伸的。看到这里是不是灵机一动:如果做一张.9图,不就可以保证指示器部分不变化,当拉伸的时候只让透明部分变化不就好了?想到这里赶紧去尝试一波。
首先声明一点,指示器的高度是5dp,上边是用一个80 * 10 px的png图片做成的.9图,其中指示器部分是40px,看下效果:
与预想中的不太一样,图片看上去是被拉伸地变形了。NinePatch当然只会拉伸我们指定的可拉伸区域,在NinePatch看来指示器部分就是这么长。既然宽度没有问题,那么两端圆角的变化会不会是高度被压缩了呢?Log显示Drawable的原始宽度是240px,高度是30px,刚好被扩大了3倍。终于真相浮出了水面!
drawable文件夹与设备的关系
(1) 设备根据自己的像素密度去加载对应文件夹的drawable文件。如xxhdpi的设备会首先加载drawable-xxhdpi中的图片。
(2) 如果对应的文件夹中没有需要加载的文件,就遵循“先高后低”的顺序去别的像素密度下的drawable中寻找。加载图片会按照比例放大或者压缩图片尺寸,从而保证图片在不同像素密度的设备上看起来大小一致。
(3) 例如,当前设备是xhdpi设备,在drawable中找到了需要的图片,就会放大2倍来使用。如果在drawable-xxhdpi中找到图片,会压缩到 2/3使用。
此时可以解释图片变形的原因了。前面制作.9图的时候误将图片放在了drawable文件夹中。drawable中的文件,其px与dp的转换比例是1:1,因此它需要的高度就是10dp,而前面只给它指定了5dp,高度被压缩了一半,就出现了上图中图片被拉伸变形的假象。同时由于使用的手机是 xxhdpi。在这种情况下一个80 * 10 px的图片的尺寸也就变成了 240 * 30 px。
在使用.9图来实现指示器宽度的时,需要首先做一下换算。比如UI图上需要一个20dp宽,5dp高的指示器,那么就应该做一张90 * 15 px的图片。其中指示器部分高度充满,宽度60px,两边各有15px的透明部分。然后做成.9图放在drawable-xxhdpi下,就可以实现需要的效果了。以刚才那张80 * 10的图片为例,把它放在drawable-xxhdpi下,它应该是一个宽26.6dp,高3.3dp的图片。
注意:我们的设计图本身就来自UI,他们一般都已经有了原始切图,我们拿过来可以直接做.9图。UI使用的工具默认宽度是750px,而我们的Android宽度设备通常是1080px。因此想要保持二者的dp值一致,这种原始切图应该放在drawable-xhdpi。不过最好还是自己计算以下
看下效果,完美解决: