目录介绍

  • 01.遇到的实际需求分析
  • 02.原生TabLayout局限
  • 03.TabLayout源码解析

    • 3.1 Tab选项卡如何实现
    • 3.2 滑动切换Tab选项卡
    • 3.3 Tab选项卡指示线宽度
  • 04.设置自定义tabView选项卡
  • 05.自定义指示器的长度
  • 06.设置滑动改变选项卡颜色
  • 07.使用反射的注意要点
  • 08.混淆时用到反射注意项

好消息

  • 博客笔记大汇总【16年3月到至今】,包括Java基础及深入知识点,Android技术博客,Python学习笔记等等,还包括平时开发中遇到的bug汇总,当然也在工作之余收集了大量的面试题,长期更新维护并且修正,持续完善……开源的文件是markdown格式的!同时也开源了生活博客,从12年起,积累共计N篇[近100万字,陆续搬到网上],转载请注明出处,谢谢!
  • 链接地址:https://github.com/yangchong2...
  • 如果觉得好,可以star一下,谢谢!当然也欢迎提出建议,万事起于忽微,量变引起质变!

01.遇到的实际需求分析

  • 实际开发中UI的效果图

    • 一般要求文字内容和指示线的宽度要一样
  • 使用TabLayout的效果图

    • 一般指示线的宽度要大于文字内容
  • 遇到问题分析

    • 设置tabPaddingStart和tabPaddingEnd,但是布局填上去后发现并没有用。
  • 实现方案

    • 第一种:自定义类似TabLayout的控件,代码量巨大,且GitHub上有许多已经比较成熟的库,代码质量是层次不齐。
    • 第二种:在原有基础上通过继承TabLayout控件,重写其中几个方法,并且通过反射来修改部分属性,也能达到第一种方案效果。
    • 下面就来讲一下我自己通过第二种方案实现步骤和原理!
  • 最终UI效果图展示

02.原生TabLayout局限

  • 一张图看懂TabLayout的结构

    • 如果要用代码进行表示的话,大概是这样的。TabLayout继承自HorizontalScrollView,而都知道ScrollView只能添加一个子 View,所以SlidingTabIndicator就是那个用来添加子View 的横向LinearLayout。
    //28版本代码public class TabLayout extends HorizontalScrollView {    private class SlidingTabIndicator extends LinearLayout {    }}
  • 存在的局限性

    • 第一个无法改变指示线的宽度
    • 第二个无法做到滑动改变tab选项卡颜色渐变的效果【有的还需要放大效果】

03.TabLayout源码解析

3.1 Tab选项卡如何实现

  • 第一种方式,直接通过addTab方法添加tab选项卡,代码如下所示

    TabLayout.Tab tab = tabLayout.newTab();View tabView = new TextView(this);tabLayout.setCustomView(tabView);tabLayout.addTab(tab);
  • 第二种方式,通过设置FragmentPagerAdapter中的getPageTitle也可以添加tab选项卡,代码如下所示

    mTitleList.add("潇湘剑雨");FragmentManager supportFragmentManager = getSupportFragmentManager();PagerAdapter myAdapter = new PagerAdapter(supportFragmentManager, mFragments, mTitleList);tabLayout.setAdapter(myAdapter);public class PagerAdapter extends FragmentPagerAdapter {    private List<?> mFragment;    private List<String> mTitleList;    public PagerAdapter(FragmentManager fm, List<?> mFragment, List<String> mTitleList) {        super(fm);        this.mFragment = mFragment;        this.mTitleList = mTitleList;    }    @Override    public CharSequence getPageTitle(int position) {        if (mTitleList != null) {            return mTitleList.get(position);        } else {            return "";        }    }}
    • 接下来看一下tabLayout源码是如何拿到getPageTitle方法的内容而达到设置addTab的目的。主要看源码中的populateFromPagerAdapter方法。看到下面代码是不是豁然开朗了……
    void populateFromPagerAdapter() {    this.removeAllTabs();    if (this.pagerAdapter != null) {        int adapterCount = this.pagerAdapter.getCount();        int curItem;        for(curItem = 0; curItem < adapterCount; ++curItem) {            this.addTab(this.newTab().setText(this.pagerAdapter.getPageTitle(curItem)), false);        }        if (this.viewPager != null && adapterCount > 0) {            curItem = this.viewPager.getCurrentItem();            if (curItem != this.getSelectedTabPosition() && curItem < this.getTabCount()) {                this.selectTab(this.getTabAt(curItem));            }        }    }}
  • 不管是上面那种方式,那么如何将tab添加到SlidingTabIndicator布局中呢?

    • 通过下面代码可以看到,最终是通过slidingTabIndicator对象调用addView将tabView添加到SlidingTabIndicator布局之中的。
    public void addTab(@NonNull TabLayout.Tab tab, int position, boolean setSelected) {    if (tab.parent != this) {        throw new IllegalArgumentException("Tab belongs to a different TabLayout.");    } else {        this.configureTab(tab, position);        this.addTabView(tab);        if (setSelected) {            tab.select();        }    }}private void addTabView(TabLayout.Tab tab) {    TabLayout.TabView tabView = tab.view;    this.slidingTabIndicator.addView(tabView, tab.getPosition(), this.createLayoutParamsForTabs());}
  • 为什么要分析这个addTab?

    • 因为需求说了,需要在滑动的时候,随着滑动而改变tabView的文字颜色,这一点原生TabLayout并没有实现。所以要实现这个逻辑,就必须重写TabLayout的addTab方法,然后将自己自定义的tabView添加到tab中,这个下面会讲如何实现……

3.2 滑动切换Tab选项卡

  • 第一步:随着页面的滑动文字颜色渐变那么肯定少不了ViewPager的页面监听,这个在我们调用setupWithViewPager的时候TabLayout就已经添加监听。那么先来看下源码监听滑动是如何实现的?

    • 绑定 ViewPager 只需要一行代码mTabLayout.setupWithViewPager(mViewPager)即可。
    • 可以看到当viewPager不为null的时候,先移除listener监听事件。然后在创建listener监听,并且重置状态。
    private void setupWithViewPager(@Nullable ViewPager viewPager, boolean autoRefresh, boolean implicitSetup) {    if (this.viewPager != null) {        if (this.pageChangeListener != null) {            this.viewPager.removeOnPageChangeListener(this.pageChangeListener);        }        if (this.adapterChangeListener != null) {            this.viewPager.removeOnAdapterChangeListener(this.adapterChangeListener);        }    }    if (this.currentVpSelectedListener != null) {        this.removeOnTabSelectedListener(this.currentVpSelectedListener);        this.currentVpSelectedListener = null;    }    if (viewPager != null) {        this.viewPager = viewPager;        if (this.pageChangeListener == null) {            this.pageChangeListener = new TabLayout.TabLayoutOnPageChangeListener(this);        }        this.pageChangeListener.reset();        viewPager.addOnPageChangeListener(this.pageChangeListener);        this.currentVpSelectedListener = new TabLayout.ViewPagerOnTabSelectedListener(viewPager);        this.addOnTabSelectedListener(this.currentVpSelectedListener);        PagerAdapter adapter = viewPager.getAdapter();        if (adapter != null) {            this.setPagerAdapter(adapter, autoRefresh);        }        if (this.adapterChangeListener == null) {            this.adapterChangeListener = new TabLayout.AdapterChangeListener();        }        this.adapterChangeListener.setAutoRefresh(autoRefresh);        viewPager.addOnAdapterChangeListener(this.adapterChangeListener);        this.setScrollPosition(viewPager.getCurrentItem(), 0.0F, true);    } else {        this.viewPager = null;        this.setPagerAdapter((PagerAdapter)null, false);    }    this.setupViewPagerImplicitly = implicitSetup;}
  • 那么滑动是如何切换选项卡和指示线呢,具体看一下TabLayoutOnPageChangeListener滑动监听源码。

    • 主要是看onPageSelected方法,该方法是通过tabLayout.selectTab来切换选项卡的。
    public static class TabLayoutOnPageChangeListener implements OnPageChangeListener {    public TabLayoutOnPageChangeListener(TabLayout tabLayout) {        this.tabLayoutRef = new WeakReference(tabLayout);    }    public void onPageScrollStateChanged(int state) {        this.previousScrollState = this.scrollState;        this.scrollState = state;    }    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {        TabLayout tabLayout = (TabLayout)this.tabLayoutRef.get();        if (tabLayout != null) {            boolean updateText = this.scrollState != 2 || this.previousScrollState == 1;            boolean updateIndicator = this.scrollState != 2 || this.previousScrollState != 0;            tabLayout.setScrollPosition(position, positionOffset, updateText, updateIndicator);        }    }    public void onPageSelected(int position) {        TabLayout tabLayout = (TabLayout)this.tabLayoutRef.get();        if (tabLayout != null && tabLayout.getSelectedTabPosition() != position && position < tabLayout.getTabCount()) {            boolean updateIndicator = this.scrollState == 0 || this.scrollState == 2 && this.previousScrollState == 0;            tabLayout.selectTab(tabLayout.getTabAt(position), updateIndicator);        }    }}
  • 知道了滑动切换选项卡后,就思考一下,能否通过反射来使用自己的滑动监听事件,然后在onPageSelected方法中,滑动改变选项卡中文字的颜色,或者缩放的功能呢。答案是可以的。

3.3 Tab选项卡指示线宽度

  • 具体可以看updateIndicatorPosition源码

    • 可以看到先获取当前滑动位置的tabView,如果内容不为空,则获取左右的位置。
    • 在滑块滑动的时候,如果滑动超过了上一个或是下一个滑块一半的话。那就说明移动到了上一个或是下一个滑块,然后取出left和right
    • 最后设置滑块的位置
    private void updateIndicatorPosition() {    //根据当前滑块的位置拿到当前TabView    View selectedTitle = this.getChildAt(this.selectedPosition);    int left;    int right;    if (selectedTitle != null && selectedTitle.getWidth() > 0) {        //拿到TabView的左、右位置        left = selectedTitle.getLeft();        right = selectedTitle.getRight();        if (!TabLayout.this.tabIndicatorFullWidth && selectedTitle instanceof TabLayout.TabView) {            this.calculateTabViewContentBounds((TabLayout.TabView)selectedTitle, TabLayout.this.tabViewContentBounds);            left = (int)TabLayout.this.tabViewContentBounds.left;            right = (int)TabLayout.this.tabViewContentBounds.right;        }        //在滑块滑动的时候,如果滑动超过了上一个或是下一个滑块一半的话        //那就说明移动到了上一个或是下一个滑块,然后取出left和right        if (this.selectionOffset > 0.0F && this.selectedPosition < this.getChildCount() - 1) {            View nextTitle = this.getChildAt(this.selectedPosition + 1);            int nextTitleLeft = nextTitle.getLeft();            int nextTitleRight = nextTitle.getRight();            if (!TabLayout.this.tabIndicatorFullWidth && nextTitle instanceof TabLayout.TabView) {                this.calculateTabViewContentBounds((TabLayout.TabView)nextTitle, TabLayout.this.tabViewContentBounds);                nextTitleLeft = (int)TabLayout.this.tabViewContentBounds.left;                nextTitleRight = (int)TabLayout.this.tabViewContentBounds.right;            }            left = (int)(this.selectionOffset * (float)nextTitleLeft + (1.0F - this.selectionOffset) * (float)left);            right = (int)(this.selectionOffset * (float)nextTitleRight + (1.0F - this.selectionOffset) * (float)right);        }    } else {        right = -1;        left = -1;    }    //设置滑块的位置    this.setIndicatorPosition(left, right);}
  • 然后看一下setIndicatorPosition的代码

    • 设置滑块的宽度是根据子TabView的宽度来设置的,也就是说,TabView的宽度是多少,那么滑块的宽度就是多少。
    void setIndicatorPosition(int left, int right) {    if (left != this.indicatorLeft || right != this.indicatorRight) {        this.indicatorLeft = left;        this.indicatorRight = right;        ViewCompat.postInvalidateOnAnimation(this);    }}
  • 为何要分析这个?

    • 因为如果你要改变指示器的宽度,那么必须要能够动态改变左右的位置。知道了这个大概的原理,那么下面利用反射设置选项卡左右的间距来改变指示器的长度就知道怎么实现呢。

04.实现滑动改变颜色

  • 滑动改变指示器文字变色

    • TabLayout中可以设置文字内容,通过上面3.2源码分析,可以知道通过addTab添加自定义选项卡,那么滑动改变选项卡tabView的颜色,可以会涉及到监听滑动。因此这里需要用反射替换成自己的滑动监听,然后在TabLayoutOnPageChangeListener的监听类中的onPageScrolled方法,改变tabView的颜色。
  • 通过反射找到源码中pageChangeListener成员变量,然后设置暴力访问权限。

    • 然后获取TabLayoutOnPageChangeListener的对象,删除自带的监听,同时将自己自定义的滑动监听listener添加上。
    @Overridepublic void setupWithViewPager(@Nullable ViewPager viewPager, boolean autoRefresh) {    super.setupWithViewPager(viewPager, autoRefresh);    try {        //通过反射找到mPageChangeListener        Field field = getPageChangeListener();        field.setAccessible(true);        TabLayoutOnPageChangeListener listener = (TabLayoutOnPageChangeListener) field.get(this);        if (listener!=null && viewPager!=null) {            //删除自带监听            viewPager.removeOnPageChangeListener(listener);            OnPageChangeListener mPageChangeListener = new OnPageChangeListener(this);            mPageChangeListener.reset();            viewPager.addOnPageChangeListener(mPageChangeListener);        }    } catch (Exception e) {        e.printStackTrace();    }}
    • 然后看一下反射的代码,我在网上看到好多博客,没有区分27前和28后的问题。这个地方一定要注意一下!
    /** * 反射获取私有的mPageChangeListener属性,考虑support 28以后变量名修改的问题 * @return Field * @throws NoSuchFieldException */private Field getPageChangeListener() throws NoSuchFieldException {    Class clazz = TabLayout.class;    try {        // support design 27及一下版本        return clazz.getDeclaredField("mPageChangeListener");    } catch (NoSuchFieldException e) {        e.printStackTrace();        // 可能是28及以上版本        return clazz.getDeclaredField("pageChangeListener");    }}
  • 然后看一下自定义的OnPageChangeListener

    • 采用弱引用方式防止监听listener内存泄漏,算是一个小的优化
    /** * 滑动监听,核心逻辑 * 建议如果是activity退到后台,或者关闭页面,将listener给remove掉 * 采用弱引用方式防止监听listener内存泄漏,算是一个小的优化 */private static class OnPageChangeListener extends TabLayoutOnPageChangeListener {    private final WeakReference<CustomTabLayout> mTabLayoutRef;    private int mPreviousScrollState;    private int mScrollState;    OnPageChangeListener(TabLayout tabLayout) {        super(tabLayout);        mTabLayoutRef = new WeakReference<>((CustomTabLayout) tabLayout);    }    /**     * 这个方法是滚动状态发生变化是调用     * @param state                     桩体     */    @Override    public void onPageScrollStateChanged(final int state) {        mPreviousScrollState = mScrollState;        mScrollState = state;    }    /**     * 正在滚动时调用     * @param position                  索引     * @param positionOffset            offset偏移     * @param positionOffsetPixels      offsetPixels     */    @Override    public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {        super.onPageScrolled(position, positionOffset, positionOffsetPixels);        CustomTabLayout tabLayout = mTabLayoutRef.get();        if (tabLayout == null) {            return;        }        final boolean updateText = mScrollState != SCROLL_STATE_SETTLING ||                mPreviousScrollState == SCROLL_STATE_DRAGGING;        if (updateText) {            tabLayout.tabScrolled(position, positionOffset);        }    }    /**     * 选中时调用     * @param position                      索引     */    @Override    public void onPageSelected(int position) {        super.onPageSelected(position);        CustomTabLayout tabLayout = mTabLayoutRef.get();        mPreviousScrollState = SCROLL_STATE_SETTLING;        tabLayout.setSelectedView(position);    }    /**     * 重置状态     */    void reset() {        mPreviousScrollState = mScrollState = SCROLL_STATE_IDLE;    }}

05.自定义指示器的长度

  • 通过反射的方式修改指示器长度,如果需要指示器宽度等于文字宽度需要自己微调,或者28版本直接通过设置app:tabIndicatorFullWidth="false"属性即可让内容和指示器宽度一样。

    • 原理就是通过反射的方式获取TabLayout的字段mTabStrip(27之前)或者slidingTabIndicator(28之后),然后再去遍历修改每一个子 View 的 Margin 值。代码如下:
    /** * 通过反射设置TabLayout每一个的长度 * @param left                      左边 Margin 单位 dp * @param right                     右边 Margin 单位 dp */public void setIndicator(int left, int right) {    Field tabStrip = null;    try {        tabStrip = getTabStrip();        tabStrip.setAccessible(true);    } catch (NoSuchFieldException e) {        e.printStackTrace();    }    LinearLayout llTab = null;    try {        if (tabStrip != null) {            llTab = (LinearLayout) tabStrip.get(this);        }    } catch (Exception e) {        e.printStackTrace();    }    int l = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, left,            Resources.getSystem().getDisplayMetrics());    int r = (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_DIP, right,            Resources.getSystem().getDisplayMetrics());    if (llTab != null) {        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 = l;            params.rightMargin = r;            child.setLayoutParams(params);            child.invalidate();        }    }}
  • 然后看一下反射获取tabStrip的代码

    /** * 反射获取私有的mTabStrip属性,考虑support 28以后变量名修改的问题 * @return Field
 */private Field getTabStrip() throws NoSuchFieldException {    Class clazz = TabLayout.class;    try {        // support design 27及一下版本        return clazz.getDeclaredField("mTabStrip");    } catch (NoSuchFieldException e) {        e.printStackTrace();        // 可能是28及以上版本        return clazz.getDeclaredField("slidingTabIndicator");    }}```
  • 这里其实也可以不用反射,那么该怎么实现呢?

    • 需要注意一点,需要在Tablayout设置完成后操作,并且必须等所有绘制操作结束,使用tabLayout.post拿到属性参数,然后设置下margin。
    public void setTabWidth(TabLayout tabLayout){    //拿到slidingTabIndicator的布局    LinearLayout mTabStrip = (LinearLayout) tabLayout.getChildAt(0);    //遍历SlidingTabStrip的所有TabView子view    for (int i = 0; i < mTabStrip.getChildCount(); i++) {        View tabView = mTabStrip.getChildAt(i);        LinearLayout.LayoutParams params = (LinearLayout.LayoutParams)tabView.getLayoutParams();        //给TabView设置leftMargin和rightMargin        params.leftMargin = dp2px(10);        params.rightMargin = dp2px(10);        tabView.setLayoutParams(params);        //触发绘制        tabView.invalidate();    }}

06.设置滑动改变选项卡颜色

  • 滑动时如何改变选项卡的颜色呢?当然在滚动的时候去动态改变属性,具体的做法:
  • 在TabLayoutOnPageChangeListener中监听,主要看onPageScrolled方法

    @Overridepublic void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {    super.onPageScrolled(position, positionOffset, positionOffsetPixels);    CustomTabLayout tabLayout = mTabLayoutRef.get();    if (tabLayout == null) {        return;    }    final boolean updateText = mScrollState != SCROLL_STATE_SETTLING ||            mPreviousScrollState == SCROLL_STATE_DRAGGING;    if (updateText) {        tabLayout.tabScrolled(position, positionOffset);    }}
  • 然后看一下tabScrolled方法,代码如下所示

    • 这个方法里,主要是拿到当前tabView和下一个tabView,然后依次改变Progress进度,以此达到更改文字的颜色。
    /** * 滑动改变自定义tabView的颜色 * @param position                      索引 * @param positionOffset                偏移量 */private void tabScrolled(int position, float positionOffset) {    if (positionOffset == 0.0F) {        return;    }    //当前tabView    CustomTabView currentTrackView = getCustomTabView(position);    //下一个tabView    CustomTabView nextTrackView = getCustomTabView(position + 1);    if (currentTrackView != null) {        currentTrackView.setDirection(1);        currentTrackView.setProgress(1.0F - positionOffset);    }    if (nextTrackView != null) {        nextTrackView.setDirection(0);        nextTrackView.setProgress(positionOffset);    }}
  • 然后在CustomTabView中,看代码如下所示

    • 调用invalidate()方法会调用onDraw()方法,然后去达到重绘view的目的。
    public void setProgress(float progress) {    this.mProgress = progress;    invalidate();}
  • 接着看看onDraw这个方法做了什么操作

    @Overrideprotected void onDraw(Canvas canvas) {    super.onDraw(canvas);    if (mDirection == DIRECTION_LEFT) {        drawChangeLeft(canvas);        drawOriginLeft(canvas);    } else if (mDirection == DIRECTION_RIGHT) {        drawOriginRight(canvas);        drawChangeRight(canvas);    } else if (mDirection == DIRECTION_TOP) {        drawOriginTop(canvas);        drawChangeTop(canvas);    } else if (mDirection == DIRECTION_BOTTOM){        drawOriginBottom(canvas);        drawChangeBottom(canvas);    }}
  • 然后看其中的一个drawChangeLeft方法

    private void drawChangeLeft(Canvas canvas) {    drawTextHor(canvas, mTextChangeColor, mTextStartX,  (int) (mTextStartX + mProgress * mTextWidth));}/** * 横向 * @param canvas                    画板 * @param color                     颜色 * @param startX                    开始x
 */private void drawTextHor(Canvas canvas, int color, int startX, int endX) {    mPaint.setColor(color);    if (debug) {        mPaint.setStyle(Style.STROKE);        canvas.drawRect(startX, 0, endX, getMeasuredHeight(), mPaint);    }    canvas.save();    canvas.clipRect(startX, 0, endX, getMeasuredHeight());    // right, bottom    canvas.drawText(mText, mTextStartX, getMeasuredHeight() / 2                    - ((mPaint.descent() + mPaint.ascent()) / 2), mPaint);    canvas.restore();}```

07.使用反射的注意要点

  • 比如或者mTabStrip属性,网上许多没有区分27和28名称的变化。如果因为名称的问题,会导致反射获取不到Field,那么所做的操作也就失效了,这是一个很大的风险。

    /** * 反射获取私有的mTabStrip属性,考虑support 28以后变量名修改的问题 * @return Field
 */private Field getTabStrip() throws NoSuchFieldException {    Class clazz = TabLayout.class;    try {        // support design 27及一下版本        return clazz.getDeclaredField("mTabStrip");    } catch (NoSuchFieldException e) {        e.printStackTrace();        // 可能是28及以上版本        return clazz.getDeclaredField("slidingTabIndicator");    }}```

08.混淆时用到反射注意项

  • 还有一点就是有的人这么使用会报错,是因为混淆产生的问题,反射slidingTabIndicator或者pageChangeListener的时候可能会出问题,可以在混淆配置里面设置下TabLayout不被混淆。

    -keep class android.support.design.widget.TabLayout{*;}

其他介绍

01.关于博客汇总链接

  • 1.技术博客汇总
  • 2.开源项目汇总
  • 3.生活博客汇总
  • 4.喜马拉雅音频汇总
  • 5.其他汇总

02.关于我的博客

  • github:https://github.com/yangchong211
  • 知乎:https://www.zhihu.com/people/...
  • 简书:http://www.jianshu.com/u/b7b2...
  • csdn:http://my.csdn.net/m0_37700275
  • 喜马拉雅听书:http://www.ximalaya.com/zhubo...
  • 开源中国:https://my.oschina.net/zbj161...
  • 泡在网上的日子:http://www.jcodecraeer.com/me...
  • 邮箱:yangchong211@163.com
  • 阿里云博客:https://yq.aliyun.com/users/a... 239.headeruserinfo.3.dT4bcV
  • segmentfault头条:https://segmentfault.com/u/xi...
  • 掘金:https://juejin.im/user/593943...

博客汇总项目开源地址:https://github.com/yangchong2...

TabLayout项目开源地址:https://github.com/yangchong2...