效果图如上图,大家可以看到今天要实现的功能主要有虚线下划线
和点击文本
。
下面我们来分别分析下实现原理和知识点,最后给大家放上关键代码。
给文本添加下划线相信大家都会,这不就是富文本的内容吗?提到富文本大家可能会想到SpannableStringBuilder
。ClickableSpan
的默认效果就是带下划线。但今天我们的目标是虚线下划线,所以我们可能要自己手动改造下了。这里我采用的是自定义view手动画线的方式实现。
大致思路就是我们需要算出每一段下划线的位置,然后在onDraw方法中根据坐标在划线。实现步骤大致为:
分两种情况讨论:
1)下划线都在一行上面;
2)下划线不在一行上:
1)保存第一行的坐标;
2)保存最后一行的坐标;
3)计算折行整行的坐标;
注意:计算坐标时我们仍然会用到Layout
提供的一些方法。
getLineForOffset(int offset)
获取指定字符的行号;getLineBounds(int line, Rect bounds)
获取指定行的所在的区域;getPrimaryHorizontal(int offset)
获取指定字符的左坐标;getSecondaryHorizontal(int offset)
获取指定字符的辅助水平偏移量;getLineMax(int line)
获取指定行的宽度,包含缩进但是不包含后面的空白,可以认为是获取文本区域显示出来的一行的宽度;getLineStart(int line)
获取指定行的第一个字符的下标;
paint = new Paint();
paint.setStyle(Paint.Style.STROKE);//描边
paint.setStrokeWidth(6);//描边宽度
setHighlightColor(Color.TRANSPARENT);//设置选中文字背景色高亮显示
Path path = new Path();
path.addCircle(0, 0, 2, Path.Direction.CCW);
PathEffect effects = new PathDashPathEffect(path, 6, 1, PathDashPathEffect.Style.ROTATE);//设置路径样式
paint.setPathEffect(effects);
注意:
1、设置高亮颜色,如果不设置会取ClickableSpan的默认高亮颜色;
2、设置路径样式PathEffect,如果不设置的话就是直线效果了;
ClickableSpan
可以让我们在点击TextView相应文字时响应点击事件,比如常用的URLSpan
,会在点击时打开相应的链接。
这里我们需要点击文字并弹出PopupWindow,所以需要重写ClickableSpan,根据自己的需求来开发onClick接口;
注意:重写时,要记得去掉ClickableSpan的默认下划线,修改选中文字的颜色;
关键代码如下:
UnderlineTextView
public class UnderlineTextView extends AppCompatTextView {
private List underLineOptionsList = new ArrayList<>();
private Paint paint;
private Path path = new Path();
public UnderlineTextView(Context context) {
this(context, null);
}
public UnderlineTextView(Context context, AttributeSet attrs) {
this(context, attrs, 0);
}
public UnderlineTextView(Context context, AttributeSet attrs, int defStyleAttr) {
super(context, attrs, defStyleAttr);
paint = new Paint();
paint.setStyle(Paint.Style.STROKE);
paint.setStrokeWidth(6);
setHighlightColor(Color.TRANSPARENT);//设置选中文字背景色高亮显示
}
//计算某一个字符的坐标的方法,它返回一个数组里面存储了坐标信息,依次是左,上,右,下
private float[] measureXY(int offset) {
float[] floats = new float[4];
Layout layout = getLayout();
int line = layout.getLineForOffset(offset);
Rect rect = new Rect();
layout.getLineBounds(line, rect);
//左
floats[0] = layout.getPrimaryHorizontal(offset) + getPaddingLeft();
//上
floats[1] = rect.top + getPaddingTop();
//右
floats[2] = layout.getSecondaryHorizontal(offset) + getPaddingRight();
//下
floats[3] = rect.bottom + getPaddingBottom();
return floats;
}
public void setLine(@NonNull UnderLineOptions options) {
post(() -> {
if (!addOptions(options)) {
return;
}
invalidate();
});
}
public void setLines(@NonNull List optionsList) {
underLineOptionsList.clear();
post(() -> {
for (UnderLineOptions options : optionsList) {
if (!addOptions(options)) {
break;
}
}
invalidate();
});
}
public boolean addOptions(UnderLineOptions underLineOptions) {
int start = underLineOptions.getLineStart();
int end = underLineOptions.getLineEnd();
if (start > getText().toString().length() || end < 0) {
return false;
}
start = start < 0 ? 0 : start;
end = end > getText().toString().length() ? getText().toString().length() : end;
underLineOptions.setContent(getText().toString().substring(start, end));
if (underLineOptions.getClickableSpan() != null) {
underLineOptions.getClickableSpan().setStart(start);
underLineOptions.getClickableSpan().setEnd(end);
underLineOptions.getClickableSpan().setContent(getText().toString().substring(start, end));
}
// 可以通过这种方法获取被这一部分是否可以被点击
// ClickableSpan[] links = ((Spannable) getText()).getSpans(start,end, ClickableSpan.class);
// System.out.println(getSelectionStart());
// System.out.println(getSelectionEnd());
// System.out.println(links.length > 0 ? links[0] : links);
float[] startXY = measureXY(start);
float[] endXY = measureXY(end);
List listXY = new ArrayList<>();
if (startXY[1] == endXY[1]) {//如果只有一行
listXY.add(startXY);
listXY.add(endXY);
//找到弹出框的中间点
if (underLineOptions.getClickableSpan() != null) {
int x = (int) (startXY[0] + (endXY[0] - startXY[0]) / 2);
underLineOptions.getClickableSpan().setX(x);
underLineOptions.getClickableSpan().setY((int) startXY[3]);
}
underLineOptions.setLineXYs(listXY);
} else {//处理折行情况
// 对于折行的弹窗,只能根据需求来做了。
int lineStart = getLayout().getLineForOffset(start);
int lineEnd = getLayout().getLineForOffset(end);
int lineNum = lineStart;
while (lineNum <= lineEnd) {
Rect rect = new Rect();
getLayout().getLineBounds(lineNum, rect);
if (lineNum == lineStart) {//第一行
float[] endXYN = new float[]{getLayout().getLineMax(lineNum) + measureXY(getLayout().getLineStart
(lineNum))[0], startXY[1], getLayout().getLineMax(lineNum) + measureXY(getLayout()
.getLineStart(lineNum))[2], startXY[3]};
listXY.add(startXY);
listXY.add(endXYN);
//找到弹出框的中间点
if (underLineOptions.getClickableSpan() != null) {
int x = (int) (startXY[0] + (endXYN[0] - startXY[0]) / 2);
underLineOptions.getClickableSpan().setX(x);
underLineOptions.getClickableSpan().setY((int) startXY[3]);
}
} else if (lineNum == lineEnd) {//最后一行
float[] startXYN = new float[]{measureXY(getLayout().getLineStart(lineNum))[0], endXY[1],
measureXY(getLayout().getLineStart(lineNum))[2], endXY[3]};
listXY.add(startXYN);
listXY.add(endXY);
} else {
Rect rect1 = new Rect();
getLayout().getLineBounds(lineNum, rect1);
float[] startXYN = new float[]{measureXY(getLayout().getLineStart(lineNum))[0], rect.top +
getPaddingTop(), measureXY(getLayout().getLineStart(lineNum))[2], 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))[2], rect.bottom + getPaddingTop()};
listXY.add(startXYN);
listXY.add(endXYN);
}
lineNum++;
}
underLineOptions.setLineXYs(listXY);
}
underLineOptionsList.add(underLineOptions);
return true;
}
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
for (UnderLineOptions options : underLineOptionsList) {
if (options.getLineXYs() != null) {
if (options.getLineStyle() == UnderLineOptions.Style.LINE_STYLE_DOTTED) {
Path path = new Path();
path.addCircle(0, 0, 2, Path.Direction.CCW);
PathEffect effects = new PathDashPathEffect(path, 6, 1, PathDashPathEffect.Style.ROTATE);//设置路径样式
paint.setPathEffect(effects);
} else {
paint.setPathEffect(null);
}
paint.setColor(options.getLineColor());
for (int i = 0; i < options.getLineXYs().size(); i++) {
Log.d("lixx", i + " xy-> " + options.getLineXYs().get(i)[0] + "," + options.getLineXYs().get(i)
[1] + "," + options.getLineXYs().get(i)[2] + "," + options.getLineXYs().get(i)[3]);
if (i % 2 == 0) {//用下标的奇偶来表示开始还是结束, 偶数开始,奇数结束
path.moveTo(options.getLineXYs().get(i)[0], options.getLineXYs().get(i)[3]);
} else {
path.lineTo(options.getLineXYs().get(i)[0], options.getLineXYs().get(i)[3]);
canvas.drawPath(path, paint);//每一行画一条线
path.reset();
}
}
}
}
}
@Override
public boolean performClick() {//拦截处理,TextView 的点击和它的局部点击事件冲突
ClickableSpan[] links = ((Spannable) getText()).getSpans(getSelectionStart(), getSelectionEnd(),
ClickableSpan.class);
if (links.length > 0) {
return false;
}
return super.performClick();
}
@Override
protected void onDetachedFromWindow() {
underLineOptionsList.clear();
super.onDetachedFromWindow();
}
}
UnderLineOptions
public class UnderLineOptions {
public @interface Style {
int LINE_STYLE_DOTTED = 1;
int LINE_STYLE_STROKE = 2;
}
private int lineHeight = -1;
private int lineStyle = Style.LINE_STYLE_DOTTED;
private int lineColor = Color.WHITE;
private int lineStart = 0;
private int lineEnd = 0;
private String content = "";
private List lineXYs;
private CustomClickableSpan clickableSpan;
private boolean clickable = false;
public UnderLineOptions(int lineStyle, int lineColor, int lineStart, int lineEnd, CustomClickableSpan
clickableSpan) {
this.lineStyle = lineStyle;
this.lineColor = lineColor;
this.lineStart = lineStart;
this.lineEnd = lineEnd;
this.clickableSpan = clickableSpan;
}
public UnderLineOptions(int lineStart, int lineEnd) {
this(Style.LINE_STYLE_DOTTED, Color.RED, lineStart, lineEnd, null);
}
public UnderLineOptions(int lineStart, int lineEnd, CustomClickableSpan clickableSpan) {
this.lineStart = lineStart;
this.lineEnd = lineEnd;
this.clickableSpan = clickableSpan;
}
public UnderLineOptions(int lineColor, int lineStart, int lineEnd) {
this(Style.LINE_STYLE_DOTTED, lineColor, lineStart, lineEnd, null);
}
public int getLineStart() {
return lineStart;
}
public int getLineEnd() {
return lineEnd;
}
public void setLineXYs(List lineXYs) {
this.lineXYs = lineXYs;
}
public List getLineXYs() {
return lineXYs;
}
public int getLineStyle() {
return lineStyle;
}
public int getLineColor() {
return lineColor;
}
public CustomClickableSpan getClickableSpan() {
return clickableSpan;
}
public void setClickableSpan(CustomClickableSpan clickableSpan) {
this.clickableSpan = clickableSpan;
}
public void setContent(String content) {
this.content = content;
}
public String getContent() {
return content;
}
}
CustomClickableSpan
public class CustomClickableSpan extends ClickableSpan {
private int mStart;
private int mEnd;
private int x;
private int y;
private String content;
private OnClickListener onClickListener;
public CustomClickableSpan(){
}
public CustomClickableSpan(int start, int end) {
this(start, end, "");
}
public CustomClickableSpan(int mStart, int mEnd, String content) {
this.mStart = mStart;
this.mEnd = mEnd;
this.content = content;
}
public void setStart(int mStart) {
this.mStart = mStart;
}
public int getStart() {
return mStart;
}
public void setEnd(int mEnd) {
this.mEnd = mEnd;
}
public int getEnd() {
return mEnd;
}
public void setContent(String content) {
this.content = content;
}
public void setX(int x) {
this.x = x;
}
public void setY(int y) {
this.y = y;
}
public void setOnClickListener(OnClickListener onClickListener) {
this.onClickListener = onClickListener;
}
@Override
public void onClick(View widget) {
if (onClickListener != null) {
onClickListener.onClick(widget, content, x, y);
}
}
@Override
public void updateDrawState(TextPaint ds) {
super.updateDrawState(ds);
// android默认被点击位置是有下划线的 源码如下:
// ds.setColor(ds.linkColor);
ds.setUnderlineText(false);//去掉选中下划线
// // 在这个方法中我们可以自己指定被点击位置的样式,这里我偷了个懒直接设置了红色
ds.setColor(Color.RED);//选中文字颜色
}
public interface OnClickListener {
void onClick(View v, String content, int x, int y);
}
}
MainActivity
private PopupWindow popupWindow;
private TextView tv;
private String popContent;
private long showTime = 1500;//ms
private long delayTime = showTime;
private Disposable dismissDisposable;
private void setTvUnderline() {
SpannableString spanableInfo = new SpannableString("这是一个测试文本,点击我看看!");
CustomClickableSpan clickableSpan = new CustomClickableSpan();
clickableSpan.setOnClickListener(this);
CustomClickableSpan clickableSpan2 = new CustomClickableSpan();
clickableSpan2.setOnClickListener(this);
spanableInfo.setSpan(clickableSpan, 4, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);//一段文字中可以实现多个文本点击
spanableInfo.setSpan(clickableSpan2, 9, 15, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE);
tvUnderline.setText(spanableInfo);
tvUnderline.setMovementMethod(LinkMovementMethod.getInstance());
//添加多处下划线
List lines = new ArrayList<>();
UnderLineOptions lineOptions2 = new UnderLineOptions(4, 6, clickableSpan);
UnderLineOptions lineOptions = new UnderLineOptions(9, 15, clickableSpan2);
lines.add(lineOptions);
lines.add(lineOptions2);
tvUnderline.setLines(lines);
}
private void initPopUp(String content) {
this.popContent = content;
delayTime = showTime;
LinearLayout layout = new LinearLayout(this);
layout.setBackgroundColor(Color.GRAY);
tv = new TextView(this);
tv.setLayoutParams(new LinearLayout.LayoutParams(ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams
.WRAP_CONTENT));
tv.setText(content);
tv.setTextColor(Color.WHITE);
layout.addView(tv);
popupWindow = new PopupWindow(layout, LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams
.WRAP_CONTENT);
// popupWindow.setFocusable(true);
// popupWindow.setOutsideTouchable(false);
// popupWindow.setBackgroundDrawable(new BitmapDrawable());
}
private void showPopUp(View v, String content, int x, int y) {
if (popupWindow != null && popupWindow.isShowing()) {
if (dismissDisposable != null && !dismissDisposable.isDisposed()) {
dismissDisposable.dispose();
}
if (!TextUtils.equals(content, popContent)) {
popupWindow.dismiss();
} else {
delayTime += showTime;
dismissDelay(delayTime);
return;
}
}
initPopUp(content);
TextPaint textPaint = tv.getPaint();
int width = (int) (textPaint.measureText(content));
Paint.FontMetrics fontMetrics = textPaint.getFontMetrics();
int height = (int) (fontMetrics.bottom - fontMetrics.top);
popupWindow.showAtLocation(v, Gravity.NO_GRAVITY, x - width / 2, y - height);
Log.d("lixx", "showpopup delayTime-> " + delayTime);
dismissDelay(delayTime);
}
private void dismissDelay(long delay) {
dismissDisposable = Observable.timer(delayTime, TimeUnit.MILLISECONDS)
.observeOn(AndroidSchedulers.mainThread())
.subscribeOn(Schedulers.io())
.subscribe(aLong -> {
Log.d("lixx", "dismiss delayTime-> " + delayTime);
if (popupWindow != null && popupWindow.isShowing()) {
popupWindow.dismiss();
}
});
}
@Override
public void onClick(View v, String content, int x, int y) {
showPopUp(v, content, x, y);
}