文章目录
文章修修补补添加了不少,主要分3个时间段
- 最早实现,就是在tab切换的时候在下边画一条线
- 不画线了,因为看源码知道线条宽度和tabview一样的,所以反射修改TabView的宽度
- viewPager?.addOnPageChangeListener 根据offset,动态计算view当前的位置,线条应该偏移的位置。
- 进化后,想画啥画啥,不一定是线条,反正位置都算出来了。
结构分析
TabLayout这个导航控件,父类关系如下
public class TabLayout extends HorizontalScrollView
public class HorizontalScrollView extends FrameLayout
里边子空间的类型
class TabView extends LinearLayout implements OnLongClickListener
private class SlidingTabStrip extends LinearLayout
tablayout的结构图
大概看下源码,整体布局的添加如下,首先加了一个SlidingTabStrip也就是个线性布局.
public TabLayout(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
ThemeUtils.checkAppCompatTheme(context);
// Disable the Scroll Bar
setHorizontalScrollBarEnabled(false);
// Add the TabStrip
mTabStrip = new SlidingTabStrip(context);
super.addView(mTabStrip, 0, new HorizontalScrollView.LayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT));
完事通过绑定viewPager或者直接addTab来添加TabView
private void addTabView(Tab tab) {
final TabView tabView = tab.mView;
mTabStrip.addView(tabView, tab.getPosition(), createLayoutParamsForTabs());
}
添加的tabView的params如下,根据mode和gravity设置为比重为1或是wrap
private LinearLayout.LayoutParams createLayoutParamsForTabs() {
final LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams(
LayoutParams.WRAP_CONTENT, LayoutParams.MATCH_PARENT);
updateTabViewLayoutParams(lp);
return lp;
}
private void updateTabViewLayoutParams(LinearLayout.LayoutParams lp) {
if (mMode == MODE_FIXED && mTabGravity == GRAVITY_FILL) {
lp.width = 0;
lp.weight = 1;
} else {
lp.width = LinearLayout.LayoutParams.WRAP_CONTENT;
lp.weight = 0;
}
}
至于TabView,最上边也说了也是个线性布局,垂直布局的,简单看下,默认的布局,里边添加了一个图片和一个TextView,自定义的就不说了。添加自定义的也就是把这两个默认的隐藏,完事add那个自定义的控件到TabView里而已
if (mCustomView == null) {
// If there isn't a custom view, we'll us our own in-built layouts
if (mIconView == null) {
ImageView iconView = (ImageView) LayoutInflater.from(getContext())
.inflate(R.layout.design_layout_tab_icon, this, false);
addView(iconView, 0);
mIconView = iconView;
}
if (mTextView == null) {
TextView textView = (TextView) LayoutInflater.from(getContext())
.inflate(R.layout.design_layout_tab_text, this, false);
addView(textView);
mTextView = textView;
mDefaultMaxLines = TextViewCompat.getMaxLines(mTextView);
}
TextViewCompat.setTextAppearance(mTextView, mTabTextAppearance);
if (mTabTextColors != null) {
mTextView.setTextColor(mTabTextColors);
}
updateTextAndIcon(mTextView, mIconView);
}
说了半天貌似和线条都没关系,好吧,再看下SlidingTabStrip的代码。里边有个onDraw方法,线条就是在这里加的
@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);
}
}
我们要做的就是如何修改这个线条的左右边界。下边先说下老的做法和思路
前提条件
下边都是为了修改mode=fixed,tabGravity="fill"这种,完事要求线条宽度和文字宽度差不多这种需求。
如果是mode=scollable这种,你要求文字和线条宽度一样,那么设置如下属性基本都能满足需求的
app:tabMinWidth="2dp"
app:tabPadding="1dp"
app:tabPaddingStart="1dp"
app:tabPaddingEnd="1dp"
简单分析下,一些默认属性
mMode = a.getInt(R.styleable.TabLayout_tabMode, MODE_FIXED);
mTabGravity = a.getInt(R.styleable.TabLayout_tabGravity, GRAVITY_FILL);//横屏的时候默认style里这个是center
final Resources res = getResources();
mScrollableTabMinWidth = res.getDimensionPixelSize(R.dimen.design_tab_scrollable_min_width);
//添加tabview的时候会setminwidth的,就是调用如下方法获取最小宽度
//可以看到tab是有个最小宽度的,design_tab_scrollable_min_width手机是72dp,pad之类的是160dp
private int getTabMinWidth() {
if (mRequestedTabMinWidth != INVALID_WIDTH) {
// If we have been given a min width, use it
return mRequestedTabMinWidth;
}
// Else, we'll use the default value
return mMode == MODE_SCROLLABLE ? mScrollableTabMinWidth : 0;
}
老的做法和思路
最早比较笨的想法,既然你这线条是画出来了,那我也画一个好了,不过因为那线条左右边界是动态的,想着麻烦,就弄个固定的好了,也就是tab切换的时候才改变线条,少了滑动效果。
我们的需求是线条和文字宽度差不多,那第一步肯定是获取到文字的宽度了,文字的宽度哪来的,当然是获取到tabView里的那个TextView的宽度了。
获取方法有两种,第一种反射,第二种直接getChildAt
看下Tab里的那个mView就是我们要的TabView,里边就包含有TextView
下边就是重写TabLayout,给他画条线,缺点就是线不能滑动,我们通过监听tab选中状态的改变,来invalidate这个布局刷新线条。另外因为有了自己的线条了,所以需要把TabLayout的线条高度设置为0或者线条颜色弄为透明
那个factor就是线条长度和文字宽度的比例,为1就是一样,比1大就是稍微出去一点
@Override
public void draw(Canvas canvas) {
super.draw(canvas);
TabCheck();
canvas.drawRect(rectF.left,getHeight()-indicatorHeight,rectF.right,getHeight(),paintLine);
}
RectF rectF=new RectF();
private void TabCheck(){
try {
//通过反射获取那个textView
Tab tab=getTabAt(getSelectedTabPosition());
Field field=tab.getClass().getDeclaredField("mView");
field.setAccessible(true);
LinearLayout linearLayout= (LinearLayout) field.get(tab);
/**child1就是tab上的文字控件,第一个是图片控件,第二个就是这个文本控件*/
View child1=linearLayout.getChildAt(1);//
float add=(factor-1)*child1.getWidth()/2;
rectF.left=linearLayout.getLeft()+child1.getLeft()-add;
rectF.right=linearLayout.getLeft()+child1.getRight()+add;
} catch (Exception e) {
e.printStackTrace();
}
//根据整体控件的结构,我们也能拿到那个textView
// int selectedPosition=getSelectedTabPosition();
// LinearLayout slidingTabStrip=(LinearLayout)getChildAt(0);
// LinearLayout tabView= (LinearLayout) slidingTabStrip.getChildAt(selectedPosition);
// View textView=tabView.getChildAt(1);
}
新的做法和思路
SlidingTabStrip里有下边的代码是来计算线条左右边距的,根据viewpager的偏移量动态改变
left 和right就是对应的线条的左右点位置
private void updateIndicatorPosition() {
final View selectedTitle = getChildAt(mSelectedPosition);
int left, right;
if (selectedTitle != null && selectedTitle.getWidth() > 0) {
left = selectedTitle.getLeft();
right = selectedTitle.getRight();
if (mSelectionOffset > 0f && mSelectedPosition < getChildCount() - 1) {
// Draw the selection partway between the tabs
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);
}
既然线条的宽度位置都和tabView有关,那么我们改变tabView的大小即可,默认的tabView的大小就是平分TabLayout的。
这里再强调下,我们的这个自定义只支持下边的属性,默认的就是这两个
app:tabGravity="fill"
app:tabMode="fixed"
完整的代码如下
import android.content.Context
import android.support.design.widget.TabLayout
import android.util.AttributeSet
import android.view.ViewTreeObserver
import android.widget.LinearLayout
import android.widget.TextView
import java.lang.reflect.Field
/**
* Created by Sage on 2017/10/25.
* Description:此控件只适用于 app:tabMode="fixed"
*/
class TabLayoutIndicatorShort : TabLayout {
constructor(context: Context?) : super(context) {
initSomeThing()
}
constructor(context: Context?, attrs: AttributeSet?) : super(context, attrs) {
initSomeThing()
}
constructor(context: Context?, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr) {
initSomeThing()
}
private fun initSomeThing() {
viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
viewTreeObserver.removeOnGlobalLayoutListener(this)
changeIndicator()
}
})
}
val factor = 1.1f
fun changeIndicator() {
if(tabCount==0){
return
}
val tabLayout:Class<*> = javaClass.superclass
var tabStrip: Field? = null
try {
tabStrip = tabLayout.getDeclaredField("mTabStrip")
} catch (e: Exception) {
e.printStackTrace()
return
}
tabStrip!!.isAccessible = true
var ll_tab: LinearLayout? = null
try {
ll_tab = tabStrip.get(this) as LinearLayout
} catch (e: Exception) {
e.printStackTrace()
return
}
/**每个tab的宽,总宽度除以tabCount*/
val widthTab = width / tabCount
for (i in 0..ll_tab.childCount - 1) {
val child = ll_tab.getChildAt(i)
child.setPadding(0, 0, 0, 0)
try {
val tv = (child as LinearLayout).getChildAt(1) as TextView
var margin = ((widthTab - tv.width * factor) / 2).toInt()
println("i==" + i + "==widthTab=" + widthTab + "==child w=" + tv.width + "==margin=" + margin)
if (margin < 0) {
margin = 0
}
val params = LinearLayout.LayoutParams(0, LinearLayout.LayoutParams.MATCH_PARENT, tv.width.toFloat())
params.leftMargin = margin
params.rightMargin = margin
child.setLayoutParams(params)
} catch (e: Exception) {
e.printStackTrace()
}
}
}
}
最后看下修改后的效果和原生的效果。
原生的线条是平分的,点击范围也很大,我们修改后的线条是小了,可点击范围也小了。我还是喜欢原生的,可惜啊,很多时候ui设计的都是线条和文字宽度一样。如果不要求滚动的时候线条动画,我还是喜欢老的那种,直接画条线,也不影响点击范围。
最后的实现
以前懒得写啊,最近闲了,就抽空把这个实现吧
先看下效果图
移动到一半效果如下图
移动了超过一半,如下图
移动完成
简单说下思路:
隐藏掉原生画的线条【把颜色设置为透明,或者你把高度弄为0也可以】
然后给viewpager添加监听滑动的偏移量,我们来计算线条的位置
偏移量在0.5以下,
线条left位置从第一个textview的left位置移动到第一个的中心位置,
线条right位置从第一个textview的right位置移动到第二个textview的中心位置
偏移量0.5到1之间的话,
线条left位置是从第一个textview的中心位置到第二个textview的left位置
线条right位置是从第二个textview的中心位置到第二个textview的rigth位置
库一直在更新,下边的使用design库,新版的androidX也就是material库,TabLayout的tabIndicatorHeight属性默认高度没有了,它是通过一张默认图片获取的高度
所以老的代码,给个默认值,或者xml必须设置一个tabIndicatorHeight的高度才行
indicotorHeight = a.getDimensionPixelSize(R.styleable.TabLayout_tabIndicatorHeight, 3)
代码如下
import android.content.Context
import android.graphics.Canvas
import android.support.design.widget.TabLayout
import android.support.v4.view.ViewPager
import android.util.AttributeSet
import android.graphics.Color
import android.graphics.Paint
import android.widget.LinearLayout
import android.graphics.RectF
import android.support.design.R
import android.support.v4.view.ViewCompat
class TabLayoutFixedFill : TabLayout {
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr){
initAttrs(context,attrs,defStyleAttr)
}
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs,R.attr.tabStyle)
private fun initAttrs(context: Context, attrs: AttributeSet?,defStyleAttr: Int) {
val a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout,
defStyleAttr, R.style.Widget_Design_TabLayout)
indicotorHeight = a.getDimensionPixelSize(R.styleable.TabLayout_tabIndicatorHeight, 0)
paintLine.color = a.getColor(R.styleable.TabLayout_tabIndicatorColor, 0)
a.recycle()
setSelectedTabIndicatorColor(Color.TRANSPARENT)//隐藏掉原生画的线
}
var factor = 1f//线条的长度和文字宽度的比例,因为有的需求是比文字稍微长点。所以这里可以修改
var indicotorHeight = 2;//线条的高度
var paintLine = Paint()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
canvas.drawRect(rectIndicator.left, getHeight().toFloat() - indicotorHeight, rectIndicator.right, getHeight().toFloat(), paintLine);
}
override fun setupWithViewPager(viewPager: ViewPager?, autoRefresh: Boolean) {
super.setupWithViewPager(viewPager, autoRefresh)
viewPager?.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrollStateChanged(state: Int) {
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
updateIndicator(position, positionOffset)
}
override fun onPageSelected(position: Int) {
}
})
}
var rectIndicator = RectF()//记录下要画的线条的left和right位置
fun updateIndicator(position: Int, positionOffset: Float) {
if(position>=tabCount){
return
}
var rectF = getTextViewRect(position)
var rectF2 = rectF
if (position < tabCount - 1) {
rectF2 = getTextViewRect(position + 1)
}
if (positionOffset < 0.5) {
rectIndicator.left = rectF.left + rectF.width * positionOffset //第一个最左边移动到第一个的中心位置
rectIndicator.right = rectF.right + (rectF2.center - rectF.right) * positionOffset * 2 //移动范围,从第一个右边,移动到另一个控件的中心位置
} else {
rectIndicator.left = rectF2.left - (rectF2.left - rectF.center) * (1 - positionOffset) * 2 //移动范围,从第一个中心到另一个最左边
rectIndicator.right = rectF2.left + rectF2.width * positionOffset//第二个中心点到第二个的右边
}
ViewCompat.postInvalidateOnAnimation(this@TabLayoutFixedFill)
}
/**找出某个tabview里Textview的left和right位置*/
private fun getTextViewRect(selectedPosition: Int): ViewOption {
var slidingTabStrip = getChildAt(0) as LinearLayout
var tabView = slidingTabStrip.getChildAt(selectedPosition) as LinearLayout
var textView = tabView.getChildAt(1);
val add = (factor - 1) * textView.width / 2
return ViewOption(tabView.left + textView.left - add, tabView.left + textView.right + add)
}
/**记录下view的left,right,center ,and width*/
data class ViewOption(var left: Float, var right: Float, var center: Float = (right + left) / 2f, var width: Float = (right - left))
}
如果有人说我没有绑定viewpager咋办,也很简单,我上边的updateIndicator方法,你可以在切换tab的时候调用这个方法也可以。或者你也可以直接用最老的那种方法,就是画条线的那个也可以。
带背景的
上边介绍的是画了一条线,其实画啥都行,只要你愿意。
看下这种效果,就是在上边的代码上稍微修改下线条的高度,弄个圆角就可以了
布局如下
设置下左右的padding,完事设置下高度。padding是高度的一半,为了实现两边半圆的效果,这样弄最简单。
不想设置高度,你自己在里边算下文本的高度,然后自己算下padding也随你了,
代码如下
增加了2个参数 var tabPaddingStart=0
var tabPaddingEnd=0;其实正常这两个是一样的,要不圆角就不一样拉。
然后onDraw里画个带圆角的
canvas.drawRoundRect(rect,tabPaddingStart/1f,height/2f, paintLine)
import android.content.Context
import android.graphics.Canvas
import android.support.design.widget.TabLayout
import android.support.v4.view.ViewPager
import android.util.AttributeSet
import android.graphics.Color
import android.graphics.Paint
import android.widget.LinearLayout
import android.graphics.RectF
import android.support.design.R
import android.support.v4.view.ViewCompat
class TabLayoutFixedWrap : TabLayout {
constructor(context: Context) : this(context, null)
constructor(context: Context, attrs: AttributeSet?, defStyleAttr: Int) : super(context, attrs, defStyleAttr){
initAttrs(context,attrs,defStyleAttr)
}
constructor(context: Context, attrs: AttributeSet?) : this(context, attrs,R.attr.tabStyle)
private fun initAttrs(context: Context, attrs: AttributeSet?,defStyleAttr: Int) {
val a = context.obtainStyledAttributes(attrs, R.styleable.TabLayout,
defStyleAttr, R.style.Widget_Design_TabLayout)
paintLine.color = a.getColor(R.styleable.TabLayout_tabIndicatorColor, 0)
val padding = a.getDimensionPixelSize(R.styleable.TabLayout_tabPadding, 0)
tabPaddingStart = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingStart,padding)
tabPaddingEnd = a.getDimensionPixelSize(R.styleable.TabLayout_tabPaddingEnd,padding)
a.recycle()
setSelectedTabIndicatorColor(Color.TRANSPARENT)
}
var tabPaddingStart=0
var tabPaddingEnd=0;
var factor = 1f//这里没啥用了
var paintLine = Paint()
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val rect=RectF(rectIndicator.left-tabPaddingStart, 0f, rectIndicator.right+tabPaddingEnd, getHeight().toFloat())
canvas.drawRoundRect(rect,tabPaddingStart/1f,height/2f, paintLine)
}
override fun setupWithViewPager(viewPager: ViewPager?, autoRefresh: Boolean) {
super.setupWithViewPager(viewPager, autoRefresh)
viewPager?.addOnPageChangeListener(object : ViewPager.OnPageChangeListener {
override fun onPageScrollStateChanged(state: Int) {
}
override fun onPageScrolled(position: Int, positionOffset: Float, positionOffsetPixels: Int) {
updateIndicator(position, positionOffset)
}
override fun onPageSelected(position: Int) {
}
})
}
var rectIndicator = RectF() //记录下要画的线条的left和right位置
fun updateIndicator(position: Int, positionOffset: Float) {
if(position>=tabCount){
return
}
var rectF = getTextViewRect(position)
var rectF2 = rectF
if (position < tabCount - 1) {
rectF2 = getTextViewRect(position + 1)
}
if (positionOffset < 0.5) {
rectIndicator.left = rectF.left + rectF.width * positionOffset //第一个最左边移动到第一个的中心位置
rectIndicator.right = rectF.right + (rectF2.center - rectF.right) * positionOffset * 2 //移动范围,从第一个右边,移动到另一个控件的中心位置
} else {
rectIndicator.left = rectF2.left - (rectF2.left - rectF.center) * (1 - positionOffset) * 2 //移动范围,从第一个中心到另一个最左边
rectIndicator.right = rectF2.left + rectF2.width * positionOffset//第二个中心点到第二个的右边
}
ViewCompat.postInvalidateOnAnimation(this@TabLayoutFixedWrap)
}
/**找出某个tabview里Textview的left和right位置*/
private fun getTextViewRect(selectedPosition: Int): ViewOption {
var slidingTabStrip = getChildAt(0) as LinearLayout
var tabView = slidingTabStrip.getChildAt(selectedPosition) as LinearLayout
var textView = tabView.getChildAt(1);
val add = (factor - 1) * textView.width / 2
return ViewOption(tabView.left + textView.left - add, tabView.left + textView.right + add)
}
/**记录下view的left,right,center ,and width*/
data class ViewOption(var left: Float, var right: Float, var center: Float = (right + left) / 2f, var width: Float = (right - left))
}
如果你需要的是那种scrollable的效果,那么添加如下的属性即可
系统默认tabview有个最小宽度的,你不设置的话,可能看到文字很少的tab宽度也很大。
app:tabMode="scrollable"
app:tabMinWidth="2dp"
其他一些看源码的收获
如果我们不设置tablayout的高度的话,用个warp,那么他的高度其实是固定的。
图片文字同时存在,是72dp,只有文字的话48dp
看下系统的一些默认值
tab是有最大宽度一说的,而且scroll模式下默认有个最小宽度的design_tab_scrollable_min_width
264dp
72dp
14sp
12sp
如果是pad的话,values-sw600dp目录默认如下
160dp
可以看到,手机默认是fill,而平板默认是center,所以如果不设置tabGravity,平板下你会发现tab不是平分宽度的,而是居中显示的。
补充
除了比较复杂的需求,如果只是要求修改选中的文字的效果,比如加粗,字体方法这些,还是可以简单实现的,还是那句话,tablayout的整体布局结构开头也都说了,那么拿到tab也就拿到了position,有了position自然能拿到那个textview控件了。
tab_page.addOnTabSelectedListener(object :TabLayout.OnTabSelectedListener{
override fun onTabReselected(tab: TabLayout.Tab?) {
}
override fun onTabUnselected(tab: TabLayout.Tab) {
changeTextStyle(tab.position,false)
}
override fun onTabSelected(tab: TabLayout.Tab) {
changeTextStyle(tab.position,true)
}
})
private fun changeTextStyle(position: Int,selected:Boolean){
var parent=tab_page.getChildAt(0) as LinearLayout
var tabview=parent.getChildAt(position) as LinearLayout
//tabview也可以通过反射tab的mView这个字段来获取
var tv=tabview.getChildAt(1) as TextView
tv.setTypeface(if(selected) Typeface.DEFAULT_BOLD else Typeface.DEFAULT)
tv.setTextSize(if(selected) 40f else 15f)
}
问题
需求是2个tab平分显示,可在平板上看到2个tab挤在一起了
添加如下的属性,修改下最大值,弄大点,另外游标长度默认是和文字长度一样的,下边的true可以保证和tab一样宽
app:tabIndicatorFullWidth="true"
app:tabMaxWidth="2000dp"