打造自定义TextView实现文本两端对齐,并实现长按自由选择文字弹出自定义ActionMenu菜单功能
写在前面的话
从安卓自由开发者转为职业开发者也有一年了,期间在CSDN社区受益很多,也很想写一些自己对于android开发的感悟和理解,可是一直迟迟没有下笔,一方面是项目确实很忙,抽不开时间;另一方面鄙人给自己定下的原则是写东西不要泛而烂,一定要是干货。因此一年来虽然积累了很多自己写的东西,却一直很迟疑这些东西写出来会不会没有营养。今年开始鄙人决定开始陆续写一些东西放在这里,写的如何也请各位过客轻拍和指正。
好了,开始这篇文章的话题。去年鄙人开发了一个阅读类的app,处理文本的时候,想要实现两个功能,一个是文本两端对齐,一个是可以自定义选中文字后弹出的菜单。第一个功能其实可以用webview实现,而原生textview并没有两端对齐的特性。第二个功能android其实提供了相关的api(textview的setCustomActionMenuCallBack()方法),但是国内厂商的定制化rom把这个接口屏蔽掉了(经测试nexus5,nexus5x,nexus6p都可以实现调用该方法自定义菜单,而小米,华为,oppo调用这个方法都不起作用);因此不管用webview还是textview都无法满足我想要实现的功能。在查阅了大量资料和博客后,我决定写一个自定义textview实现上面需要的功能。
先看一张效果图:
完成上述功能,主要有以下三个功能点:
- 文字两端对齐处理
- 长按选择文字并高亮文本
- 选中文字后ActionMenu的弹出
由于文章过长,我分成了两篇,这一篇主要讲部分1,2;下一篇将部分3和其它的一些细节。
由于是自定义textview,首先需要继承原生TextView,然后在此基础上重写一系列方法。其实也可以选择继承EditText,因为EditText也是继承自TextView的。选择TextView继承或EditText继承,没有太大的区别,只有一点细节需要处理。在这里我选择了EditText作为SelectableTextView的继承。
在开始自定义之旅之前,我们需要屏蔽掉EditText原生那套文本选择逻辑,不然在功能上会出现冲突。那么要如何屏蔽呢?阅读EditText和TextView源码后发现,通过重写EditText的getDefaultEditable方法,返回false就会把原生自带的ActionMenu屏蔽掉。
@Override
public boolean getDefaultEditable() {
// 返回false,屏蔽掉系统自带的ActionMenu
return false;
}
至于具体的源码解读在这里不做过多篇墨,如果感兴趣可以去读相关源码。
1.文字两端对齐处理
接下来我们讨论第一部分文字两端对齐的处理。如果对自定义view比较熟悉的话,就应该知道这部分需要在onDraw方法里实现对文字的重新绘制以达到两端对齐的效果。首先先介绍一下需要用到的几个辅助类:
- Layout
- StaticLayout
安卓自带的Layout类可以获得一段文本的行数以及每一行对应字符的开始和结束处的位移:
Layout layout = getLayout();
int textTotalCount = layout.getLineCount();
int lineStart = layout.getLineStart(i);
int lineEnd = layout.getLineEnd(i);
而StaticLayout是我们实现两端对齐的关键。其getDesiredWidth()方法可以计算出一段字符串的实际宽度,不包括字符间距。
// 获取每行字符串的宽度(不包括字符间距)
float desiredWidth = StaticLayout.getDesiredWidth(text_str, lineStart, lineEnd, getPaint());
介绍完这两个辅助类,文本两端对齐的基本思路就有了,很简单:
循环遍历文本的每一行,计算每一行文本字符串的宽度;
用控件本身宽度减去该行字符串的宽度获得多余的宽度;
将多余的宽度均分给该行每一个字符,得到一个平均补偿宽度;
绘制该行。每绘制一个字符,接着向右偏移一个补偿宽度。
这样就基本实现了文本的两端对齐。下面我们看代码:
/**
* 重绘文字,两端对齐
*
* @param canvas
*/
private void drawTextWithJustify(Canvas canvas) {
// 文字画笔
TextPaint textPaint = getPaint();
textPaint.setColor(getCurrentTextColor());
textPaint.drawableState = getDrawableState();
String text_str = getText().toString();
// 当前所在行的Y向偏移
int currentLineOffsetY = getPaddingTop();
currentLineOffsetY += getTextSize();
Layout layout = getLayout();
//循环每一行,绘制文字
for (int i = 0; i < layout.getLineCount(); i++) {
int lineStart = layout.getLineStart(i);
int lineEnd = layout.getLineEnd(i);
//获取到TextView每行中的内容
String line_str = text_str.substring(lineStart, lineEnd);
// 获取每行字符串的宽度(不包括字符间距)
float desiredWidth = StaticLayout.getDesiredWidth(text_str, lineStart, lineEnd, getPaint());
if (isLineNeedJustify(line_str)) {
//最后一行不需要重绘
if (i == layout.getLineCount() - 1) {
canvas.drawText(line_str, getPaddingLeft(), currentLineOffsetY, textPaint);
} else {
drawJustifyTextForLine(canvas, line_str, desiredWidth, currentLineOffsetY);
}
} else {
canvas.drawText(line_str, getPaddingLeft(), currentLineOffsetY, textPaint);
}
//更新行Y向偏移
currentLineOffsetY += getLineHeight();
}
}
注意,在28行,对每一行做了一个判断,如果该行以换行符结尾,说明这一行是某一段的最后一行了,不需要两端对齐。
第31行,如果不需要两端对齐,直接用canvas的drawText方法绘制该行;
第33行,调用的drawJustifyTextForLine方法就是处理文本两端对齐的方法,看该方法的代码逻辑:
/**
* 重绘此行,两端对齐
*
* @param canvas
* @param line_str 该行所有的文字
* @param desiredWidth 该行每个文字的宽度的总和
* @param currentLineOffsetY 该行的Y向偏移
*/
private void drawJustifyTextForLine(Canvas canvas, String line_str, float desiredWidth, int currentLineOffsetY) {
// 画笔X方向的偏移
float lineTextOffsetX = getPaddingLeft();
// 判断是否是首行
if (isFirstLineOfParagraph(line_str)) {
String blanks = " ";
// 画出缩进空格
canvas.drawText(blanks, lineTextOffsetX, currentLineOffsetY, getPaint());
// 空格需要的宽度
float blank_width = StaticLayout.getDesiredWidth(blanks, getPaint());
// 更新画笔X方向的偏移
lineTextOffsetX += blank_width;
line_str = line_str.substring(3);
}
// 计算相邻字符(或单词)之间需要填充的宽度,英文按单词处理,中文按字符处理
// (TextView内容的实际宽度 - 该行字符串的宽度)/(字符或单词个数-1)
if (isContentABC(line_str)) {
// 该行包含英文,以空格分割单词
String[] line_words = line_str.split(" ");
// 计算相邻单词间需要插入的空白
float insert_blank = mViewTextWidth - desiredWidth;
if (line_words.length > 1)
insert_blank = (mViewTextWidth - desiredWidth) / (line_words.length - 1);
// 遍历单词
for (int i = 0; i < line_words.length; i++) {
// 判断分割后的每一个单词;如果是纯英文,按照纯英文单词处理,直接在画布上画出单词;
// 如果包括汉字,则按照汉字字符处理,逐个字符绘画
// 如果只有一个单词,按中文处理
// 最后一个单词按照纯英文单词处理
String word_i = line_words[i] + " ";
if (line_words.length == 1 || (isContentHanZi(word_i) && i < line_words.length - 1)) {
// 单词按照汉字字符处理
// 计算单词中相邻字符间需要插入的空白
float insert_blank_word_i = insert_blank;
if (word_i.length() > 1)
insert_blank_word_i = insert_blank / (word_i.length() - 1);
// 遍历单词中字符,依次绘画
for (int j = 0; j < word_i.length(); j++) {
String word_i_char_j = String.valueOf(word_i.charAt(j));
float word_i_char_j_width = StaticLayout.getDesiredWidth(word_i_char_j, getPaint());
canvas.drawText(word_i_char_j, lineTextOffsetX, currentLineOffsetY, getPaint());
// 更新画笔X方向的偏移
lineTextOffsetX += word_i_char_j_width + insert_blank_word_i;
}
} else {
//单词按照纯英文处理
float word_i_width = StaticLayout.getDesiredWidth(word_i, getPaint());
canvas.drawText(word_i, lineTextOffsetX, currentLineOffsetY, getPaint());
// 更新画笔X方向的偏移
lineTextOffsetX += word_i_width + insert_blank;
}
}
} else {
// 该行按照中文处理
float insert_blank = (mViewTextWidth - desiredWidth) / (line_str.length() - 1);
for (int i = 0; i < line_str.length(); i++) {
String char_i = String.valueOf(line_str.charAt(i));
float char_i_width = StaticLayout.getDesiredWidth(char_i, getPaint());
canvas.drawText(char_i, lineTextOffsetX, currentLineOffsetY, getPaint());
// 更新画笔X方向的偏移
lineTextOffsetX += char_i_width + insert_blank;
}
}
}
第13-23行是对行首缩进的处理。第27行开始的 if…else 将文本按照英文和中文分解进行了处理。经过测试,纯英文文本和中英混合文本的两端对齐都达到了很好的效果,文末会放出托管到github的demo示例。
英文和中文的处理逻辑是一样的,注释也写的很清楚,这里还是以中文情况简单讲解一下:
第65行,通过空间本身宽度和字符宽度计算出补偿宽度;
第71行,每绘制完一个字符,将X向偏移向右偏移一个补偿宽度。
到此为止,我们已经实现了文本的两端对齐功能。放几张图感受一下:
2.长按选择文字并高亮文本
接下来,开始第二部分,长按选择文字并高亮文本。这一部分需要用到view的触摸事件相关内容。如果你很熟悉这部分,那么应该很轻松知道,我们需要在onTouchEvent里面搞事情。话不多说,先看代码:
@Override
public boolean onTouchEvent(MotionEvent event) {
int action = event.getAction();
Layout layout = getLayout();
int currentLine; // 当前所在行
switch (action) {
case MotionEvent.ACTION_DOWN:
Log.d("SelectableTextView", "ACTION_DOWN");
// 每次按下时,创建ActionMenu菜单,创建不成功,屏蔽长按事件
if (null == mActionMenu) {
mActionMenu = createActionMenu();
}
mTouchDownX = event.getX();
mTouchDownY = event.getY();
mTouchDownRawY = event.getRawY();
isLongPress = false;
isVibrator = false;
isLongPressTouchActionUp = false;
break;
case MotionEvent.ACTION_MOVE:
Log.d("SelectableTextView", "ACTION_MOVE");
// 先判断是否禁用了ActionMenu功能,以及ActionMenu是否创建失败,
// 二者只要满足了一个条件,退出长按事件
if (!isForbiddenActionMenu || mActionMenu.getChildCount() == 0) {
// 手指移动过程中的字符偏移
currentLine = layout.getLineForVertical(getScrollY() + (int) event.getY());
int mWordOffset_move = layout.getOffsetForHorizontal(currentLine, (int) event.getX());
// 判断是否触发长按事件
if (event.getEventTime() - event.getDownTime() >= TRIGGER_LONGPRESS_TIME_THRESHOLD
&& Math.abs(event.getX() - mTouchDownX) < TRIGGER_LONGPRESS_DISTANCE_THRESHOLD
&& Math.abs(event.getY() - mTouchDownY) < TRIGGER_LONGPRESS_DISTANCE_THRESHOLD) {
Log.d("SelectableTextView", "ACTION_MOVE 长按");
isLongPress = true;
isLongPressTouchActionUp = false;
mStartLine = currentLine;
mStartTextOffset = mWordOffset_move;
// 每次触发长按时,震动提示一次
if (!isVibrator) {
mVibrator.vibrate(30);
isVibrator = true;
}
}
if (isLongPress) {
if (!isTextJustify)
requestFocus();
mCurrentLine = currentLine;
mCurrentTextOffset = mWordOffset_move;
// 通知父布局不要拦截触摸事件
getParent().requestDisallowInterceptTouchEvent(true);
// 选择字符
Selection.setSelection(getEditableText(), Math.min(mStartTextOffset, mWordOffset_move),
Math.max(mStartTextOffset, mWordOffset_move));
}
}
break;
case MotionEvent.ACTION_UP:
Log.d("SelectableTextView", "ACTION_UP");
// 处理长按事件
if (isLongPress) {
currentLine = layout.getLineForVertical(getScrollY() + (int) event.getY());
int mWordOffsetEnd = layout.getOffsetForHorizontal(currentLine, (int) event.getX());
// 至少选中一个字符
mCurrentLine = currentLine;
mCurrentTextOffset = mWordOffsetEnd;
int maxOffset = getEditableText().length() - 1;
if (mStartTextOffset > maxOffset)
mStartTextOffset = maxOffset;
if (mCurrentTextOffset > maxOffset)
mCurrentTextOffset = maxOffset;
if (mCurrentTextOffset == mStartTextOffset) {
if (mCurrentTextOffset == layout.getLineEnd(currentLine) - 1)
mStartTextOffset -= 1;
else
mCurrentTextOffset += 1;
}
Selection.setSelection(getEditableText(), Math.min(mStartTextOffset, mCurrentTextOffset),
Math.max(mStartTextOffset, mCurrentTextOffset));
// 计算菜单显示位置
int mPopWindowOffsetY = calculatorActionMenuYPosition((int) mTouchDownRawY, (int) event.getRawY());
// 弹出菜单
showActionMenu(mPopWindowOffsetY, mActionMenu);
isLongPressTouchActionUp = true;
isLongPress = false;
} else if (event.getEventTime() - event.getDownTime() < TRIGGER_LONGPRESS_TIME_THRESHOLD) {
// 由于onTouchEvent最终返回了true,onClick事件会被屏蔽掉,因此在这里处理onClick事件
if (null != mOnClickListener)
mOnClickListener.onClick(this);
}
// 通知父布局继续拦截触摸事件
getParent().requestDisallowInterceptTouchEvent(false);
break;
}
return true;
}
具体的思路是,每次触发触摸事件时,首先ACTION_DOWN动作里进行一系列初始化工作。接着在ACTION_MOVE动作里判断是否触发了长按事件,并作出相应处理,最后在ACTION_UP动作里进行长按后续处理。
第25行是有关ActionMenu的相关判断,这部分将会在下一篇讲解。
第27行通过调用layout的getLineForVertical方法计算出当前手指触点处所对应的的行;
第28行通过调用layout的getOffsetForHorizontal方法计算出手指触点处对应的字符偏移;
第30行开始判断是否触发长按事件;触发条件有三个:
- 触摸事件超过阈值时间(500ms);
- X向移动位移未超过阈值位移;
- Y向移动位移未超过阈值位移;
满足这三个条件,进入长按事件。第35-44行对相关参数进行标记,并震动一次,告知已经进触发长按。第46-58行,开始处理长按事件,主要是第55行,调用Selection的setSelection根据手指的移动不断更新文本的选择。
到这里,有人可能会困惑,我们只讲了长按时对文本的选择,而对所选择的文本是如何进行高亮的呢?
不要急,我们慢慢道来。首先要知道一点,由于我们屏蔽了原生的文本高亮,因此在此处,当我们进行长按选择文本时,需要在ondraw方法里实时对所选文本绘制高亮色。那么,手指移动过程中如何触发ondraw操作呢?我想大部分人立刻想到了invalidate()方法。没错,调用invalidate()方法可以通知view重绘。但实际上我们不需要显示调用invalidate()方法了,因为Selection的setSelection方法本身就会不断通知view进行重绘。所以在第55行,当我们调用Selection的setSelection方法时,已经在不断调用onDraw方法了。
下面就是绘制所选文本的高亮色了,先看代码:
/**
* 绘制选中的文字的背景
*
* @param canvas
*/
private void drawSelectedTextBackground(Canvas canvas) {
if (mStartTextOffset == mCurrentTextOffset)
return;
// 文字背景高亮画笔
Paint highlightPaint = new Paint(Paint.ANTI_ALIAS_FLAG);
highlightPaint.setStyle(Paint.Style.FILL);
highlightPaint.setColor(mTextHighlightColor);
highlightPaint.setAlpha(60);
// 计算开始位置和结束位置的字符相对view最左侧的x偏移
float startToLeftPosition = calculatorCharPositionToLeft(mStartLine, mStartTextOffset);
float currentToLeftPosition = calculatorCharPositionToLeft(mCurrentLine, mCurrentTextOffset);
// 行高
int h = getLineHeight();
int paddingTop = getPaddingTop();
int paddingLeft = getPaddingLeft();
// 创建三个矩形,分别对应:
// 所有选中的行对应的矩形,起始行左侧未选中文字的对应的矩形,结束行右侧未选中的文字对应的矩形
RectF rect_all, rect_lt, rect_rb;
// sdk版本控制
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.KITKAT) {
if (mStartTextOffset < mCurrentTextOffset) {
rect_all = new RectF(paddingLeft, mStartLine * h + paddingTop,
mViewTextWidth + paddingLeft, (mCurrentLine + 1) * h + paddingTop);
rect_lt = new RectF(paddingLeft, mStartLine * h + paddingTop,
startToLeftPosition, (mStartLine + 1) * h + paddingTop);
rect_rb = new RectF(currentToLeftPosition, mCurrentLine * h + paddingTop,
mViewTextWidth + paddingLeft, (mCurrentLine + 1) * h + paddingTop);
} else {
rect_all = new RectF(paddingLeft, mCurrentLine * h + paddingTop,
mViewTextWidth + paddingLeft, (mStartLine + 1) * h + paddingTop);
rect_lt = new RectF(paddingLeft, mCurrentLine * h + paddingTop,
currentToLeftPosition, (mCurrentLine + 1) * h + paddingTop);
rect_rb = new RectF(startToLeftPosition, mStartLine * h + paddingTop,
mViewTextWidth + paddingLeft, (mStartLine + 1) * h + paddingTop);
}
// 创建三个路径,分别对应上面三个矩形
Path path_all = new Path();
Path path_lt = new Path();
Path path_rb = new Path();
path_all.addRect(rect_all, Path.Direction.CCW);
path_lt.addRect(rect_lt, Path.Direction.CCW);
path_rb.addRect(rect_rb, Path.Direction.CCW);
// 将左上角和右下角的矩形从path_all中减去
path_all.addRect(rect_all, Path.Direction.CCW);
path_all.op(path_lt, Path.Op.DIFFERENCE);
path_all.op(path_rb, Path.Op.DIFFERENCE);
canvas.drawPath(path_all, highlightPaint);
} else {
Path path_all = new Path();
path_all.moveTo(startToLeftPosition, (mStartLine + 1) * h + paddingTop);
path_all.lineTo(startToLeftPosition, mStartLine * h + paddingTop);
path_all.lineTo(mViewTextWidth + paddingLeft, mStartLine * h + paddingTop);
path_all.lineTo(mViewTextWidth + paddingLeft, mCurrentLine * h + paddingTop);
path_all.lineTo(currentToLeftPosition, mCurrentLine * h + paddingTop);
path_all.lineTo(currentToLeftPosition, (mCurrentLine + 1) * h + paddingTop);
path_all.lineTo(paddingLeft, (mCurrentLine + 1) * h + paddingTop);
path_all.lineTo(paddingLeft, (mStartLine + 1) * h + paddingTop);
path_all.lineTo(startToLeftPosition, (mStartLine + 1) * h + paddingTop);
canvas.drawPath(path_all, highlightPaint);
}
canvas.save();
canvas.restore();
}
当我们选择一段文本时,实际上是选择了一片矩形区域,而这片矩形区域要减去最上面一行最左边未选中文字对应的矩形区域和最下面一行最右边未选中文字对应的区域。明白这一点后,其实就很简单了,我们只要计算出所选文字对应的矩形区域的八个边界点,然后绘制依次通过这八个点的封闭路径就实现了对文本的背景高亮,大家可以自行想象一下。
绘制高亮色的具体思路是:
1.计算所选文本对应的矩形区域的边界(对应八个点的坐标);
2.创建依次通过这八个点的封闭路径;
3.通过canvas的drawPath方法绘制路径。
如此就实现了文本高亮功能。这里边最主要的一点就是八个点坐标的计算。实际上只有两个点的坐标比较难计算,即开始位置和结束位置的坐标。
第17,18行调用了一个方法calculatorCharPositionToLeft()来计算开始和结束位置的坐标,我们看一下具体代码:
/**
* 计算字符距离控件左侧的位移
*
* @param line 字符所在行
* @param charOffset 字符偏移量
*/
private float calculatorCharPositionToLeft(int line, int charOffset) {
String text_str = getText().toString();
Layout layout = getLayout();
int lineStart = layout.getLineStart(line);
int lineEnd = layout.getLineEnd(line);
String line_str = text_str.substring(lineStart, lineEnd);
if (line_str.equals("\n"))
return getPaddingLeft();
// 最左侧
if (lineStart == charOffset)
return getPaddingLeft();
// 最右侧
if (charOffset == lineEnd - 1)
return mViewTextWidth + getPaddingLeft();
float desiredWidth = StaticLayout.getDesiredWidth(text_str, lineStart, lineEnd, getPaint());
// 中间位置
// 计算相邻字符之间需要填充的宽度
// (TextView内容的实际宽度 - 该行字符串的宽度)/(字符个数-1)
float insert_blank = (mViewTextWidth - desiredWidth) / (line_str.length() - 1);
// 计算当前字符左侧所有字符的宽度
float allLeftCharWidth = StaticLayout.getDesiredWidth(text_str.substring(lineStart, charOffset), getPaint());
// 相邻字符之间需要填充的宽度 + 当前字符左侧所有字符的宽度
return insert_blank * (charOffset - lineStart) + allLeftCharWidth + getPaddingLeft();
}
我们看第27-37代码是不是觉得很熟悉?没错,其实就是获得某个字符相对于最坐边的偏移。逻辑跟第一部分我们处理文本两端对齐的逻辑是很相似的。
到现在位置,我们已经实现了文本的两端对齐和长按选择文本并高亮的功能,我们最后再看一下onDraw方法里都搞了些什么事情:
@Override
protected void onDraw(Canvas canvas) {
Log.d("SelectableTextView", "onDraw");
if (!isTextJustify) {
// 不需要两端对齐
super.onDraw(canvas);
} else {
//textview内容的实际宽度
mViewTextWidth = getMeasuredWidth() - getPaddingLeft() - getPaddingRight();
// 重绘文字,两端对齐
drawTextWithJustify(canvas);
// 绘制选中文字的背景,触发以下事件时需要绘制背景:
// 1.长按事件 2.全选事件 3.手指滑动过快时,进入ACTION_UP事件后,
// 可能会出现背景未绘制的情况
if (isLongPress | isActionSelectAll | isLongPressTouchActionUp) {
drawSelectedTextBackground(canvas);
isActionSelectAll = false;
isLongPressTouchActionUp = false;
}
}
}
代码很清晰了,不再过多讲解。文本高亮的效果请看最上面第一张图。
不知不觉写了这么多,很久没写文章了,难免会有一些表达很不清楚的地方,如果有疑问欢迎大家留言。
下一篇介绍第三部分:自定义ActionMenu
Android 自定义Textview实现文字两端对齐功能和长按自由选择文字弹出自定义ActionMenu功能(二)自定义ActionMenu
写了一个demo,托管到github上了
链接:https://github.com/devilist/AdvancedTextView