反射改變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

相關文章
相關標籤/搜索