一个项目中有一个需求:很多个TextView按照从左到右,从上到下依次排列,如下图所示:
这种效果叫做流式布局,在网上查了一下流布局,发现基本都是通过继承ViewGroup来实现的,所以想通过自定义View来实现一个流式布局效果的TextView。
观察我们的效果图,发现有如下要点:
1. 流布局的实现
2. 添加点击事件
3. 点击文本块后有水波纹效果
1.流布局的实现:
自定义一个View,名为FlowTextView,继承自View。首先需要计算View的大小,宽度很简单,这里就不多过描述,麻烦的是高度的计算。看效果图,不难发现每块文本的高度是一样的,所以View的高度应该是:
height = textHeight * row + spacingVertical * (row-1) + paddingTop + paddingBottom
其中:
row : 行数
textHeight:文本高度;
spacingVertical:文本垂直间距;
paddingTop、paddingBottom:View的上下Padding
<1>.计算文字的高度
这里是将画笔paint设置好字体大小和字体后,通过Paint的FontMetrics来计算文字的高度。
private void calculateTextHeightAndBaselineY() {
mPaint.setTextSize(mTxtSize);
if (mTypeface != null)
mPaint.setTypeface(mTypeface);
Paint.FontMetrics fontMetrics = mPaint.getFontMetrics();
mTxtHeightMax = (float) Math.ceil(fontMetrics.descent - fontMetrics.ascent)
+ mTxtPaddingTop + mTxtPaddingBottom;
}
<2>.计算view的高度、文本块坐标
复写onMeasure方法。获取view的宽度,遍历所有文本,获取文本的宽度,累积相加,超过View的宽度时换行,记录行数和文本坐标。重复以上动作,直到文本遍历完毕,进而计算view高度。需要注意的是当单个文本的长度超过View的宽度时,需要对该个文本做特殊处理。如,截取文本,使文本在文本后添加“…”等。
@Override
protected void onMeasure(int widthMeasureSpec, int heightMeasureSpec) {
calculateTextHeightAndBaselineY();
int widthSize = MeasureSpec.getSize(widthMeasureSpec);
int height = 0;
int actualWidth = widthSize - getPaddingRight(); //获取可绘制的最大宽度
if (actualWidth > 0) {
//计算总高度
height = initTextListPoint(actualWidth);
}
setMeasuredDimension(widthSize, height);
}
/**
* 初始化文本坐标区间,计算View高度
*
* @param maxX 最大横坐标
* @return view的高度
*/
private int initTextListPoint(int maxX) {
int x = getPaddingLeft();
int height = 0;
int rows = 1;
int drawMaxWidth = (int) (maxX - getPaddingLeft() - mTxtPaddingLeft - mTxtPaddingRight); //绘制的最大宽度
mRow.append(0, 0);
mTextPoints = new Rect[mTextList.length];
for (int i = 0; i < mTextList.length; i++) {
String text = mTextList[i];
mPaint.getTextBounds(text, 0, text.length(), mBound);
int w = (int) mPaint.measureText(text); //计算该文本的长度
if (w > drawMaxWidth) {
mTextList[i] = getSuitableText(w, drawMaxWidth, text);
w = drawMaxWidth;
} else {
w += mTxtPaddingLeft + mTxtPaddingRight;
}
if (i == 0) { //第一个文本不计算水平间距
x += w;
} else {
x += w + mTxtSpacingHorizontal;
}
if (x > maxX) { //如果累积横坐标大于最大横坐标则换行
x = w + getPaddingLeft();
rows++;
mRow.append(rows - 1, i); //记录换行位置
}
height = (int) (rows * mTxtHeightMax + (rows - 1) * mTxtSpacingVertical + getPaddingTop());
//记录文本块的坐标
Rect rect = new Rect(
x - w,
height - mTxtHeightMax,
x,
height);
mTextPoints[i] = rect;
}
rowHeight = (height - getPaddingTop()) / rows; //平均每行的高度
height += getPaddingBottom(); //View的高度
return height;
}
/**
* 当文本长度超出可绘制最大宽度时,截取文本,并在文本后添加...
*
* @param currentWidth 当前文本长度
* @param maxWidth 最大长度
* @param text 文本
* @return 截取后的文本
*/
private String getSuitableText(int currentWidth, int maxWidth, String text) {
int drawLength = (int) (((float) maxWidth) / ((float) currentWidth) * text.length());
while (currentWidth > maxWidth) {
drawLength--;
text = text.substring(0, drawLength) + "...";
// BaseLog.i("TEXT:" + text + " | LENGHT:" + drawLength);
mPaint.getTextBounds(text, 0, text.length(), mBound);
currentWidth = (int) (mPaint.measureText(text));
}
return text;
}
<3>.view的绘制
复写onDraw方法绘制文本和背景色块。使用canvas.drawText(String text, float x, float y,
Paint paint)方法绘制文本。值得注意的是绘制文本时,y表示的文字的下边,而且y参数需要减去一个文本偏移量,才会使文本垂直居中于文本背景色块。参考居中绘制文本内容的正确方法。
而绘制的顺序如下:循环所有文本,先绘制文本背景色块,然后绘制文本,直至绘制完所有文本。
/**
* 绘制文本
*
* @param canvas 画布
* @param rect 绘制坐标区间
* @param position 文本位置
*/
private void drawText(Canvas canvas, Rect rect, int position) {
if (mColorList != null) { //设置文本字体颜色
Integer color = mColorList[position];
if (color != null) {
mPaint.setColor(color);
} else {
mPaint.setColor(mTxtColor);
}
} else {
mPaint.setColor(mTxtColor);
}
String text = mTextList[position];
canvas.drawText(
text,
rect.pointLeftTop.x + mTxtPaddingLeft,
rect.pointRightBottom.y - mTextCenterVerticalBaselineY,
mPaint);
}
/**
* 绘制文本块背景色
*
* @param canvas 画布
* @param rect 绘制坐标区间
*/
private void drawTextBG(Canvas canvas, Rect rect) {
mPaint.setColor(mTxtBGColor);
canvas.drawRect(
rect.pointLeftTop.x,
rect.pointLeftTop.y,
rect.pointRightBottom.x,
rect.pointRightBottom.y,
mPaint);
}
@Override
protected void onDraw(Canvas canvas) {
if (mTextPoints != null) {
for (int i = 0; i < mTextPoints.length; i++) {
Rect rect = mTextPoints[i];
if (rect != null) {
drawTextBG(canvas, rect);
drawText(canvas, rect, i);
}
}
}
}
到这里,我们的自定义View效果就已经出来了,只是还没有实现点击和水波纹效果。
2.设置点击事件,和绘制水波纹效果
点击事件比较简单,重写onTouchEvent方法处理Touch动作,之前我们已经记录下每一块文本的坐标,所以我们只需要获取触摸view的坐标,然后和我们记录的坐标比较,就能知道点击的是哪块。
而水波纹效果,实际上就是在背景色块之上,文本之下绘制了一个圆,这个圆有两种情况,长按时圆慢慢变大、松开时快速变大。
<1>定义用于点击事件的接口
public interface OnFlowItemClickListener {
void onItemClick(int position);
}
private OnFlowItemClickListener mOnFlowItemClickListener;
public void setOnFlowItemClickListener(OnFlowItemClickListener onFlowItemClickListener) {
this.mOnFlowItemClickListener = onFlowItemClickListener;
}
<2>处理onTouchEvent
我们只需要处理三种情况,按下(ACTION_DOWN)、移动(ACTION_MOVE)、松开(ACTION_UP)
按下:判断触摸点是否在某块文本上,如果不在,什么都不用处理;否则,需要开始绘制水波纹效果
移动:如果有某块文本被触摸,需要判断移动时触摸点是否还在该文本块上,继续绘制水波纹,否则停止绘制水波纹
松开:如果有某块文本被触摸,响应点击事件,并停止绘制水波纹
/**
* 触摸文本块时
*
* @param point 触摸的点
* @return
*/
private void touchTextForDownEvent(Point point) {
touchPosition = DEFAULT_TOUCH_POSITION; //初始化触摸位置
int row = (int) (point.y - getPaddingTop()) / rowHeight; //获取触摸点所在的行数
Integer touchIndexMin = mRow.get(row); //获取该行处于最左边的文本块的索引
if (touchIndexMin != null) {
float touchYMin = mTextPoints[touchIndexMin].pointLeftTop.y; //该行最小纵坐标
float touchYMax = mTextPoints[touchIndexMin].pointRightBottom.y; //该行最大纵坐标
if (point.y >= touchYMin && point.y <= touchYMax) { //判断触摸点纵坐标是否处于该行
Integer touchIndexMax = mRow.get(row + 1); //获取下行处于最左边的文本块的索引
if (touchIndexMax == null)
touchIndexMax = mTextPoints.length;
for (int i = touchIndexMin; i < touchIndexMax; i++) {//循环判断触摸点所在的文本块
Rect rect = mTextPoints[i];
if (point.x >= rect.pointLeftTop.x
&& point.x <= rect.pointRightBottom.x) {
this.touchPosition = i;//记录触摸位置
this.touchPoint = point;//记录触摸坐标
downTime = System.currentTimeMillis();
startRipple(rect);//开始绘制水波纹
break;
}
}
}
}
}
/**
* 按下后移动手指时
*
* @param point
*/
private void touchTextForMoveEvent(@NonNull Point point) {
if (touchPosition != DEFAULT_TOUCH_POSITION) {
Rect rect = mTextPoints[touchPosition];
if (rect != null && rect.isInRect(touchPoint)) {
this.touchPoint = point;
} else {
isDown = false;
}
}
}
/**
* 松开手指时
*/
private void touchTextForUpEvent() {
if (touchPosition != DEFAULT_TOUCH_POSITION) {
if (System.currentTimeMillis() - downTime <= DOWN_TIME_OUT) //长按时不触发
mOnFlowItemClickListener.onItemClick(touchPosition); //执行点击事件
stopRipple(); //停止绘制水波效果
}
}
@Override
public boolean onTouchEvent(MotionEvent event) {
if (mOnFlowItemClickListener != null && mTextPoints != null) {
Point touchPoint = new Point(event.getX(), event.getY());
switch (event.getAction()) {
case MotionEvent.ACTION_DOWN:
touchTextForDownEvent(touchPoint);
return true;
case MotionEvent.ACTION_MOVE:
touchTextForMoveEvent(touchPoint);
return true;
case MotionEvent.ACTION_UP:
touchTextForUpEvent();
return true;
default:
break;
}
}
return super.onTouchEvent(event);
}
<3>绘制水波纹
我们需要先绘制一个圆,值得注意的是,我们的绘制的圆的范围不能超过响应点击事件的文本块的大小。
/**
* 绘制圆
*
* @param canvas 画布
* @param rect 绘制坐标区间
*/
private void drawCircle(Canvas canvas, Rect rect) {
//指定绘制范围
canvas.clipRect(
rect.pointLeftTop.x, rect.pointLeftTop.y,
rect.pointRightBottom.x, rect.pointRightBottom.y);
mPaint.setColor(rippleColor);
canvas.drawCircle(touchPoint.x, touchPoint.y, circleR, mPaint);
canvas.drawColor(rippleColorBg);
}
绘制水波纹时,分两种,一种是圆慢慢变大,一种是快速变大。这里使用一个标志isDown用于区别。
/**
* 绘制水波纹
*
* @param canvas 画布
* @param rect 绘制坐标区间
*/
private void drawRipple(Canvas canvas, Rect rect) {
if (touchPoint != null) {
if (circleR < circleRMax) { //当前半径小于最大半径
circleR += isDown ? circleRMax / 120 : circleRMax / 20;
drawCircle(canvas, rect);
postInvalidateDelayed(16);
} else {
if (isDown) { //保持按下状态时
drawCircle(canvas, rect);
postInvalidateDelayed(16);
}
}
}
}
这些方法总归需要在重写的onDraw方法中调用,但是使用的时候遇到一个问题,就是后面绘制的覆盖前面绘制的问题,所以绘制的顺序比较重要,而且这里需要借助图层的功能。绘制过程如下:
首先绘制文本块背景,因为它处于最底层;
然后有两种情况,1.如果当前绘制文本块触发点击事件时,需要绘制文本和水波纹,这时需要新建一个图层,在新图层上绘制文本,然后使用图层合成的功能(使上下两层都显示,下层居上显示),接着绘制水波纹,这样水波纹才会在文本之下;2.如果没有触发点击事件,则直接绘制文本。
所以onDraw方法修改如下:
@Override
protected void onDraw(Canvas canvas) {
if (mTextPoints != null) {
for (int i = 0; i < mTextPoints.length; i++) {
Rect rect = mTextPoints[i];
if (rect != null) {
drawTextBG(canvas, rect);
if (touchPosition == i) {
int txtLayer = canvas.saveLayer(0, 0, getWidth(), getHeight(), null, Canvas.CLIP_TO_LAYER_SAVE_FLAG); //新建一个图层
drawText(canvas, rect, i);
mPaint.setXfermode(new PorterDuffXfermode(PorterDuff.Mode.DST_OVER)); //设置图层合成,上下两层都显示,下层居上显示
drawRipple(canvas, rect);
mPaint.setXfermode(null); //还原图层合成
canvas.restoreToCount(txtLayer); //恢复到txtLayer图层
} else {
drawText(canvas, rect, i);
}
}
}
}
}
如有错误,敬请指教。
1 : http://kf.tutusoso.com/kf_mobile/article/9_31376_30207.asp
2 : http://www.tuicool.com/articles/u2eEbe
3 : http://www.111cn.net/sj/android/117381.htm
4 : http://www.android100.org/html/201605/19/238626.html