Android TabLayout修改指示器宽度方案汇总

本文详细探讨了Android TabLayout中修改指示器宽度的多种方法,包括通过获取tabSelectedIndicator边界、重写draw()方法以及自定义布局等方案,旨在实现更符合设计需求的指示器效果。

前言

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" />

在这里插入图片描述
获取源码:https://github.com/material-components/material-components-android

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。不过最好还是自己计算以下

看下效果,完美解决:
在这里插入图片描述

评论 5
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值