TabLayout 自定义 Indicator 指示线宽度样式

8,222 阅读8分钟
原文链接: yongyu.itscoder.com

转载请附原文链接:TabLayout 自定义 Indicator 指示线宽度样式

需求

最近写公司项目的时候遇到一个需求是两个界面滑动左右切换,点击标题界面也切换,效果如下:

tablayout

这个 UI 界面第一想法就是用 TabLayout+Fragment去做,但是仔细观察这个 UI 效果你会发现一个比较蛋疼的问题,就是选中标题的指示线 Indicator 长度问题, TabLayout 默认情况下指示线会比标题文字长出一部分,并且没有设置指示线长度的 API 可调用。下面是 TabLayout 原生效果图:

tablayout_origin

解决方案

既然原生的 TabLayout 没有可以设置指示线宽度的 API ,我们有两种方案去实现这个效果:

方案 1 自定义 View

不用系统的 TabLayout ,自定义一个 View,然后给 View 设置点击事件并结合监听 ViewPager 的滑动,计算指示线的滑动位置,再给添加个动画就可以实现这个效果,很简单,但是这种方案不适合我们项目,因为我们项目上个版本就有这种 UI 效果,同事是用 TabLayout ,但是指示线就是原生的效果,这个版本设计要求全部改成全部改成需求第一个 Gif 图效果,如果我们自定义 View 实现的话,代码改动太大,这是我们程序员最不希望做的,所以这种方案这里我就不写了。感兴趣的自己玩玩。

方案 2 TabLayoutHelper 实现

TabLayout 源码

在 TabLayout 的基础上去实现,一开始想,如果那个指示线如果是一个 View 就好办了,我们可以通过 view.getChildAt(n) 方法找到这个 View,然后动态给这个指示线 View 设置一个宽带就解决了,所以先去源码,找了一下发现代码如下:

    @Override
    public void draw(Canvas canvas) {
        super.draw(canvas);
        // Thick colored underline below the current selection
        if (mIndicatorLeft >= 0 && mIndicatorRight > mIndicatorLeft) {
            canvas.drawRect(mIndicatorLeft, getHeight() - mSelectedIndicatorHeight,
                    mIndicatorRight, getHeight(), mSelectedIndicatorPaint);
        }
    }
}

完犊子了,这个指示线是 draw画出来的,所以这种想法破灭了,既然看到这了,我们所以研究下这个指示器线宽度是根据啥画的。从头捋,先看一下 TabLayout 是个什么东西:

   public class TabLayout extends HorizontalScrollView {
    private Tab mSelectedTab;
    private final SlidingTabStrip mTabStrip;
  .......
    int mTabGravity;
    int mMode;
    ViewPager mViewPager;
    private PagerAdapter mPagerAdapter;
      public TabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
        super(context, attrs, defStyleAttr);
        ThemeUtils.checkAppCompatTheme(context);
        setHorizontalScrollBarEnabled(false);
         //关键代码,创建了一个 SlidingTabStrip,然后添加到 TabLayout
        mTabStrip = new SlidingTabStrip(context);
        super.addView(mTabStrip, 0, new HorizontalScrollView.LayoutParams(
                LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT));
      }
}

emmm, 继承自 HorizontalScrollView,这就是很多 Tab 时候可以滑动的原因了。这里面关键代码是 super.addView(mTabStrip,...) TabLayout 中添加了一个 SlidingTabStrip,那么其实主要方法基本就是看 SlidingTabStrip 类了。好了现在我们从使用 Tablayout 经常调用的 方法开始:

public void addTab(@NonNull Tab tab, int position, boolean setSelected) {
 ..
    configureTab(tab, position);
    addTabView(tab);
..
}
 private void addTabView(Tab tab) {
        final TabView tabView = tab.mView;
        mTabStrip.addView(tabView, tab.getPosition(), createLayoutParamsForTabs());
    }

代码很简单,调用 addTab 最终会调用 mTabStrip.addView 果然是主要是看 SlidingTabStrip 这个类, 这里看一下 TabView :

class TabView extends LinearLayout {
    private Tab mTab;
   //记录我们设置的 title
    private TextView mTextView;
    private ImageView mIconView;
//可以自己添加的 view
    private View mCustomView;
    private TextView mCustomTextView;
    private ImageView mCustomIconView;
    private int mDefaultMaxLines = 2;

TabView 是我们调用 addTabView 方法时候创建的,用于显示我们设置的 Tittle ,继承自 LinearLayout 但是没有记录 Indicator 代码,那么接着看 mTabStrip :

private class SlidingTabStrip extends LinearLayout {
    private int mSelectedIndicatorHeight;
    private final Paint mSelectedIndicatorPaint;
    int mSelectedPosition = -1;
    float mSelectionOffset;
    private int mLayoutDirection = -1;
    private int mIndicatorLeft = -1;
    private int mIndicatorRight = -1;

emmm,这里找到了在 draw 里面使用的变量 mIndicatorLeft ,有了 Indicator 踪影。也就是说这个 Indicator 是在 SlidingTabStrip 这个类中画出来的,那么最追 mIndicatorLeft 是根据什么设置的:

void setIndicatorPosition(int left, int right) {
    if (left != mIndicatorLeft || right != mIndicatorRight) {
       //来源
        mIndicatorLeft = left;
        mIndicatorRight = right;
        ViewCompat.postInvalidateOnAnimation(this);
    }
}
   private void updateIndicatorPosition() {
      //这个 selectedTitle 就是 TabView
            final View selectedTitle = getChildAt(mSelectedPosition);
            int left, right;
            if (selectedTitle != null && selectedTitle.getWidth() > 0) {
               //这里设置了绘制的左右坐标点就是 TabView 一样,所以
               //Indicator 宽度总是和 TabView 一样宽的
                left = selectedTitle.getLeft();
                right = selectedTitle.getRight();
                if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) {
                    View nextTitle = getChildAt(mSelectedPosition + 1);
                    left = (int) (mSelectionOffset * nextTitle.getLeft() +
                            (1.0f - mSelectionOffset) * left);
                    right = (int) (mSelectionOffset * nextTitle.getRight() +
                            (1.0f - mSelectionOffset) * right);
                }
            } else {
                left = right = -1;
            }
            setIndicatorPosition(left, right);
        }

上面代码可以得知 Indicator 宽度总是和我们添加的 TabView 一样宽的,那么 TabView 的宽度我们一般也没有去设置,它又是根据什么设置的呢,为什么我们两个字的 TabView 和 4 个字的 TabView 是一样宽度,那么就去看 TabViewonMeasure 方法,这里具体方法代码就补贴出来了,反正结果就是里面没发现设置使得两个字的 TabView 和 4 个字的 TabView 是一样宽度的代码, 我们知道 TabView 是添加在 SlidingTabStrip 中,父布局会影响子视图宽度,那么继续看 SlidingTabStriponMeasure

@Override
protected void onMeasure(final int widthMeasureSpec, final int heightMeasureSpec) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec);
    if (MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY) {
        return;
    }
    if (mMode == MODE_FIXED && mTabGravity == GRAVITY_CENTER) {
        final int count = getChildCount();
        //这里遍历子视图找到子视图中最大宽度并记录下来
        int largestTabWidth = 0;
        for (int i = 0, z = count; i < z; i++) {
            View child = getChildAt(i);
            if (child.getVisibility() == VISIBLE) {
                largestTabWidth = Math.max(largestTabWidth, child.getMeasuredWidth());
            }
        }
        if (largestTabWidth <= 0) {
          
            return;
        }
        final int gutter = dpToPx(FIXED_WRAP_GUTTER_MIN);
        boolean remeasure = false;
        if (largestTabWidth * count <= getMeasuredWidth() - gutter * 2) {
           //重点看这里,遍历子视图, lp.width = largestTabWidth; 将所有子视图宽度设置为子视图中最大宽度
           //然后 lp.weight = 0; 进行均分剩余父布局空间,解密了。
            for (int i = 0; i < count; i++) {
                final LinearLayout.LayoutParams lp =
                        (LayoutParams) getChildAt(i).getLayoutParams();
                if (lp.width != largestTabWidth || lp.weight != 0) {
                    lp.width = largestTabWidth;
                    lp.weight = 0;
                    remeasure = true;
                }
            }
        } else {
            // If the tabs will wrap to be larger than the width minus gutters, we need
            // to switch to GRAVITY_FILL
            mTabGravity = GRAVITY_FILL;
            updateTabViews(false);
            remeasure = true;
        }
        if (remeasure) {
            // Now re-measure after our changes
            super.onMeasure(widthMeasureSpec, heightMeasureSpec);
        }
    }
}

我们发现在 SlidingTabStrip onMeasure 放在中遍历子视图将所有子视图宽度设置为子视图中最大宽度然后均分剩余父布局空间,到这里我们彻底明白了 TabLayout 的布局规则。

google 大法好

通过上面 TabLayout 源码是梳理,我们发现不太可能实现 Indicator 指示线比 TabView 短了,那么我们先 goolge 一下,看看网友是怎么处理的,下面是 stackoverflow 上面的解决方案 Android Tab layout: Wrap tab indicator width with respect to tab title:

 public void wrapTabIndicatorToTitle(TabLayout tabLayout, int externalMargin, int internalMargin) {
        View tabStrip = tabLayout.getChildAt(0);
        if (tabStrip instanceof ViewGroup) {
            ViewGroup tabStripGroup = (ViewGroup) tabStrip;
            int childCount = ((ViewGroup) tabStrip).getChildCount();
            for (int i = 0; i < childCount; i++) {
                View tabView = tabStripGroup.getChildAt(i);
                //set minimum width to 0 for instead for small texts, indicator is not wrapped as expected
                tabView.setMinimumWidth(0);
                // set padding to 0 for wrapping indicator as title
                tabView.setPadding(0, tabView.getPaddingTop(), 0, tabView.getPaddingBottom());
                // setting custom margin between tabs
                if (tabView.getLayoutParams() instanceof ViewGroup.MarginLayoutParams) {
                    ViewGroup.MarginLayoutParams layoutParams = (ViewGroup.MarginLayoutParams) tabView.getLayoutParams();
                    if (i == 0) {
                        // left
                        setMargin(layoutParams, externalMargin, internalMargin);
                    } else if (i == childCount - 1) {
                        // right
                        setMargin(layoutParams, internalMargin, externalMargin);
                    } else {
                        // internal
                        setMargin(layoutParams, internalMargin, internalMargin);
                    }
                }
            }
            tabLayout.requestLayout();
        }
}
private void settingMargin(ViewGroup.MarginLayoutParams layoutParams, int start, int end) {
    if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.JELLY_BEAN_MR1) {
        layoutParams.setMarginStart(start);
        layoutParams.setMarginEnd(end);
    } else {
        layoutParams.leftMargin = start;
        layoutParams.rightMargin = end;
    }
}

上面解决方案,只是将 TabView 的 左右 padding 值设置为0,使得指示线宽度和文字 Title 宽度一样长,无法达到指示线宽度比文字宽度短的效果,显然这种解决方案是不行的

setCustomView 解决方案

还记得 TabView 的源码中有个 mCustomView

class TabView extends LinearLayout {
    private Tab mTab;
    private TextView mTextView;
    private ImageView mIconView;
   //自定义的 view
    private View mCustomView;
    private TextView mCustomTextView;
    private ImageView mCustomIconView;
    private int mDefaultMaxLines = 2;
    public TabView(Context context) {
        super(context);
    }

而且还有个 setCustomView 方法

/**
 * Set a custom view to be used for this tab.
 * If the inflated layout contains a {@link TextView} with an ID of
 * {@link android.R.id#text1} then that will be updated with the value given
 * to {@link #setText(CharSequence)}. Similarly, if this layout contains an
 * {@link ImageView} with ID {@link android.R.id#icon} then it will be updated with
 * the value given to {@link #setIcon(Drawable)}.
 * @param resId A layout resource to inflate and use as a custom tab view
 * @return The current instance for call chaining
 */
@NonNull
public Tab setCustomView(@LayoutRes int resId) {
    final LayoutInflater inflater = LayoutInflater.from(mView.getContext());
    return setCustomView(inflater.inflate(resId, mView, false));
}

注释已经写的很清楚,就是这个 view 用于当前这个 tab,那么事情就好办了,我们自己定义一个 View ,然后包含一个 TextView 和一个指示线 View ,那么这个指示线 View 我们想设置成什么样都可以了。下面代码就很简单直接贴出来:

//item_tab_view.xml,用于我们自定义的 custumView
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent">
    <TextView
        android:gravity="center"
        android:layout_gravity="center"
        android:id="@+id/tv_tab"
        android:layout_width="match_parent"
        android:layout_height="match_parent" />
    <View
        android:visibility="invisible"
        android:id="@+id/view_indicator"
        android:layout_gravity="bottom|center"
        android:background="@drawable/bg_tab_red"
        android:layout_width="match_parent"
        android:layout_height="3dp"/>
</FrameLayout>
private void initView(final TabLayout tabLayout) {
    builder.getTabLayout().post(new Runnable() {
        @Override
        public void run() {
            try {
                LinearLayout childAt = (LinearLayout) tabLayout.getChildAt(0);
                for (int j = 0; j < childAt.getChildCount(); j++) {
                    TabLayout.Tab tab = tabLayout.getTabAt(j);
                    if (tab == null) return;
                    CharSequence text = tab.getText();
                    tab.setCustomView(R.layout.item_tab_view);
                    if (tab.getCustomView() == null) return;
                    View customView = tab.getCustomView();
                    TextView textView = (TextView) customView.findViewById(R.id.tv_tab);
                    textView.setText(text);
                    if(builder.getNormalBackgroundColor()!=0){
                        textView.setBackgroundColor(builder.getNormalBackgroundColor());
                    }
                    View indicator = customView.findViewById(R.id.view_indicator);
                    if (j == 0) {
                        int color = builder.getSelectedTextColor();
                        if (color == 0) {
                            color = tabLayout.getContext().getResources().getColor(R.color.color_000000);
                        }
                        textView.setTextColor(color);
                        if (builder.isSelectedBold()) {
                            textView.setTypeface(Typeface.defaultFromStyle(Typeface.BOLD));
                        }
                        if (builder.getSelectedBackgroundColor() != 0) {
                            textView.setBackgroundColor(builder.getSelectedBackgroundColor());
                        }
                        indicator.setVisibility(View.VISIBLE);
                    }
                    FrameLayout.LayoutParams indicatorLayout = (FrameLayout.LayoutParams) indicator.getLayoutParams();
                    if (builder.getIndicatorWith() != 0) {
                        indicatorLayout.width = builder.getIndicatorWith();
                    }
                    if (builder.getIndicatorHeight() != 0) {
                        indicatorLayout.height = builder.getIndicatorHeight();
                    }
                    if (builder.getIndicatorColor() != 0) {
                        indicator.setBackgroundColor(builder.getIndicatorColor());
                    }
                    if(builder.getIndicatorMargin()!=0){
                        indicatorLayout.rightMargin=builder.getIndicatorMargin();
                        indicatorLayout.leftMargin=builder.getIndicatorMargin();
                    }
                    if(builder.getIndicatorDrawable()!=0){
                        indicator.setBackgroundResource(builder.getIndicatorDrawable());
                    }
                    childAt.getChildAt(j).setPadding(builder.getTabItemPadding(), 0, builder.getTabItemPadding(), 0);
                    LinearLayout.LayoutParams lLayoutParams = (LinearLayout.LayoutParams) childAt
                            .getChildAt(j).getLayoutParams();
                    lLayoutParams.rightMargin = builder.getTabItemMarginRight();
                    lLayoutParams.leftMargin = builder.getTabItemMarginLeft();
                    if (builder.getTabItemWith() != 0) {
                        lLayoutParams.width = builder.getTabItemWith();
                    }
                }
            } catch (Exception e) {
                e.printStackTrace();
            }
        }
    });
}

上面代码很简单,没什么好说的,但是大家会看到一个 builder 对象,而且把 TabLayout 对象传递传递进来进行设置, 没错,我们采用了 Builder + Helper 模式去实现的,Builder 把需要的设置的属性存起来,Helper 去设置 TabLayout 属性,而不是继承自 TabLayout 自定义 一个 View 方式,因为如果自定义 View 的话,代码侵入性太强,已经使用了 TabLayout 的地方需要改动原来代码,而才 TabLayoutHelper 我们不需要改动原来代码,只需要在原来基础上调用几行代码即可:

private void setTabLayout() {
    new TabLayoutHelper.Builder(tabLayout)
            .setIndicatorColor(Color.BLUE)
            .setIndicatorHeight(6)
            .setIndicatorWith(100)
            .setTabItemMarginLeft(20)
            .setIndicatorDrawable(R.drawable.bg_tab_red)
            .setNormalTextColor(Color.GRAY)
            .setSelectedTextColor(Color.RED)
            .setSelectedBold(true)
            .setIndicatorMargin(40)
           .setTabItemWith(300)
            .setTabItemPadding(20)
            .setSelectedBackgroundColor(Color.YELLOW)
          .setNormalBackgroundColor(Color.DKGRAY)
            .setTabItemMarginLeft(20)
            .build();
}

使用起来很简单,根据自己的需要设置即可。

总结:这种解决方案优点是指示线宽度样式自己可以随便设置,缺点是没有了原来的滑动动画效果。

项目地址:TabLayoutHelper