其实现在用来设置标签的流式布局开源库和文章都挺多的,写这个是因为自己学习总结自定义View的相关知识,毕竟看的再多都不如自己实现一下来得有用。流式布局用来学习还是挺不错的,下面就一步一步来实现流式布局。
实现基本功能
首先来说明几点:
1.标签视图TagView直接TextView,这样有几个好处:不用去重写onMeasure()接口,不用自己绘制Text,对Text控制也方便;
2.标签布局TagGroup继承ViewGroup,需要重写onMeasure()和onLayout()方法来控制TagView的显示;
直接来看下TagView的实现:
public class TagView extends TextView {
private Paint mPaint;
// 背景色
private int mBgColor;
// 边框颜色
private int mBorderColor;
// 字体颜色
private int mTextColor;
// 边框大小
private float mBorderWidth;
// 字体大小,单位sp
private float mTextSize;
// 边框角半径
private float mRadius;
// 字体水平空隙
private int mHorizontalPadding;
// 字体垂直空隙
private int mVerticalPadding;
// 边框矩形
private RectF mRect;
public TagView(Context context, String text) {
super(context);
setText(text);
_init(context);
}
public TagView(Context context, AttributeSet attrs) {
super(context, attrs);
_init(context);
}
/**
* 初始化
* @param context
*/
private void _init(Context context) {
mRect = new RectF();
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBgColor = Color.parseColor("#33F44336");
mBorderColor = Color.parseColor("#88F44336");
mTextColor = Color.parseColor("#FF666666");
mBorderWidth = MeasureUtils.dp2px(context, 0.5f);
mTextSize = 13.0f;
mRadius = MeasureUtils.dp2px(context, 5f);
mHorizontalPadding = (int) MeasureUtils.dp2px(context, 5);
mVerticalPadding = (int) MeasureUtils.dp2px(context, 5);
// 设置字体占中
setGravity(Gravity.CENTER);
setPadding(mHorizontalPadding, mVerticalPadding, mHorizontalPadding, mVerticalPadding);
setTextColor(mTextColor);
// 设置字体大小,如果转化为像素单位则要使用setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize)
setTextSize(mTextSize);
// setTextSize(TypedValue.COMPLEX_UNIT_PX, mTextSize);
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
// 设置矩形边框
mRect.set(mBorderWidth, mBorderWidth, w - mBorderWidth, h - mBorderWidth);
}
@Override
protected void onDraw(Canvas canvas) {
// 绘制背景
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mBgColor);
canvas.drawRoundRect(mRect, mRadius, mRadius, mPaint);
// 绘制边框
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(mBorderWidth);
mPaint.setColor(mBorderColor);
canvas.drawRoundRect(mRect, mRadius, mRadius, mPaint);
super.onDraw(canvas);
}
}
其实还是很简单的,主要通过一些属性来设置绘制的效果,包括背景、边框和文字。在代码中设置了文字占中,并在onSizeChanged()方法中设置了边框矩形,其它就没什么了看代码就好了。再看下ViewGroup的实现:
public class TagGroup extends ViewGroup {
private Paint mPaint;
// 背景色
private int mBgColor;
// 边框颜色
private int mBorderColor;
// 边框大小
private float mBorderWidth;
// 边框角半径
private float mRadius;
// Tag之间的垂直间隙
private int mVerticalInterval;
// Tag之间的水平间隙
private int mHorizontalInterval;
// 边框矩形
private RectF mRect;
public TagGroup(Context context) {
this(context, null);
}
public TagGroup(Context context, AttributeSet attrs) {
this(context, attrs, -1);
}
public TagGroup(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
_init(context);
}
private void _init(Context context) {
mPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
mBgColor = Color.parseColor("#11FF0000");
mBorderColor = Color.parseColor("#22FF0000");
mBorderWidth = MeasureUtils.dp2px(context, 1f);
mRadius = MeasureUtils.dp2px(context, 5f);
int defaultInterval = (int) MeasureUtils.dp2px(context, 5f);
mHorizontalInterval = defaultInterval;
mVerticalInterval = defaultInterval;
mRect = new RectF();
// 如果想要自己绘制内容,则必须设置这个标志位为false,否则onDraw()方法不会调用
setWillNotDraw(false);
setPadding(defaultInterval, defaultInterval, defaultInterval, defaultInterval);
}
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
int widthSpecSize = MeasureSpec.getSize(widthMeasureSpec);
int heightSpecSize = MeasureSpec.getSize(heightMeasureSpec);
int heightSpecMode = MeasureSpec.getMode(heightMeasureSpec);
// 计算可用宽度,为测量宽度减去左右padding值
int availableWidth = widthSpecSize - getPaddingLeft() - getPaddingRight();
// 测量子视图
measureChildren(widthMeasureSpec, heightMeasureSpec);
int childCount = getChildCount();
int tmpWidth = 0;
int measureHeight = 0;
int maxLineHeight = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
// 记录该行的最大高度
if (maxLineHeight == 0) {
maxLineHeight = child.getMeasuredHeight();
} else {
maxLineHeight = Math.max(maxLineHeight, child.getMeasuredHeight());
}
// 统计该行TagView的总宽度
tmpWidth += child.getMeasuredWidth() + mHorizontalInterval;
// 如果超过可用宽度则换行
if (tmpWidth - mHorizontalInterval > availableWidth) {
// 统计TagGroup的测量高度,要加上垂直间隙
measureHeight += maxLineHeight + mVerticalInterval;
// 重新赋值
tmpWidth = child.getMeasuredWidth() + mHorizontalInterval;
maxLineHeight = child.getMeasuredHeight();
}
}
// 统计TagGroup的测量高度,加上最后一行
measureHeight += maxLineHeight;
// 设置测量宽高,记得算上padding
if (childCount == 0) {
setMeasuredDimension(0, 0);
} else if (heightSpecMode == MeasureSpec.UNSPECIFIED || heightSpecMode == MeasureSpec.AT_MOST){
setMeasuredDimension(widthSpecSize, measureHeight + getPaddingTop() + getPaddingBottom());
} else {
setMeasuredDimension(widthSpecSize, heightSpecSize);
}
}
@Override
protected void onLayout(boolean changed, int l, int t, int r, int b) {
int childCount = getChildCount();
if (childCount <= 0) {
return;
}
int availableWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
// 当前布局使用的top坐标
int curTop = getPaddingTop();
// 当前布局使用的left坐标
int curLeft = getPaddingLeft();
int maxHeight = 0;
for (int i = 0; i < childCount; i++) {
View child = getChildAt(i);
if (maxHeight == 0) {
maxHeight = child.getMeasuredHeight();
} else {
maxHeight = Math.max(maxHeight, child.getMeasuredHeight());
}
int width = child.getMeasuredWidth();
int height = child.getMeasuredHeight();
// 超过一行做换行操作
if (width + curLeft > availableWidth) {
curLeft = getPaddingLeft();
// 计算top坐标,要加上垂直间隙
curTop += maxHeight + mVerticalInterval;
maxHeight = child.getMeasuredHeight();
}
// 设置子视图布局
child.layout(curLeft, curTop, curLeft + width, curTop + height);
// 计算left坐标,要加上水平间隙
curLeft += width + mHorizontalInterval;
}
}
@Override
protected void onSizeChanged(int w, int h, int oldw, int oldh) {
super.onSizeChanged(w, h, oldw, oldh);
mRect.set(mBorderWidth, mBorderWidth, w - mBorderWidth, h - mBorderWidth);
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 绘制背景
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mBgColor);
canvas.drawRoundRect(mRect, mRadius, mRadius, mPaint);
// 绘制边框
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(mBorderWidth);
mPaint.setColor(mBorderColor);
canvas.drawRoundRect(mRect, mRadius, mRadius, mPaint);
}
/******************************************************************/
/**
* 添加Tag
* @param text tag内容
*/
public void addTag(String text) {
addView(new TagView(getContext(), text));
}
public void addTags(String... textList) {
for (String text : textList) {
addTag(text);
}
}
public void cleanTags() {
removeAllViews();
postInvalidate();
}
public void setTags(String... textList) {
cleanTags();
addTags(textList);
}
}
其实代码主要看onMeasure()和onLayout()两个方法。
在onMeasure()我们要对布局进行测量,遍历所有子视图来计算布局的最终宽高,需要注意的是要把布局的padding属性计算上去,所以布局可用宽度为测量宽度减去左右两边的padding值,除了padding需要计算外,还要计算上TagView之间的间隙值。具体的测量过程代码注释的挺清楚,看下就懂了。
然后再看onLayout(),这个和onMeasure()其实挺像的,同样要计算上padding和间隙值,然后就是一个一个算出每个TagView的上下左右坐标,再调用TagView的layout()方法来设置到布局中的相应位置。
这只是初步的代码,还是很简单的,先看下效果:
大体效果是有了,但可以看到在显示长字符串的时候是有问题的,TagView一个只能显示一行,所以太长的字符串显示就要做裁剪。功能都是一点一点加的,后面就是一个个去添加功能了。
裁剪过长的字符串
下面来实现字符串的裁剪,裁剪的方式可能很多样,来简单说下思路:
首先太长的字符串截取前面的部分,并在后面补上3个“.”,就类似省略号;
既然要裁剪就要知道最大可用的布局宽度,这个要从父布局中获取,需要TagGroup提供接口;
最后计算的时候也要算上TagView的padding值,然后一个字符一个字符测量到符合要求;
还是看下代码实际些:
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec);
_adjustText();
}
/**
* 调整内容,如果超出可显示的范围则做裁剪
*/
private void _adjustText() {
if (mIsAdjusted) {
return;
}
mIsAdjusted = true;
// 获取可用宽度
int availableWidth = ((TagGroup) getParent()).getAvailableWidth();
mPaint.setTextSize(getTextSize());
// 计算字符串长度
float textWidth = mPaint.measureText(String.valueOf(mTagText));
// 如果可用宽度不够用,则做裁剪处理,末尾不3个.
if (textWidth + mHorizontalPadding * 2 > availableWidth) {
float pointWidth = mPaint.measureText(".");
// 计算能显示的字体长度
float maxTextWidth = availableWidth - mHorizontalPadding * 2 - pointWidth * 3;
float tmpWidth = 0;
StringBuilder strBuilder = new StringBuilder();
for (int i = 0; i < mTagText.length(); i++) {
char c = mTagText.charAt(i);
float cWidth = mPaint.measureText(String.valueOf(c));
// 计算每个字符的宽度之和,如果超过能显示的长度则退出
if (tmpWidth + cWidth > maxTextWidth) {
break;
}
strBuilder.append(c);
tmpWidth += cWidth;
}
// 末尾添加3个.并设置为显示字符
strBuilder.append("...");
setText(strBuilder.toString());
}
}
结合上面的思路看应该没什么问题,就是遍历测量每个字符,最后加"..."结尾,再设置给TagView作为显示的字符。需要注意的时,后面要获取完整的TagView字符串需要返回mTagText而不能直接通过getText()方法。还有就是我把_adjustText()放在onMeasure()里调用,如果直接初始化调用会找不到父类。
来看下效果:
好了,字符串太长的问题就解决了,当然了这只是其中的一种方式,也有别的好方法大家自己想。现在功能还很简陋,还有很多东西可以添加,我们先来添加必备的点击监听功能。
点击监听
先在TagView中实现监听器接口OnTagClickListener,并对外提供方法来设置监听器,其实和大部分设置监听器一个样。然后给TagView设置OnClickListener和OnLongClickListener,并来执行OnTagClickListener回调方法。如下:
public OnTagClickListener getTagClickListener() {
return mTagClickListener;
}
public void setTagClickListener(OnTagClickListener tagClickListener) {
mTagClickListener = tagClickListener;
}
/**
* 点击监听器
*/
public interface OnTagClickListener{
void onTagClick(String text);
void onTagLongClick(String text);
}
/**
* 初始化
* @param context
*/
private void _init(Context context) {
// 略......
setOnClickListener(new OnClickListener() {
@Override
public void onClick(View v) {
if (mTagClickListener != null) {
mTagClickListener.onTagClick(String.valueOf(mTagText));
}
}
});
setOnLongClickListener(new OnLongClickListener() {
@Override
public boolean onLongClick(View v) {
if (mTagClickListener != null) {
mTagClickListener.onTagLongClick(String.valueOf(mTagText));
}
return true;
}
});
}
现在要做的就是通过TagGroup来对外提供OnTagClickListener的设置接口,但是有一点要注意的是,如果你先添加Tags再设置监听器就可能出现前面设置的Tags没办法响应点击,所以你需要在设置监听器的地方为前面设置的Tags都重新添加上监听器,当然了你需要在之前保存好设置过的TagView。代码没什么特别的,简单看下,具体Tags在哪里保存其实想下也知道是在设置的时候保存- -,保存代码就不贴了:
public void setOnTagClickListener(TagView.OnTagClickListener onTagClickListener) {
mOnTagClickListener = onTagClickListener;
// 避免先调用设置TagView,后设置监听器导致前面设置的TagView不能响应点击
for (TagView tagView : mTagViews) {
tagView.setTagClickListener(mOnTagClickListener);
}
}
现在点击功能也OK了,再来添加别的功能,比如我们看到的Tag有圆角矩形,也有两边半圆的,下面来给它添加个切换的功能。
Tag模式切换
我们定义3种模式:圆角矩形、两边半圆和直角矩形。来看下关键代码:
// 3种模式:圆角矩形、圆弧、直角矩形
public final static int MODE_ROUND_RECT = 1;
public final static int MODE_ARC = 2;
public final static int MODE_RECT = 3;
public void setTagMode(@TagMode int tagMode) {
mTagMode = tagMode;
}
@IntDef({MODE_ROUND_RECT, MODE_ARC, MODE_RECT})
@Retention(RetentionPolicy.SOURCE)
@Target(ElementType.PARAMETER)
public @interface TagMode {}
// ......
@Override
protected void onDraw(Canvas canvas) {
// 绘制背景
mPaint.setStyle(Paint.Style.FILL);
mPaint.setColor(mBgColor);
float radius = mRadius;
if (mTagMode == MODE_ARC) {
radius = mRect.height() / 2;
} else if (mTagMode == MODE_RECT) {
radius = 0;
}
canvas.drawRoundRect(mRect, radius, radius, mPaint);
// 绘制边框
mPaint.setStyle(Paint.Style.STROKE);
mPaint.setStrokeWidth(mBorderWidth);
mPaint.setColor(mBorderColor);
canvas.drawRoundRect(mRect, radius, radius, mPaint);
super.onDraw(canvas);
}
这里定义了个注解,其实作用相当于枚举,不过比枚举更轻量级,关于这个注解的详细介绍看这里:
JAVA ENUM AND ANDROID INTDEF
在onDraw()方法里根据不同的模式来设置半径进行绘制,很简单,剩下的就是TagGroup对外提供接口来设置模式就行了,代码不贴了,来看下效果:
到这基本的东西就说的差不多了,其实还有东西没加上,比如自定义属性,这个想加的话自己加上很容易的。还有很多功能可以添加,比如可以加个编辑模式、多选模式、随机颜色、TagView可拖拽,如果你爱折腾也可以加上各种动画。这些喜欢自己去加,我就不弄了,这里介绍个可拖拽的TagView开源库,我觉得效果挺酷的:AndroidTagView
最后是源代码:TagLayout