看了关于自定义控件的很多视频还有书籍等,也写了不少自定义控件。拿出来与大家共享一下。
一、概述
1.前言
直奔主题吧,我们当同一个页面内容过多时,比如新闻类app,要分很多栏目,但是在同一个activity中显示,这时需要tab栏+ViewPager+Fragment来实现这样的效果。因此在此处来讲述一下这个tab栏的实现。这个很早以前做的,很多地方都忘了,也当时回顾一下吧。当时做的时候是参考的Hongyang在慕课网上的视频,然后加以自己的需求实现。现在在Android的support包中也有PagerTabStrip的类似控件实现,就不多说了。
2.实现原理
关于原理,先简单说说,后面再看实现。自定义ViewGroup有两种实现方式,一个继承自ViewGroup,另一个就是继承已经实现的控件。先来分析一下我们所需要的效果:
- tab栏要横向排列;
- 每个tab要有点击事件 ;
- tab点击对应样式变化,以及viewpager的联动变化;
- tab底部有可自定义的下划线;
来分析一下:
- tab要横向排列,其实最好的就是继承LinearLayout来实现,这样就可以跳过measure和layout的过程;
- tab的点击事件,当然view也能实现点击事件,但是由于要区分每个tab,如果在同一个view 实现,还要根据触摸点去辨别点击的tab,相当麻烦,所以使用ViewGroup是最简单的;
- tab点击对应样式变化,这里可以在点击事件中重置或是联动的ViewPager中重置;而与viewpager的联动变化则可以通过viewPager的OnPageChangeListener接口来改变标题栏变化;
- 这个可以在onDraw中绘制ViewGroup内部显示的内容,但是要计算位置和变化。
效果图:
二、实现
1.继承LinearLayout并添加变量
public class SimpleTabStripView extends LinearLayout {
private static final String TAG = "SimpleTabStripView";
private static final String[] DEFAULT_TITLES = new String[]{"Tab1", "Tab2", "Tab3"};// 默认标题
private static final int DEFAULT_VISIBLE = 3;//默认可见三个tab
private static final int DEFAULT_SELECTED_COLOR = Color.RED;//默认的选中tab和下划线相同颜色
private static final int DEFAULT_UNSELECTED_COLOR = Color.GRAY;//默认下划线颜色
private static final int DEFAULT_UNDERLINE_HEIGHT = 20;//px,默认下划线高度
private static final int DEFAULT_TXT_SIZE = 16;//sp,默认字体大小
private int mSelectedTxtColor; //选中文本颜色
private CharSequence[] mTitles; //tab标题
private int mWidth; //控件宽度
private int mVisibleTabsCount; //可见tab数量
private int mTabWidth; //tab宽度
private ViewPager mViewPager;
private int mTranslationX; //偏移量
private int mUnderlineColor; //下划线颜色
private int mUnselectedTxtColor; //未选中文本颜色
private int mSelectedPosition = 0;//此处主要是为了标记当前选中的tab,因为设置viewpager时不知道此时view是否已经添加,所以只能先做标记
private int mUnderlineHeight; //下划线高度
private UnderlineStyle mUnderlineStyle; //下划线样式(已经定义好了条形和三角形的样式,扩展可实现UnderlineStyle接口)
private int mTabTextSize; //文字颜色
public SimpleTabStripView(Context context) {
this(context, null);
}
public SimpleTabStripView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public SimpleTabStripView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
setOrientation(HORIZONTAL);
TypedArray ta = context.obtainStyledAttributes(attrs, R.styleable.SimpleTabStripView);
mTitles = ta.getTextArray(R.styleable.SimpleTabStripView_titles);
if (mTitles == null) {
mTitles = DEFAULT_TITLES;
}
mVisibleTabsCount = ta.getInt(R.styleable.SimpleTabStripView_visible_tabs_count, DEFAULT_VISIBLE);
mSelectedTxtColor = ta.getColor(R.styleable.SimpleTabStripView_selected_txt_color, DEFAULT_SELECTED_COLOR);
mUnderlineColor = ta.getColor(R.styleable.SimpleTabStripView_underline_color, mSelectedTxtColor);
mUnselectedTxtColor = ta.getColor(R.styleable.SimpleTabStripView_unselected_txt_color, DEFAULT_UNSELECTED_COLOR);
mUnderlineHeight = ta.getDimensionPixelSize(R.styleable.SimpleTabStripView_underline_height, DEFAULT_UNDERLINE_HEIGHT);
mTabTextSize = (int) ta.getDimension(R.styleable.SimpleTabStripView_tab_txt_size, sp2px(context, DEFAULT_TXT_SIZE));
ta.recycle();
mUnderlineStyle = new RectUnderlineStyle();
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mWidth = w;
mTabWidth = w / mVisibleTabsCount;
updateTabs();
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
if (mUnderlineStyle != null) {
mUnderlineStyle.draw(canvas, mTranslationX, mTabWidth, mUnderlineColor, getMeasuredHeight(), mUnderlineHeight);
}
}
/**
* sp转px
*
* @param context 上下文
* @param spVal sp值
* @return px值
*/
public static int sp2px(Context context, float spVal) {
return (int) TypedValue.applyDimension(TypedValue.COMPLEX_UNIT_SP,
spVal, context.getResources().getDisplayMetrics());
}
/**
* px转sp
*
* @param context 上下文
* @param pxVal px值
* @return sp值
*/
public static float px2sp(Context context, float pxVal) {
return (pxVal / context.getResources().getDisplayMetrics().scaledDensity);
}
}
这个,基本上不用过多的解释,已经在上面进行了注释。提一下,在上面的构造函数中我们实现了自定义属性的获取,这个我到后面再稍微提一下。之后在onDraw中调用自定义的下划线绘制接口进行下划线绘制。具体绘制的实现下面再进行分析。
2.添加tab与点击事件
由于在上面已经添加了一个字符串数组mTiltles,因此提供一个方法用于添加tab即可。
/**
* 创建添加tab
*/
private void updateTabs() {
removeAllViews();
for (int i = 0; i < mTitles.length; i++) {
final int j = i;
CharSequence title = mTitles[i];
TextView tab = new TextView(getContext());
tab.setLayoutParams(new LinearLayout.LayoutParams(mTabWidth, LinearLayout.LayoutParams.MATCH_PARENT));
tab.setText(title);
tab.setTextColor(i == mSelectedPosition ? mSelectedTxtColor : mUnselectedTxtColor);
tab.setTextSize(px2sp(getContext(), mTabTextSize));
tab.setGravity(Gravity.CENTER);
tab.setOnClickListener(new OnClickListener() {
@Override
public void onClick(View view) {
mViewPager.setCurrentItem(j);
}
});
addView(tab);
}
}
ok,从代码中可以看出,在我们更新tab时,首先清空所有的tab,然后根据mTitles中的内容来逐个创建tab,并分别设置其文本,颜色,尺寸,以及居中方式。最后我们通过添加点击事件,调用ViewPager的选择item方法,做到tab与mViewPager的联动。关于update的调用时机,我们选择在onSizeChanged()方法中进行调用,这样可以解决几个问题,首先,onSizeChanged方法中,我们可以得到测量后的整个ViewGroup的尺寸,进而可以计算出每个tab应有的尺寸。后面还会添加手动添加tab的方法以及显示的tab数量的方法,同时要在这几个方法中计算出tab该有的尺寸。
3.tab点击样式变化,与viewpager的联动
public void setViewPager(ViewPager viewpager) {
this.mViewPager = viewpager;
viewpager.addOnPageChangeListener(new ViewPager.OnPageChangeListener() {
@Override
public void onPageScrolled(int position, float positionOffset, int positionOffsetPixels) {
scrollToChild(position, positionOffset);
}
@Override
public void onPageSelected(int position) {
highLightTextView(position);
mSelectedPosition = position;
}
@Override
public void onPageScrollStateChanged(int state) {
}
});
mViewPager.setCurrentItem(mSelectedPosition);
//此处不hightLight文本,主要是因为,set的时候不能确定宽度是否已经测量。
}
/**
* 滚动到指定位置
*
* @param position 起始位置
* @param positionOffset 偏移量
*/
private void scrollToChild(int position, float positionOffset) {
mTranslationX = (int) ((position + positionOffset) * mTabWidth);
invalidate();
if (position >= mVisibleTabsCount - 2 && getChildCount() > mVisibleTabsCount && position < getChildCount() - 2) {
if (mVisibleTabsCount != 1) {
//例:可见3个,当前在1位置,由1滚动到2,
// 则position为1,而滚动的距离则为positionOffset * mTabWidth
scrollTo((int) ((position + 2 - mVisibleTabsCount + positionOffset) * mTabWidth), 0);
} else {
scrollTo((int) ((position + positionOffset) * mVisibleTabsCount), 0);
}
}
}
/**
* 重置所有tab的字体颜色
*/
private void resetAllTabsColor() {
int count = getChildCount();
for (int i = 0; i < count; i++) {
View tab = getChildAt(i);
if (tab instanceof TextView) {
((TextView) tab).setTextColor(mUnselectedTxtColor);
}
}
}
/**
* 高亮文本
*
* @param pos 选中的tab位置
*/
private void highLightTextView(int pos) {
resetAllTabsColor();
View tab = getChildAt(pos);
if (tab instanceof TextView) {
((TextView) tab).setTextColor(mSelectedTxtColor);
}
}
首先在下面提供对应tab点击的样式变化方法,resetAllTabsColor和highLightTextView的方法,故名思议,先重置,再高亮指定的tab即可。
与viewPager的联动,我们设置了ViewPager的OnPageChangeListener。重写其onPageScrolled执行tab的下划线的滚动;重写onPageSelected方法执行tab的选中的样式变化。
在scrollToChild(int position,float positionOffset)方法中,position(0~tab数-1)是指选中的tab(或者说是ViewPager的item),positionOffset(-1~1)是相对于当前选中的item的滑动偏移量。该方法首先计算偏移量,再调用绘制tab的下划线,之后再使用scrollTo方法来滚动LinearLayout的内容部分,则可以实现滑动的效果。
解释一下其中的判断,下面提到的滑动都是指viewGroup内容的滑动,不是下划线:
- 首先,选中position大于等于可见tab数-2,也就是从可见数的倒数第2个才可开始进行滑动(比如,总共有4个tab可见,那么就从position为2的tab位置可以滑动,也就是第三个tab;6个可见,就从第5个tab选中开始可以滑动),这样做是为了良好的用户体验,总不能从开始就滑动;
- getChildCount() > mVisibleTabsCount,即tab总数大于可见tab数才会发生滚动,都放不满一屏还滑动条毛啊;
- position < getChildCount() - 2,这里与第一个条件稍微有点关系,第一个条件中从倒数第二个可见tab数开始滑动,那么在滑动结束时,后面总会有1个没被选中的tab显示。此时,如果从倒数第二个开始往最后一个tab滑动时,其实最后1个已经显示出来了,不需要再滚动了。比如,可见数4个,则tab总数至少5个(假设5个),从第3个tab(即position为2)选中可滑动,但第4个tab也是可见的,position < getChildCount() - 2 满足,到第4个被选中时,即position为3,则第5个就可见了,后面没tab了就不用滑动了,此时position < getChildCount() - 2 不满足则不可滑动。
这里或许有点难懂,或许改为position < (getChildCount() - 1) -1这样会更好理解,即position比最后一个tab的前一个tab还小时可以滑动,即倒数第三个tab选中时可滑动,倒数第二个tab选中时不可滑动。 - 最后如果可见数不等于1,则每次滑动到的位置就需要position+ positionOffset的同时还有减去不会滑动的mVisibleTabsCount-2的数量。当然如果可见就是1个,那就正常滑动好了。
4.tab底部下划线
这里就很简单了,根据偏移量在onDraw中画出线即可。
写了个接口,用于之后可以扩展底部样式:
/**
* 应对不同的底色
*/
interface UnderlineStyle {
/**
* 绘制选中的按钮的下划线样式(三角形,长方形等等)
*
* @param canvas canvas
* @param translationX X偏移量
* @param tabWidth 每个tab宽度
* @param underlineColor 下划线颜色
* @param height tab高度
* @param underlineHeight 下划线高度(xml设置)
*/
void draw(Canvas canvas, int translationX, int tabWidth, int underlineColor, int height, int underlineHeight);
}
再给出我矩形指示器和三角指示器的实现:
矩形指示器(图就是上面那张):
@Override
public void draw(Canvas canvas, int translationX, int tabWidth, int underlineColor, int height, int underlineHeight) {
mPaint.setColor(underlineColor);
float offset = RADIO_OFFSET * tabWidth / 2; // RADIO_OFFSET = 0.35f
canvas.save();
canvas.translate(translationX, height - underlineHeight);
canvas.drawRect(offset, 0, tabWidth - offset, underlineHeight, mPaint);
canvas.restore();
}
上面的偏移比例是指相对于tab宽度,指示器应该相对小一点,所以,前面留0.35/2的比例为空白,然后画矩形。
mPaint.setColor(underlineColor);
int mTriangleWidth = (int) (tabWidth * RADIO_TRIANGLE_WIDTH); RADIO_TRIANGLE_WIDTH = 1/6f
int mInitTriangleWidth = tabWidth / 2 - mTriangleWidth / 2;
Path path = new Path();
path.moveTo(0, 0);
path.lineTo(mTriangleWidth, 0);
path.lineTo(mTriangleWidth / 2, -underlineHeight);
path.close();
canvas.save();
canvas.translate(mInitTriangleWidth + translationX, height - 2);
canvas.drawPath(path, mPaint);
canvas.restore();
在后面绘制时相对向上偏移了一点位置,防止三角形被底部覆盖。
5.其他部分功能
当然还给出了一些设置tab的标题、可见tab数、选中文本色、未选中文本色等功能的方法。请自己在代码中看一下。还有就是自定义属性,这个要在attrs.xml中定义其属性,之后才可在view中获取:
<declare-styleable name="SimpleTabStripView">
<attr name="visible_tabs_count" format="integer" />
<attr name="titles" format="reference" />
<attr name="selected_txt_color" format="color" />
<attr name="unselected_txt_color" format="color" />
<attr name="underline_color" format="color" />
<attr name="tab_selected_bg" format="color" />
<attr name="underline_height" format="dimension" />
<attr name="tab_txt_size" format="dimension" />
</declare-styleable>
三、总结
其实也没啥可说的。讲讲怎样自定义View吧。首先理清需要的View所含功能到底是啥,然后把这些功能按照每个步骤实现划分开来,保证每一步都很清晰的能够达到目标。之后了解需要的api就可以实现了。当然这只是简单的需求。如果涉及到touch事件,还需要对Android的事件分发机制有所了解。说到这,想到其实我们这个tab还有个缺陷,如果过多的tab,需要一个个滑动到最后面,我们可以通过拦截滑动事件,来定义其为可随意滑动的tab控件。这个就不写了,我自己写过了,留个思考空间给大家吧。当然涉及到Touch事件,还是需要下一番功夫的。
补充:其实上述的实现中还有个问题,如果按照上面的实现,我们在AndroidStudio的preview中其实是看不到指定的tab的,我一开始还没在意。后来用到项目中,发现华为的荣耀8以上,p8等都不显示,但是其他机子都显示。根据其测量过程的log,我发现一般我们在addView之后都会重新onMeasure再layout,但是在华为的部分机子上不是,他们直接layout了,导致其宽高度为0,不显示。只要在addView之后重新measure一下就可以了,这个我加在代码中了。
代码下载地址:自定义tab栏控件