反射改变TabLayout属性

目录介绍

  • 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/yangchong211/YCBlogs
  • 若是以为好,能够star一下,谢谢!固然也欢迎提出建议,万事起于忽微,量变引发质变!

01.遇到的实际需求分析

  • 实际开发中UI的效果图
    • 通常要求文字内容和指示线的宽度要同样
    • image
  • 使用TabLayout的效果图
    • 通常指示线的宽度要大于文字内容
    • image
  • 遇到问题分析
    • 设置tabPaddingStart和tabPaddingEnd,可是布局填上去后发现并无用。
  • 实现方案
    • 第一种:自定义相似TabLayout的控件,代码量巨大,且GitHub上有许多已经比较成熟的库,代码质量是层次不齐。
    • 第二种:在原有基础上经过继承TabLayout控件,重写其中几个方法,而且经过反射来修改部分属性,也能达到第一种方案效果。
    • 下面就来说一下我本身经过第二种方案实现步骤和原理!
  • 最终UI效果图展现
    • image

02.原生TabLayout局限

  • 一张图看懂TabLayout的结构
    • image
    • 若是要用代码进行表示的话,大概是这样的。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添加上。
    @Override public 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 * @throws NoSuchFieldException */ 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方法
    @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); } } 
  • 而后看一下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这个方法作了什么操做
    @Override
    protected 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 * @param endX 结束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 * @throws NoSuchFieldException */ 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.关于博客汇总连接

02.关于个人博客

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

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

相关文章
相关标签/搜索