MaterialDesign之对TabLayout的探索

一、简述

TabLayout是Android Support Design库的新控件,可以用来实现开源框架ViewPageIndicator的效果(在MaterialDesign没出来之前基本都用这玩意儿吧~),TabLayout相比它使用上更加简单,且不一定要跟ViewPager一起使用,毕竟谷歌做出来的,稳定性更是不用说啦,此外,本文还会仔细列出本人对该控件的探索过程,从而实现一些控件本身没法实现的自定义效果,下面来看看它都有哪些操作吧。

二、使用

1、创建Tab及Tab的点击事件

要使用TabLayout,一般会先在布局文件中放好,如:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:app="http://schemas.android.com/apk/res-auto"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical">

    <android.support.design.widget.TabLayout
        android:id="@+id/tabLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content" />
</LinearLayout>

然后在Activity中找到它,对它进行设置,如果不跟ViewPager一起使用的话,可以对TabLayout手动添加多个tab,并设置其点击事件,如:

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_tab_layout);
    mTabLayout = (TabLayout) findViewById(R.id.tabLayout);
    // 添加多个tab
    for (int i = 0; i < title.length; i++) {
        TabLayout.Tab tab = mTabLayout.newTab();
        tab.setText(title[i]);
        // tab.setIcon(R.mipmap.ic_launcher);//icon会显示在文字上面
        mTabLayout.addTab(tab);
    }
    // 给tab设置点击事件
    mTabLayout.setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
        @Override
        public void onTabSelected(TabLayout.Tab tab) {
            Toast.makeText(getApplicationContext(), title[tab.getPosition()], Toast.LENGTH_SHORT).show();
        }

        @Override
        public void onTabUnselected(TabLayout.Tab tab) {

        }

        @Override
        public void onTabReselected(TabLayout.Tab tab) {

        }
    });
}

这里比较有意思的是Tab的创建需要调用TabLayout对象的newTab()方法,而不是直接new出一个Tab。Tab除了可以设置文字外,还能设置Icon,甚至可以自定义View,分别调用的是setIcon()和setCustomView(),有兴趣的可以试试看,上面代码效果如下:

手动创建Tab,并设置点击事件

2、自定义TabLayout样式

这个TabLayout还是挺好看的,但开发中难免会要定制TabLayout的样式,如设置默认或选中文字的颜色和大小等,还好,TabLayout尽可能多的提供了这些自定义属性,可以让开发者很方便的修改样式,下面来看看都有哪些控件属性可以设置:

<!--设置Tab指示器-->
app:tabIndicatorColor=""
app:tabIndicatorHeight=""

<!--设置Tab位置及显示模式-->
app:tabGravity=""
app:tabMode=""

<!--设置Tab文字样式-->
app:tabSelectedTextColor=""
app:tabTextAppearance=""
app:tabTextColor=""

<!--设置Tab的宽度、背景、内间距-->
app:tabMaxWidth=""
app:tabMinWidth=""
app:tabBackground=""
app:tabPadding=""

1)设置Tab指示器

TabLayout的指示器默认颜色是color.xml中的colorAccent,通过TabLayout提供的自定义属性,可以设置指示器的高度和颜色,如果不想显示指示器(Indicator),可以将其高度设置为0dp或设置其颜色为透明,这里为演示,我就把显示指示器(Indicator)的高度提高,颜色改为刺眼的红眼,如下:

<android.support.design.widget.TabLayout
    android:id="@+id/tabLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabIndicatorColor="@color/red"
    app:tabIndicatorHeight="8dp"/>
设置Tab指示器样式

2)设置Tab位置及显示模式

TabLayout的显示模式(tabMode)默认是固定不可滚动(fixed),位置(tabGravity)默认填满(fill)整个TabLayout,我们先保持app:tabMode="fixed",把tabGravity的值换成fill和center对比下,为了方便对比,我把背景也设置了。

<android.support.design.widget.TabLayout
    android:id="@+id/tabLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabBackground="@color/colorPrimaryDark"
    app:tabGravity="fill" // 再换成center
    app:tabMode="fixed"/>
app:tabGravity="fill"

app:tabGravity="center"

当tab比较多的时候,一个屏幕宽度容纳不下,这时候就需要让TabLayout可以横向滚动了,只需要修改app:tabMode="scrollable"即可。注意,当app:tabMode="scrollable"时,app:tabGravity=""不管取什么值都不会生效。

<android.support.design.widget.TabLayout
    android:id="@+id/tabLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabBackground="@color/colorPrimaryDark"
    app:tabGravity="center"
    app:tabMode="scrollable"/>
app:tabMode="scrollable"

3)设置Tab文字样式

上面的效果不好看,我想让它默认文字颜色为灰色,选中时文字为白色,文字大小为16sp,但TabLayout没有提供直接设置文字大小的属性,这时候就需要用到app:tabTextAppearance=""了。操作如下:

在Style.xml中声明文字样式

<style name="TabLayout.TabText" parent="TextAppearance.Design.Tab">
    <item name="android:textSize">16sp</item>
    <item name="textAllCaps">false</item>
</style>

其中除了可以设置字体大小外,还可以设置英文是否都全部大写显示。textAllCaps的默认值为true,即英文全部大写。

在布局文件中设置TabLayout的文字相关属性

<android.support.design.widget.TabLayout
    android:id="@+id/tabLayout"
    android:layout_width="match_parent"
    android:layout_height="wrap_content"
    app:tabBackground="@color/colorPrimaryDark"
    app:tabGravity="center"
    app:tabMode="scrollable"
    ...
    app:tabSelectedTextColor="@android:color/white"
    app:tabTextAppearance="@style/TabLayout.TabText"
    app:tabTextColor="@android:color/darker_gray"/>

好了,看看效果如何:

设置Tab文字样式

好了,关于Tab的宽度、内间距等设置比较简单,自己需要的时候试试吧,这里就不演示了。

3、与ViewPager结合

上面通过对TabLayout的单独使用学习了TabLayout的样式自定义、创建Tab及设置Tab的点击事件等,可以说常用的也就那些了,下面来看看TabLayout如何与ViewPager的结合使用。这种需求也是很常见的,界面顶部有一个标签栏,中下部是与标签对应的内容,可以左右滑动,同时标签也跟随其切换,相反的,在切换标签时,内容部分也会跟着变化,不太明白的可以参考下“今日头条”APP的首页界面。这样的效果就可以用TabLayout+ViewPager+Fragment来实现。

1)先在布局文件中放好TabLayout和ViewPager:

<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
              xmlns:app="http://schemas.android.com/apk/res-auto"
              android:layout_width="match_parent"
              android:layout_height="match_parent"
              android:orientation="vertical">

    <android.support.design.widget.TabLayout
        android:id="@+id/tabLayout"
        android:layout_width="match_parent"
        android:layout_height="wrap_content"
        app:tabBackground="@color/colorPrimaryDark"
        app:tabMode="scrollable"
        app:tabSelectedTextColor="@android:color/white"
        app:tabTextColor="@android:color/darker_gray"/>

    <android.support.v4.view.ViewPager
        android:id="@+id/viewPager"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
</LinearLayout>

2)在代码中设置TabLayout与ViewPager相互关联:

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_tab_layout);
    mTabLayout = (TabLayout) findViewById(R.id.tabLayout);
    mViewPager = (ViewPager) findViewById(R.id.viewPager);
    MyViewPagerAdapter adapter = new MyViewPagerAdapter(getSupportFragmentManager());
    mViewPager.setAdapter(adapter);

    // 适配器必须重写getPageTitle()方法 
    mTabLayout.setTabsFromPagerAdapter(adapter);
    // 监听TabLayout的标签选择,当标签选中时ViewPager切换
    mTabLayout.setOnTabSelectedListener(new TabLayout.ViewPagerOnTabSelectedListener(mViewPager));
    // 监听ViewPager的页面切换,当页面切换时TabLayout的标签跟着切换
    mViewPager.setOnPageChangeListener(new TabLayout.TabLayoutOnPageChangeListener(mTabLayout));
}

这三句代码不难理解,就字面上的意思,但是这三句代码都已经过时,因为要关联TabLayout与ViewPager就得写三句代码似乎是麻烦了一点点(其实我觉得还好吧),所以TabLayout提供了可以通过一句代码搞定两者关联的方法:setupWithViewPager(),因此,上面的代码可以简化如下:

@Override
protected void onCreate(@Nullable Bundle savedInstanceState) {
    super.onCreate(savedInstanceState);
    setContentView(R.layout.activity_tab_layout);
    mTabLayout = (TabLayout) findViewById(R.id.tabLayout);
    mViewPager = (ViewPager) findViewById(R.id.viewPager);
    MyViewPagerAdapter adapter = new MyViewPagerAdapter(getSupportFragmentManager());
    mViewPager.setAdapter(adapter);

    // 关联TabLayout与ViewPager,且适配器必须重写getPageTitle()方法 
    mTabLayout.setupWithViewPager(mViewPager);
}

来看下效果:

TabLayout与ViewPager相互关联

关联TabLayout与ViewPager相当简单,只要注意ViewPager适配器需重写getPageTitle()方法,这里顺便贴出Demo中适配器的代码:

class MyViewPagerAdapter extends FragmentPagerAdapter {

    private final String[] title = new String[]{
            "推荐", "热点", "视频", "深圳", "通信",
            "互联网", "问答", "图片", "电影",
            "网络安全", "软件"};

    public MyViewPagerAdapter(FragmentManager fm) {
        super(fm);
    }

    @Override
    public Fragment getItem(int i) {
        Fragment fragment = new TextFragment();
        Bundle bundle = new Bundle();
        bundle.putString("title", title[i]);
        fragment.setArguments(bundle);
        return fragment;
    }

    @Override
    public int getCount() {
        return title.length;
    }

    @Override
    public CharSequence getPageTitle(int position) {
        return title[position];
    }
}   

三、拓展

上面部分是TabLayout的正规使用说明,而这部分是对TabLayout的进一步探索,同时将列出本人在这个过程中的探索思路,可以说是对TabLayout的进一步自定义吧。废话不多说,下面就直接开车了。

假如,你手中的APP设计稿中有如下的三个需求那该怎么办:

  1. 为TabLayout添加分割线,且分割线距离上下存在间距。
  1. 选中时tab字体变大,未选中时tab字体变小。
  2. 指示器(Indicator)不要充满整个标签(Tab)

简单的说就是为TabLayout添加分割线、设置不同状态下的字体大小和指示器的“长度”,这些在TabLayout中并没有提供直接的修改方法,你可能会想,那我们对TabLayout进行源码分析,然后通过反射等手段拿到其中的控件来设置?不!有时候解决问题不要循规蹈矩,应该适当转变下思路,或许解决问题的方法并不需要去看源码那么困难(如果你是大神,就当我没说),下面看我操作:

1、分析TabLayout的结构

将APP运行起来,然后回到AS,在菜单栏中依次找到Tools-->Android-->Android Device Monitor,用过Eclipse开发的Android程序员应该都知道这久违的老伙计———Android 设备监测仪。

Android 设备监测仪

选中正在运行的APP,点击Dump View Hierarchy for UI Automator。

Dump View Hierarchy for UI Automator

可能会卡一下,然后它会自动把当前界面的控件结构展示出来。

界面控件结构图

从这个结构上我们可以知道TabLayout(就是HorizontalScrollView)并不是直接就包裹这些Tab的,而是包裹了一个LinearLayout,然后这些Tab放在这个LinearLayout中,此外,可以发现Tab里包含了一个TextView,到这里对前面2个需求是不是有点想法了呢?

2、为TabLayout添加分割线

LinearLayout自带就有设置分割线的方法,我们可以通过它来添加分割线,也没什么好说的,直接上代码:

mLinearLayout = (LinearLayout) mTabLayout.getChildAt(0);
// 在所有子控件的中间显示分割线(还可能只显示顶部、尾部和不显示分割线)
mLinearLayout.setShowDividers(LinearLayout.SHOW_DIVIDER_MIDDLE);
// 设置分割线的距离本身(LinearLayout)的内间距
mLinearLayout.setDividerPadding(20);
// 设置分割线的样式
mLinearLayout.setDividerDrawable(ContextCompat.getDrawable(this, R.drawable.divider_vertical));

divider_vertical.xml:

<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android">
    <solid android:color="#ccc"/>
    <size android:width="1dp" />
</shape>

这样,分割线就有了。

为TabLayout添加分割线(有瑕疵)

看起来有点怪是吧,这是因为我们前面设置的app:tabBackground="@color/colorPrimaryDark"只是给Tab设置了背景色,而不是给Tab的父级控件LinearLayout设置,这个LinearLayout默认的背景色是白色,所以才会是这个样子,解决方法自然就是给LinearLayout设置跟Tab一样的背景色就好了。

mLinearLayout = (LinearLayout) mTabLayout.getChildAt(0);
...
mLinearLayout.setBackgroundColor(getResources().getColor(R.color.colorPrimaryDark));

这样就完美的为TabLayout设置分割线了。

为TabLayout添加分割线(完美)

3、为TabLayout设置不同状态下的字体大小(并不能成功)

用同样的方式拿到Tab中的文本控件,判断当前是否被选中,再对该文本控件进行字体大小设置就欧了。借助上面拿到的用来包裹Tab的LinearLayout(mLinearLayout),遍历LinearLayout中的子控件,拿到一个个的子view(即Tab),再从Tab中拿到文本控件设置文字大小。

// 默认让所有没有选中的Tab的文字设置为小字体
for (int i = 0; i < mTabLayout.getTabCount(); i++) {
    ((TextView) ((LinearLayout) mLinearLayout.getChildAt(i)).getChildAt(1)).setTextSize(10);
    // 也可以这么写,一样的
    // ((TextView) ((LinearLayout) ((LinearLayout) mTabLayout.getChildAt(0)).getChildAt(i)).getChildAt(0)).setTextSize(12);
}
// 再把当前被选中的Tab文字设置为大字体
((TextView) ((LinearLayout) mLinearLayout.getChildAt(mTabLayout.getSelectedTabPosition())).getChildAt(1)).setTextSize(30);

// 当选中的Tab切换时,再调整Tab的字体大小
mTabLayout.setOnTabSelectedListener(new TabLayout.OnTabSelectedListener() {
    @Override
    public void onTabSelected(TabLayout.Tab tab) {
        ((TextView) ((LinearLayout) mLinearLayout.getChildAt(tab.getPosition())).getChildAt(1)).setTextSize(30);
    }

    @Override
    public void onTabUnselected(TabLayout.Tab tab) {
        ((TextView) ((LinearLayout) mLinearLayout.getChildAt(tab.getPosition())).getChildAt(1)).setTextSize(12);
    }

    @Override
    public void onTabReselected(TabLayout.Tab tab) {

    }
});

上面代码中把得到的Tab强转成LinearLayout,这是因为Tab实际上是TabView,而TabView继承自LinearLayout,所以可以这样转换,我们可以看下TabLayout的newTab()方法:

通过newTab()源码查看TabView本质

然而事实并不如意,完全没有效果,去看了下源码,也不是很确定,我的猜想是这样的,当我们对Tab中的文本控件设置字体大小后,TabView的onMeasuer()方法会被重新调用,而这个方法里就对文字大小重新进行赋值,导致文字大小没法按上面的方式进行修改。

TabLayout源码中对Tab文字大小做了限制

所以,文字的大小只能通过Style的方法去修改,且只能统一设置选中和未选中的文字大小,故,这个需求没法完成。

4、自定义指示器长度

其实这有点标题党的意思了,TabLayout的指示器长度没法指定,它原本多长就是多长,但可以通过设置Tab外间距的方式,让指示器看起来像是与Tab保持一定距离,这里我在网上找到了方法,方法如下:

// 设置TabLayout的“长度”
setIndicator(mTabLayout,10,10);

// 具体方法(通过反射的方式)
public void setIndicator(TabLayout tabs, int leftDip, int rightDip) {
    Class<?> tabLayout = tabs.getClass();
    Field tabStrip = null;
    try {
        tabStrip = tabLayout.getDeclaredField("mTabStrip");
    } catch (NoSuchFieldException e) {
        e.printStackTrace();
    }

    tabStrip.setAccessible(true);
    LinearLayout llTab = null;
    try {
        llTab = (LinearLayout) tabStrip.get(tabs);
    } catch (IllegalAccessException e) {
        e.printStackTrace();
    }

    int left = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, leftDip, Resources.getSystem().getDisplayMetrics());
    int right = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, rightDip, Resources.getSystem().getDisplayMetrics());

    for (int i = 0; i < llTab.getChildCount(); i++) {
        View child = llTab.getChildAt(i);
        child.setPadding(0, 0, 0, 0);
        LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1);
        params.leftMargin = left;
        params.rightMargin = right;
        child.setLayoutParams(params);
        child.invalidate();
    }
}

这个setIndicator()方法主要是通过反射的方式,先拿到mTabStrip(其实就是TabLayout直接包裹的LinearLayout),再遍历出mTabStrip中的子控件Tab,再设置Tab的外间距,为了证明就是设置了Tab的外间距,这里我分别对mTabStrip设置了背景色和不设置其背景色,来看看对比:

mTabStrip设置了背景色

mTabStrip不设置背景色

设置了背景色看起来效果还马马虎虎吧,但这样的方式没办法让指示器的长度比文字长度短(无奈~)。好了,不管这个了,既然我前面说了mTabStrip其实就是TabLayout直接包裹的LinearLayout,那通过这个LinearLayout来设置也是可以的,证明一下:

// 得到TabLayout包裹的LinearLayout并设置背景色
mLinearLayout = (LinearLayout) mTabLayout.getChildAt(0);
mLinearLayout.setBackgroundColor(getResources().getColor(R.color.colorPrimaryDark));
...
// 设置LinearLayout中子View(Tab)的外间距
int left = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, Resources.getSystem().getDisplayMetrics());
int right = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, 10, Resources.getSystem().getDisplayMetrics());
for (int i = 0; i < mLinearLayout.getChildCount(); i++) {
    View tabView = mLinearLayout.getChildAt(0);
    LinearLayout.LayoutParams params = new LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, 1);
    params.leftMargin = left;
    params.rightMargin = right;
    tabView.setLayoutParams(params);
}
通过查找控件方式,让指示器与Tab存在间距

好,到这里我对TabLayout的探索之旅就结束了,本文通过直接查找TabLayout中控件的方式,来自定义TabLayout本身没法直接设置的样式效果,从而来满足我们项目的需求。这仅仅是我个人对TabLayout的理解,可能存在些瑕疵,请多包涵,如果对“为TabLayout设置不同状态下的字体大小”和“自定义指示器长度”其他确实可行的方法,请留言告诉我一下吧,多多指教,谢谢。

最后附上Demo链接

https://github.com/GitLqr/MaterialDesignDemo

推荐阅读更多精彩内容