最近在项目中遇到一个需求,产品要求实现一个可以部分点击的 TextView,可点击的部分需要有虚线下划线以及在点击区域弹出 Popupwindow,这里把我的实现过程记录下来。
实现点击很简单,google 给提供了 SpannableString 来实现对 TextView 的各种操作,包括点击、字体、文字颜色等等,当然也包括有下划线,但是产品要求下划线是虚线,google 提供的 api 这个时候就不能满足要求了;而且产品还要求在点击的位置弹出 popu。当然第一时间想到的就是自定义 View 了,这里一开始想了两种方案:
后来经过衡量选择了第二种方案,难点就在于坐标的计算上,因为不管是划线,还是弹出 popu 都需要一个坐标,这里 google 给提供了一个类——Layout,layout 提供了一系列的操作文字元素的 api,它就是接下来实现功能的关键。
根据以上的一些方法,基本上就可以计算出要点击位置的坐标,以及弹出 popu 的位置。下面开始编码。
这里我是定义了一个 UnderLineOptions 类来实现下划线类型的配置,以方便进行扩展,这里是用 kotlin 来写的。
class UnderLineOptions {
enum class Style {
LINE_STYLE_DOTTED, LINE_STYLE_STROKE
}
// 线宽 -1 表示默认
var lineHeight = -1
// 线类型
var lineStyle = Style.LINE_STYLE_DOTTED
// 颜色
var lineColor = Color.RED
var lineStart = 0
var lineEnd = 0
var contentStr: String = ""
//存储下划线起始坐标的列表,0和偶数位表示开始,奇数位表示结束,坐标用一个数组来存储分别为左,上,右,下
var linesXY: List? = null
var myClickableSpan: MyClickableSpan? = null
constructor(lineStart: Int, lineEnd: Int, lineStyle: Style, lineColor: Int) {
this.lineStyle = lineStyle
this.lineColor = lineColor
this.lineStart = lineStart
this.lineEnd = lineEnd
}
constructor(lineStart: Int, lineEnd: Int) : this(lineStart, lineEnd, Style.LINE_STYLE_DOTTED, Color.RED, null)
constructor(lineStart: Int, lineEnd: Int, lineColor: Int) : this(lineStart, lineEnd, Style.LINE_STYLE_DOTTED, lineColor, null)
constructor(lineStart: Int, lineEnd: Int, lineStyle: Style, lineColor: Int) : this(lineStart, lineEnd, lineStyle, lineColor, null)
}
然后重写 TextView 类为 MyTextViewJ,由于代码太多这里只贴出关键代码;
首先是计算某一个字符的坐标的方法,它返回一个数组里面存储了坐标信息,依次是左,上,右,下:
private float[] measureXY(int poi) {
float[] floats = new float[4];
Layout layout = getLayout();
int line = layout.getLineForOffset(poi);
Rect rect = new Rect();
layout.getLineBounds(line, rect);
// 原因不明,获取到的坐标左右相同
//左
floats[0] = layout.getPrimaryHorizontal(poi) + getPaddingLeft();
//上
floats[1] = rect.top + getPaddingTop();
//右
floats[2] = layout.getSecondaryHorizontal(poi) + getPaddingLeft();
//下
floats[3] = rect.bottom + getPaddingTop();
return floats;
}
计算配置类相关信息并添加进列表的方法:
//下划线配置列表
private List<UnderLineOptions> lineOptions = new ArrayList<>();
private boolean addOptions(UnderLineOptions option) {
int s = option.getLineStart();
int e = option.getLineEnd();
if (s > getText().toString().length()) {
return false;
}
if (e < 0) {
return false;
}
int start = s < 0 ? 0 : s;
int end = e > getText().toString().length() ? getText().toString().length() : e;
float[] startXY = measureXY(start);
float[] endXY = measureXY(end);
List<float[]> listXY = new ArrayList<>();
if (startXY[1] == endXY[1]) { // 如果只有一行
listXY.add(startXY);
listXY.add(endXY);
option.setLinesXY(listXY);
} else {
// 这里处理折行的情况
int lineStart = getLayout().getLineForOffset(start);
int lineEnd = getLayout().getLineForOffset(end);
int lineNum = lineStart;
while (lineNum <= lineEnd) {
Rect rect1 = new Rect();
getLayout().getLineBounds(lineNum, rect1);
if (lineNum == lineStart) { // 第一行
float[] endXYN = new float[]{getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
startXY[0],
getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
startXY[3]};
listXY.add(startXY);
listXY.add(endXYN);
} else if (lineNum == lineEnd) { // 最后一行
float[] startXYN = new float[]{measureXY(getLayout().getLineStart(lineNum))[0],
endXY[0],
measureXY(getLayout().getLineStart(lineNum))[0],
endXY[3]};
listXY.add(startXYN);
listXY.add(endXY);
} else { // 中间的行
Rect rect = new Rect();
getLayout().getLineBounds(lineNum, rect);
float[] startXYN = new float[]{measureXY(getLayout().getLineStart(lineNum))[0],
rect.top + getPaddingTop(),
measureXY(getLayout().getLineStart(lineNum))[0],
rect.bottom + getPaddingTop()};
float[] endXYN = new float[]{getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
rect.top + getPaddingTop(),
getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart(lineNum))[0],
rect.bottom + getPaddingTop()};
listXY.add(startXYN);
listXY.add(endXYN);
}
lineNum++;
}
option.setLinesXY(listXY);
}
lineOptions.add(option);
return true;
}
添加下划线的配置主要是要处理一个问题就是需要添加下划线的起始字符是跨越多行还是只有一行,如果涉及到多行就需要循环遍历并把每一段的起始位置添加进 option 中,所以 option 中用一个 list 来保存多段下划线的起始位置。
最后是 onDraw 方法修改:
private Paint paint;
private DashPathEffect effects = new DashPathEffect(new float[]{5f, 5f, 5f, 5f}, 3f);
private Path path;
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
// 循环遍历每一个配置文件
for (UnderLineOptions options: lineOptions) {
if (options.getLinesXY() != null) {
if (options.getLineStyle() == UnderLineOptions.Style.LINE_STYLE_DOTTED) {
paint.setPathEffect(effects);
} else {
paint.setPathEffect(null);
}
int color = options.getLineColor();
paint.setColor(color);
//循环遍历来画下划线 画一行或者多行
for (int i = 0; i < options.getLinesXY().size(); i++) {
if (i % 2 == 0) {
// 用下标的奇偶来表示开始还是结束
path.moveTo(options.getLinesXY().get(i)[0], options.getLinesXY().get(i)[3]);
} else {
path.lineTo(options.getLinesXY().get(i)[0], options.getLinesXY().get(i)[3]);
canvas.drawPath(path, paint);
path.reset();
}
}
}
}
}
到这里画下划线的关键代码已经都有了,然后再添加一些 set 方法就可以了,完整代码已经上传到 github 上戳这里。
使用 SpannableString 很容易就能实现点击事件,同时我们上面已经有了点击位置的坐标,两者结合起来很简单的就能实现在特定位置弹出 popu 了。
关于 SpannableString 的使用请戳这里。
首先重写了 ClickableSpan 为 MyClickableSpan,这个类也是用 kotlin 来写的。
class MyClickableSpan(start: Int, end: Int) : ClickableSpan() {
var mStart = start
var mEnd = end
// 点击回调
lateinit var onClickListener: (v: View, str: String, x: Int, y: Int) -> Unit
var contentStr = ""
// x y 用来记录弹出框在 x 和 y 轴的偏移量,这里也可以用一个数组来将被点击位置的起始坐标传递回来
var x = 0
var y = 0
override fun onClick(p0: View?) {
if (!::onClickListener.isInitialized) {
return
}
p0?.let {
onClickListener(p0, contentStr, x, y)
}
}
override fun updateDrawState(ds: TextPaint?) {
// android默认被点击位置是有下划线的 源码如下:
//ds.setColor(ds.linkColor);
//ds.setUnderlineText(true);
// 在这个方法中我们可以自己指定被点击位置的样式,这里我偷了个懒直接设置了红色
ds?.color = Color.RED
}
}
在 UnderLineOptions 中添加 ClickableSpan 字段
var myClickableSpan: MyClickableSpan? = null
在 MyTextViewJ.addOptions() 中将计算出来的坐标信息设置给 myClickableSpan:
private boolean addOptions(UnderLineOptions option) {
···
if (option.getMyClickableSpan() != null) {
option.getMyClickableSpan().setMStart(start);
option.getMyClickableSpan().setMEnd(end);
// 设置被点击的具体内容。
option.getMyClickableSpan().setContentStr(getText().toString().subSequence(start, end).toString());
}
if (startXY[1] == endXY[1]) {
listXY.add(startXY);
listXY.add(endXY);
//找到弹出框的中间点
int x = (int) (startXY[0] + (endXY[0] - startXY[0]) / 2);
if(option.getMyClickableSpan() != null){
option.getMyClickableSpan().setX(x);
option.getMyClickableSpan().setY((int) endXY[1]);
}
option.setLinesXY(listXY);
} else {
// 对于折行的文本,可以根据不同的产品要求来确定具体的弹窗位置。
}
···
}
对于折行点击的情况在这里我没有给出来,因为不同的产品需求是不同的,在此次实现中我们只考虑一行的情况。
到这里所有的关键代码就都有了,然后就可以根据 MyClickableSpan 的回调来进行弹窗。测试的代码就不给出了,都在 github 上,在这里可以看到。
最大的问题就是点击冲突, TextView 的点击和它的局部点击事件冲突,在很多情况下我们当然是不希望他们有冲突的,解决办法有很多种,主要有这两种
翻了一下 TextView 的源码,研究了 TextView 局部点击的实现原理,关键代码其实很简单
public boolean onTouchEvent(MotionEvent event) {
···
if (touchIsFinished && mLinksClickable && mAutoLinkMask != 0 && textIsSelectable) {
// 检测被点击的位置是否设置了 ClickableSpan,如果有就调用它的onClick 方法
ClickableSpan[] links = ((Spannable) mText).getSpans(getSelectionStart(),
getSelectionEnd(), ClickableSpan.class);
if (links.length > 0) {
links[0].onClick(this);
handled = true;
}
}
····
if (handled) {
return true;
}
}
那么其实可以在 performClick() 方法中对它进行一个拦截:
@Override
public boolean performClick() {
ClickableSpan[] links = ((Spannable) getText()).getSpans(getSelectionStart(),
getSelectionEnd(), ClickableSpan.class);
if (links.length > 0) {
return false;
}
return super.performClick();
}
这里其实有两点
lineOptions.clear();
在 view 被销毁的时候清空一下下划线配置信息。在用 kotlin 写的 activity 中运行后出现了被点击位置总有一个和colorAccent 一样的背景色,原因不明,最后给 view 设置 theme,把 colorAccent 颜色设置成和 TextView 一样的背景色来解决问题。用java 写的代码没有这个问题??如果大家也遇到相同的情况欢迎在评论区讨论。
以上就是本篇的所有内容,所有代码已经上传到 github 中——戳这里,欢迎 star。
版权声明:本文为博主原创文章,转载请声明出处,请尊重别人的劳动成果,谢谢!