前言
添加标签,但是标签的字数不固定,所以造成一个问题,如果每行的标签的个数固定,可能某些行的标签会溢出屏幕。那么自适应行宽,能自动换行的标签容器控件就可以完美解决此类问题。因此,这篇文章向大家介绍如何通过自定义View实现此类可能会经常遇见需求。
效果图
使用方法
第一步:添加JitPack repository到项目的build.gradle
allprojects {
repositories {
...
maven { url 'https://jitpack.io' }
}
第二步:添加依赖到app的build.gradle
dependencies {
implementation 'com.github.Simonzhuqi:SmartTabContainerLayout:1.0'
}
第三步:在需要显示此控件的xml布局文件中添加控件
<com.doophe.smarttabcontainerlib.SmartTabContainerLayout
android:id="@+id/tab_container_layout"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="@android:color/darker_gray"
android:padding="30dp"
app:tab_background="@drawable/default_bg_tab"
app:tab_text_size="8sp"
app:tab_text_color="@android:color/black"
app:tab_vertical_span="6dp"
app:tab_horizontal_span="12dp"
app:tab_padding_left="20dp"
app:tab_padding_top="6dp"
app:tab_padding_right="20dp"
app:tab_padding_bottom="6dp"
></com.doophe.smarttabcontainerlib.SmartTabContainerLayout>
第四步:在Java代码中查找到控件并调用相关方法
参数说明
<declare-styleable name="TabContainerLayout">
<!-- 标签的背景 -->
<attr name="tab_background" format="reference"></attr>
<!-- 标签的中字体大小 -->
<attr name="tab_text_size" format="dimension"></attr>
<!-- 标签的中字体颜色 -->
<attr name="tab_text_color" format="color"></attr>
<!-- 标签之间的行间距-->
<attr name="tab_vertical_span" format="dimension"></attr>
<!-- 标签之间的水平间距 -->
<attr name="tab_horizontal_span" format="dimension"></attr>
<!-- 标签的左内边距 -->
<attr name="tab_padding_left" format="dimension"></attr>
<!-- 标签的上内边距 -->
<attr name="tab_padding_top" format="dimension"></attr>
<!-- 标签的右内边距 -->
<attr name="tab_padding_right" format="dimension"></attr>
<!-- 标签的下内边距 -->
<attr name="tab_padding_bottom" format="dimension"></attr>
</declare-styleable>
目前支持方法
/**
* 添加标签
*
* @param t 标签上显示的文字
*/
public void addTab(String t) {
if (t != null && !t.trim().isEmpty()) {
addView(createTabView(t), getChildCount());
}
}
/**
* 批量添加标签
*
* @param tabs
*/
public void addTabs(String[] tabs) {
if (tabs != null && tabs.length > 0) {
for (String tab : tabs) {
addTab(tab);
}
}
}
/**
* 监听回调(单击和长按的回调)
* @param callback
*/
public void addCallback(Callback callback) {
this.mCallback = callback;
}
实现思路
首先考虑用哪种形式的自定义View。关于自定义View的实现有以下几种方式:
继承View
继承ViewGroup
继承Android已经封装好的控件(例如 RelativeLayout)
组合Android已经封装好的控件
这里选择第2种方式,继承ViewGroup实现。原因是相比直接继承View的方式实现起来更加方便,而如果使用继承RelativeLayout的方式也能实现,相对布局可以通过子View的位置来定位其他子View,并支持Margin参数,但是这些特征对于实现以上的需求可有可无,因此笔者选择直接继承ViewGroup的实现方式。
既然选择好了大方向,那边就可以进入正题了。如何自定义一个全新的ViewGroup?讲到这里,恰巧今天早上在鸿洋的微信公众号上看到一篇文章,里面有句话说,“据官方统计,超过一半的开发者没有写过自定义ViewGroup;接近六成的人不知道MeasureSpec......”。我不确认这是否属实,但是个人觉得这些都是Android开发者必须掌握的基础。其实有时候就算了解这些原理,现场要你完全自己手写一个比较复杂的自定义控件,并且具有良好的封装,那也是挺难的。所以平时还是要多实践,“纸上得来终觉浅,绝知此事要躬行”。回到正题,“如何自定义一个全新的ViewGroup”。很简单,无非就算三部曲:onMeasure、onLayout以及onDraw。这里无需onDraw,只要重写前两个方法就可以。
重写onMeasure——测量出自定义控件整体的宽和高
这里不详细介绍Android的测量原理,各自书籍和文章很多都有详细解读。
Android中的View类中对onMeasure方法做了默认的实现,下面会讲到。ViewGroup中没有对onMeasure方法进行重写,但继承ViewGroup的控件都会去重写onMeasure方法,其主要目的是在onMeasure方法中实现对子控件的测量。
下面介绍Android对于View的测量的大致过程。
根据屏幕尺寸测量出DecorView的宽高,这里的DecorView读者可以理解为根View。DecorView是继承自FrameLayout,因此它下面肯定还有子控件,我们在Activity的onCreate方法下的set的contentView就算其中的子控件之一。我们的contentView宽高的测量就是在DecorView的父类FrameLayout的onMeasure方法中测量出来的,测量的依据是父控件的宽高和子控件的参数设置。这边贴下源码作为证据,在measureChildWithMargins中分别对子控件进行测量。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
int count = getChildCount();
final boolean measureMatchParentChildren =
MeasureSpec.getMode(widthMeasureSpec) != MeasureSpec.EXACTLY ||
MeasureSpec.getMode(heightMeasureSpec) != MeasureSpec.EXACTLY;
mMatchParentChildren.clear();
int maxHeight = 0;
int maxWidth = 0;
int childState = 0;
for (int i = 0; i < count; i++) {
final View child = getChildAt(i);
if (mMeasureAllChildren || child.getVisibility() != GONE) {
//在此测量子控件
measureChildWithMargins(child, widthMeasureSpec, 0, heightMeasureSpec, 0);
final LayoutParams lp = (LayoutParams) child.getLayoutParams();
maxWidth = Math.max(maxWidth,
child.getMeasuredWidth() + lp.leftMargin + lp.rightMargin);
maxHeight = Math.max(maxHeight,
child.getMeasuredHeight() + lp.topMargin + lp.bottomMargin);
childState = combineMeasuredStates(childState, child.getMeasuredState());
if (measureMatchParentChildren) {
if (lp.width == LayoutParams.MATCH_PARENT ||
lp.height == LayoutParams.MATCH_PARENT) {
mMatchParentChildren.add(child);
}
}
}
}
...
}
由此可见,在父控件的onMeasure方法中完成了对子控件的测量,就这样一层层类似递归的进行测量,直到某个不包含子控件的View为止。要注意的是如果是自定义ViewGroup,在onMeasure中必须对其含有的子控件进行测量,否则子控件是无法显示出来的。
当我们重写这个自定义ViewGroup的onMeasure的时候,如何测量出准确的宽和高呢?
首先读者需要了解,如果自定义ViewGroup不重写onMeasure方法,它的宽和高将和父控件的宽和高大小一样。从源码分析可以得出这个结论,查看View下默认的onMeasure:
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
setMeasuredDimension(getDefaultSize(getSuggestedMinimumWidth(), widthMeasureSpec),
getDefaultSize(getSuggestedMinimumHeight(), heightMeasureSpec));
}
最终宽和高的大小通过getDefaultSize得到:
/**
* Utility to return a default size. Uses the supplied size if the
* MeasureSpec imposed no constraints. Will get larger if allowed
* by the MeasureSpec.
*
* @param size Default size for this view
* @param measureSpec Constraints imposed by the parent(父控件的测量大小)
* @return The size this view should be.
*/
public static int getDefaultSize(int size, int measureSpec) {
int result = size;
int specMode = MeasureSpec.getMode(measureSpec);
int specSize = MeasureSpec.getSize(measureSpec);
switch (specMode) {
case MeasureSpec.UNSPECIFIED:
result = size;
break;
case MeasureSpec.AT_MOST:
case MeasureSpec.EXACTLY:
result = specSize;
break;
}
return result;
}
这里的reslut = specSize这个specSize就是父控件的测量大小。
根据需求,我们不能放任我们的自定义控件的高跟随父控件的高来决定,必须根据一定的规则计算出精确的高度,而宽可以和父控件保持一致。分析到这里,现在的小目标就是要计算出自定义控件的高。接下来继续分析得出决定自定义控件高度的因素有三个,第一是子View的行高(子View的高+行间距),第二就是子View的行数,第三自定义控件Padding值(在View的测量过程中不能忽略Padding)。得出公式:
自定义控件高度 = (子View的高+行间距)x 子View的行数 + PaddingTop + PaddingBottom
这里子View的高度可以通过对子View的测量得到,行间距是用户可以设置的,直接可以得到,Padding的值也很容易获取;那么子View的行数如何获得?答:可以遍历子View根据累加子View的宽度+横向间距判断是否需要换行,从而通过累加计算出准确无误的行数。这样整个onMeasure的核心数据“自定义控件高度”就计算好了,接下来setMeasuredDimension传入默认的宽和计算得到的高就完成测量了。在此贴上具体实现的代码:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
mWidth = View.MeasureSpec.getSize(widthMeasureSpec);
mHeight = 0;
int leftPadding = getPaddingLeft();
int rightPadding = getPaddingRight();
int topPadding = getPaddingTop();
int bottomPadding = getPaddingBottom();
int lineTotalWidth = 0;
int totalLine = 1;
int childCount = getChildCount();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if(child.getVisibility() == GONE){
continue;
}
//在此测量子View
measureChild(child, widthMeasureSpec, heightMeasureSpec);
//测量后子View的宽
int childWidth = child.getMeasuredWidth();
//测量后子View的高
int childHeight = child.getMeasuredHeight();
//通过累加子View宽度的和判断是否超过父控件的宽,超过则进行换行
lineTotalWidth += childWidth;
if (lineTotalWidth > mWidth - leftPadding - rightPadding) {
//通过累加计算总行数
totalLine++;
lineTotalWidth = childWidth;
}
lineTotalWidth += mHorizontalSpan;
if (mHeight == 0) {
mHeight = (int) (childHeight + mVerticalSpan);
}
}
if (mHeight != 0) {
// 自定义控件高度 = (子View的高+行间距)x 子View的行数 + PaddingTop + PaddingBottom
mHeight = (int) (totalLine * mHeight - mVerticalSpan) + topPadding + bottomPadding;
}
setMeasuredDimension(mWidth, mHeight);
}
重写onLayout方法——对各个子View进行精准定位
在完成了对这个自定义控件的测量主要是对子View的测量后,已经确定的整体的大小,接下来就是要确定各个子View在以及测量好大小的控件中显示的位置。
相比onMeasure,onLayout方法更好理解。
在Android中View和ViewGroup的源码均未对onLayout进行实现。
在View的源码中,onLayout方法是一个空方法。
/**
* Called from layout when this view should
* assign a size and position to each of its children.
*
* Derived classes with children should override
* this method and call layout on each of
* their children.
* @param changed This is a new size or position for this view
* @param left Left position, relative to parent
* @param top Top position, relative to parent
* @param right Right position, relative to parent
* @param bottom Bottom position, relative to parent
*/
protected void onLayout(boolean changed, int left, int top, int right, int bottom) {
}
而在ViewGroup的源码中,onLayout方法被设置成了抽象方法,因此继承自ViewGroup的控件必须去重写onLayout方法。
@Override
protected abstract void onLayout(boolean changed,
int l, int t, int r, int b);
那么在我们这个自定义控件中,如何去重写这个方法呢?
重写onLayout的关键是对子View进行遍历,分别调用View的layout方法对各个可见的子View定位。View的layout方法需要传入四个参数,分别对应子View在父控件中left、top、right、bottom位置。可以通过计算得到这四个值:
left = PaddingLeft + 同一行的子View的宽之和(子View宽+ 水平间距)
top = (子View的高 + 行间距)x 行数 + PaddingTop
right = left + 子View的宽
bottom = top + 子View的高
源码如下:
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int lineTotalWidth = 0;
int totalLine = 0;
int childCount = getChildCount();
int leftPadding = getPaddingLeft();
int rightPadding = getPaddingRight();
int topPadding = getPaddingTop();
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if(child.getVisibility() == GONE){
continue;
}
int childWidth = child.getMeasuredWidth();
int childHeight = child.getMeasuredHeight();
lineTotalWidth += childWidth;
if (lineTotalWidth > mWidth - leftPadding - rightPadding) {
totalLine++;
lineTotalWidth = childWidth;
}
l = lineTotalWidth - childWidth + leftPadding;
t = (int) (totalLine * (childHeight + mVerticalSpan)) + topPadding;
r = l + childWidth;
b = t + childHeight;
child.layout(l, t, r, b);
lineTotalWidth += mHorizontalSpan;
}
}
至此,主要的两个核心方法介绍完成了!关于这个项目还有很多功能可以迭代进去,一些地方可以封装成类运用一些设计模式。而关于Android自定义控件还有如何重写onDraw方法绘制图形,以及对滑动事件的处理等等,还要考虑最终的性能,和后期的迭代。只有多实践多思考才能运用自如,具备工匠精神才能打造出优秀的控件。很多程序员一直在搬砖,是时候尝试着自己制造些砖头。
最后附上GitHub的传送门。