本文借鉴了 大牛 Android机动车的博文:
https://juejin.cn/post/6844903511407230990
近日公司项目需要做一个文章阅读器 其中有一项需求是给文本划线,比如 某些词的注释用双下划线;用户长按屏幕选择部分文本可以收藏这段文本(收藏划线) 也可以添加笔记(笔记划线)等,这时就需要区分不同的下划线。
如果要满足这个需求,用html或是SpannableStringBuilder(只能划单线并且线与文字的颜色只能一样,线的位置也不能调整)这种方式就完全无法满足需求。
大体思路:
将字符串或是span 放入textview后,首先计算出文本共有多少行,记录行号和每一行文字开始的索引(layout.getLineStart(i))和结束的索引(layout.getLineEnd(i))(i是行号),然后根据用户选择的文字的索引计算出第几行第几个文字索引开始划线,中间跨越了几行,结尾是在第几行第几个文字索引。为了记录行信息和划线信息我们设计一个类(TextIndex)
1 在textview里给文字划线首先想到的就是文字的位置索引
这里设两个变量:文字在textview里的字符串里的索引:
2 有了需要划线的文本的索引就可以计算出划线的坐标位置
这里设几个变量:
3 其他需要的变量:
到这里基本的变量都有了。
4 记录行信息和划线信息的类
5 初始化:
7 onDraw里 开始划线:
https://juejin.cn/post/6844903511407230990
关于textview文字的基线等知识:
近日公司项目需要做一个文章阅读器 其中有一项需求是给文本划线,比如 某些词的注释用双下划线;用户长按屏幕选择部分文本可以收藏这段文本(收藏划线) 也可以添加笔记(笔记划线)等,这时就需要区分不同的下划线。
如果要满足这个需求,用html或是SpannableStringBuilder(只能划单线并且线与文字的颜色只能一样,线的位置也不能调整)这种方式就完全无法满足需求。
大体思路:
将字符串或是span 放入textview后,首先计算出文本共有多少行,记录行号和每一行文字开始的索引(layout.getLineStart(i))和结束的索引(layout.getLineEnd(i))(i是行号),然后根据用户选择的文字的索引计算出第几行第几个文字索引开始划线,中间跨越了几行,结尾是在第几行第几个文字索引。为了记录行信息和划线信息我们设计一个类(TextIndex)
1 在textview里给文字划线首先想到的就是文字的位置索引
这里设两个变量:文字在textview里的字符串里的索引:
// 开始和结束位置索引
private int startIndex = 0;
private int endIndex = 0;
2 有了需要划线的文本的索引就可以计算出划线的坐标位置
这里设几个变量:
//x坐标系值:开始位置x坐标的值,结束位置x坐标的值,x坐标系文字的间距
private float x_start, x_stop, x_diff; //y坐标系值:获取到一行文字的baseline
private int baseline; 3 其他需要的变量:
//屏幕的像素密度(用它来确定线的厚度)
private float mStrokeWidth; //··划线的画笔
private Paint mNPaint; //··矩形的类 用以获取baseline(mRect.bottom获取到的是一行文字矩形的下边框而baseline是这行文字的基线)
private Rect mRect; 到这里基本的变量都有了。
4 记录行信息和划线信息的类
private List<TextIndex> indexs = new ArrayList<>();
private List<TextIndex> drawIndexs = new ArrayList<>();
class TextIndex {
int line;
int start;
int end;
boolean lineend;
public int getLine() {
return line;
}
public void setLine(int line) {
this.line = line;
}
public int getStart() {
return start;
}
public void setStart(int start) {
this.start = start;
}
public int getEnd() {
return end;
}
public void setEnd(int end) {
this.end = end;
}
public boolean isLineend() {
return lineend;
}
public void setLineend(boolean lineend) {
this.lineend = lineend;
}
public TextIndex(int line, int start, int end, boolean lineend) {
this.line = line;
this.start = start;
this.end = end;
this.lineend = lineend;
}
} 5 初始化:
private void init() {
//获取屏幕密度
density = getResources().getDisplayMetrics().density;
//线的基准高度:一个屏幕密度
mStrokeWidth = density;
mRect = new Rect();
//画笔
mNPaint = new Paint();
mNPaint.setStyle(Paint.Style.FILL_AND_STROKE);
//画笔设置颜色(根据自己的项目需求来设置)
mNPaint.setColor(mColor);
//抗锯齿
mNPaint.setAntiAlias(true);
//画笔粗细(这里用1.5倍屏幕密度)
mNPaint.setStrokeWidth(mStrokeWidth*1.5f);
}
6 获取用户选择文本的方法: private ArrayList<> sellectIndexArrayList;
private void setSellectIndexArrayList(){
//这里可以传入一个记录 所选文本起始位置和结束位置的类集合
} 7 onDraw里 开始划线:
@Override
protected void onDraw(Canvas canvas) {
super.onDraw(canvas);
//得到TextView显示有多少行
int count = getLineCount();
//得到TextView的布局
final Layout layout = getLayout();
// 初始化索引序列
indexs.clear();
//记录每一行的信息(文字的开始位置和结束位置)
for (int i = 0; i < count; i++) {
TextIndex index = new TextIndex(i, layout.getLineStart(i),
layout.getLineEnd(i), false);
indexs.add(index);
}
//seBeans是我自己定义的一个类集合(这个类专门存放用户选择的文本的开始和结束位置等信息)
//先拿出一个用户划线的对象
for (int j = 0; j < seBeans.size(); j++) {
boolean hasStart = false;
startIndex = seBeans.get(j).getStartIndex();
endIndex = seBeans.get(j).getEndIndex();
//拿出每一行与用户划线的索引进行匹配(匹配成功后放入另一个(drawIndexs:专门存放(已行为单位)需要划线的类集合))
for (int i = 0; i < indexs.size(); i++) {
// 先确定开始位置
if (startIndex >= indexs.get(i).start && startIndex <= indexs.get(i).end) {
// 在确定结束位置
if (endIndex >= indexs.get(i).start && endIndex <= indexs.get(i).end) {
drawIndexs.add(new TextIndex(i, startIndex, endIndex, false, seBeans.get(j).isIssellect(),seBeans.get(j).isIsorinote()));
break;
} else {
// 结束位置不再此行的话,先记下起始位置,结束位置为本行最后一位
drawIndexs.add(new TextIndex(i, startIndex, indexs.get(i).end, true, seBeans.get(j).isIssellect(),seBeans.get(j).isIsorinote()));
hasStart = true;
}
} else {
if (endIndex >= indexs.get(i).start && endIndex <= indexs.get(i).end) {
drawIndexs.add(new TextIndex(i, indexs.get(i).start, endIndex, false, seBeans.get(j).isIssellect(),seBeans.get(j).isIsorinote()));
hasStart = false;
break;
// 否则此行全画
} else {
if (hasStart) {
drawIndexs.add(new TextIndex(i, indexs.get(i).start, indexs.get(i).end, true, seBeans.get(j).isIssellect(),seBeans.get(j).isIsorinote()));
}
}
}
}
}
//根据drawIndexs提供的需要划线的行信息 开始划线
for (int i = 0; i < drawIndexs.size(); i++) {
if(drawIndexs.get(i).getStart()!=drawIndexs.get(i).getEnd()){
// getLineBounds得到这一行的外包矩形
// 这个字符的顶部Y坐标就是rect的top 底部Y坐标就是rect的bottom
// baseline是textview文字的基线
baseline = getLineBounds(drawIndexs.get(i).line, mRect);
//要得到这个字符的左边X坐标 用layout.getPrimaryHorizontal
x_start = layout.getPrimaryHorizontal(drawIndexs.get(i).start);
x_diff = layout.getPrimaryHorizontal(drawIndexs.get(i).start + 1) - x_start;
//如果是选择的是某一行最后一个字符时的解决方法:这个方式有点简单粗暴 -_-!
if(x_diff==-x_start){
x_diff = this.getPaint().measureText("字");
}
x_stop = layout.getPrimaryHorizontal(drawIndexs.get(i).end - 1) + x_diff;
canvas.drawLine(x_start, baseline + mStrokeWidth + 24, x_stop, baseline + mStrokeWidth + 24, mNPaint);
}
}
} ondraw里的逻辑大致是:
先拿出textview一共有多少行 每一行文字的开始位置索引和结束位置索引 然后将这些行的对象与用户选择的文本对象进行比对(开始位置和结束位置),比对完成后生成需要划线的drawIndexs对象,进而使用drawIndexs进行循环划线。这里有个小bug 当用户跨段落划线的时候会将\n进行划线 应该在代码里将\n过滤掉。
总结:
基本的划线逻辑就是以上这些。在用户选择文字的时候可以传入多个记录起始位置和结束位置的对象,并且可以传入自定义的各种flag然后再ondraw里根据这些不同的flag改变线的位置 画笔的设置等等画出不同的划线,而且可以在所划线的后面自定义一些标识比如上图的笔记标识等等,加标识可以借鉴开始写的 大牛 Android机动车的博文链接。
好了 文章写完了 希望对不太了解这块知识的朋友有所帮助。