目录介绍
- 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 添加上。
@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
*/
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
*/
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…