TextView自定义轻松实现下划线、点击弹框

5,229 阅读6分钟

前言

最近公司有意需求,就是类似于电子书,选择一段文字然后做笔记,需要给做过的文字加下划线,下划线最后加一图标按钮,点击弹框显示笔记内容。

立马会想到使用TextView的fromHtml方法,给添加笔记的文本手动加标签,或者使用SpanString类的相关方法设置标签。

但是!

经过反复测试,无论使用何种下划线标签或者SpanString设置下划线,画出的下划线颜色始终和文本内容颜色一样,还不能随便定义颜色。更何况:我们需要在下划线最后加图标,并且能够点击。看来这种方法不可行...

于是,便开始了我的自定义之路~~~~

先看效果图:

这是纯文本的TextView

这里写图片描述

这是富文本的TextView

这里写图片描述

分析

这里写图片描述

要实现以上需求,应该从这几个方面入手:

文本展示,普通文本调用TextView的setText方法既可,如果是富文本,就使用TextView的fromHtml方法,至于图片如何展示,我在上一篇文章用TextView实现富文本展示,点击断句和语音播报介绍过了,有兴趣的可以跳转阅读,核心是拦截到图片url然后自己实现加载图片。

给TextView设置要划线的起始位置和结束位置,需要计算出在哪些行进行绘制,每行又是从哪里开始,到哪里结束,注意第一行和最后一行。

然后就是在onDraw方法中对计算出的行进行逐行绘制,在最后一行的结束位置绘制笔记图标(小圆圈)。

在TextView的onTouchEvent判断按下位置是否是笔记图标(小圆圈)的附近,是的话则弹框(PopupWindow)显示。

文本显示

这里就不再重复累赘了,文本展示很简单:

调用setText或fromHtml方法既可。

颜色等属性设置

private Rect mRect;
private Paint mPaint;
private int mColor = 0xFFFFA200;
private float density;
private float mStrokeWidth;

// 笔记白点
private Paint mPointPaint;

// 开始各结束位置索引,startIndex必须大于等于endIndex
private int startIndex = 0;
private int endIndex = 0;

// 下划线的位置(每次更新)
private float x_start, x_stop, x_diff;
private int baseline;
// 小圆圈的位置
private float notePointX, notePointY;

private int scrollY = 0;

我们需要定义画笔、画笔颜色、线条粗细;开始位置的结束位置的索引。

还有就是下划线的位置,因为我们是按行来画,每画完一行就会重新计算,尤其是横向的结束位置,所以我将x的结束位置定义出来,每次都更新。

最后要将计算出的小图标的x和y值保留,在onTouchEvent中会用到。

并初始化:

//获取屏幕密度
density = getResources().getDisplayMetrics().density;

mStrokeWidth = density;

mRect = new Rect();
mPaint = new Paint();
mPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mPaint.setColor(mColor);
mPaint.setAntiAlias(true);
mPaint.setStrokeWidth(mStrokeWidth);

mPointPaint = new Paint();
mPointPaint.setStyle(Paint.Style.FILL_AND_STROKE);
mPointPaint.setColor(Color.WHITE);
mPointPaint.setAntiAlias(true);
mPointPaint.setStrokeWidth(2.2f);

计算划线位置

class TextIndex {
	int line;
	int start;
	int end;

	public TextIndex(int line, int start, int end) {
		this.line = line;
		this.start = start;
		this.end = end;
	}
}

我们先定义一个实体类,这个类中存放每行的索引,和对应每行上的一个开始位置索引,结束位置索引。

// 存放所有行,和对应的每行开始位置和结束位置
private List<TextIndex> indexs = new ArrayList<>();
// 存放需要绘制的行,和每行对应的开始和结束的位置
private List<TextIndex> drawIndexs = new ArrayList<>();

定义两个集合,分别存放所有行的信息和需要绘制的行的信息。

接下来开始计算:

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));
			break;
		} else {
			// 结束位置不再此行的话,先记下起始位置,结束位置为本行最后一位
			drawIndexs.add(new TextIndex(i, startIndex, indexs.get(i).end));
			hasStart = true;
			continue;
		}
	} else {
		if (endIndex >= indexs.get(i).start && endIndex <= indexs.get(i).end) {

			drawIndexs.add(new TextIndex(i, indexs.get(i).start, endIndex));
			hasStart = false;
			break;
		// 否则此行全画
		} else {
			if (hasStart) {
				drawIndexs.add(new TextIndex(i, indexs.get(i).start, indexs.get(i).end));
			}
		}
	}
}

思路是这样的:

  1. 循环所有行;
  2. 如果要绘制的开始位置在这行中,并且结束位置也在这行中,直接向要绘制的集合中添加一个对象,终止循环;
  3. 如果开始位置在这行中,但结束位置不在这行中,则添加一个结束位置是本行结束位置的对象到要绘制的集中中,继续下次循环;
  4. 如果结束位置在此行,则添加开始位置为本行开始位置,结束位置为自己结束位置的对象到集合中;
  5. 否则,将整行填入集合。

绘制下划线

for (int i = 0; i < drawIndexs.size(); i++) {

	// getLineBounds得到这一行的外包矩形,
	// 这个字符的顶部Y坐标就是rect的top 底部Y坐标就是rect的bottom
	baseline = getLineBounds(drawIndexs.get(i).line, mRect);

	//要得到这个字符的左边X坐标 用layout.getPrimaryHorizontal
	//得到字符的右边X坐标用layout.getSecondaryHorizontal
	x_start = layout.getPrimaryHorizontal(drawIndexs.get(i).start);
	x_diff = layout.getPrimaryHorizontal(drawIndexs.get(i).start + 1) - x_start;
	x_stop = layout.getPrimaryHorizontal(drawIndexs.get(i).end - 1) + x_diff;
	canvas.drawLine(x_start, baseline + mStrokeWidth + 8, x_stop, baseline + mStrokeWidth + 8, mPaint);
}

核心使用的是canvas的drwaLine方法进行绘制。

循环所有要绘制的集合,得到这一行的外包矩形,根据当前行的开始和结束位置,算出横向x的开始和结束位置;baseline是字符底部y的值,这样就可以绘制划线了!

绘制笔记图标

/**
 * 在最后位置绘制椭圆和三个白点
 * 注意这里的所有值都不能给死,否则无法适配
 */
if (i == drawIndexs.size() - 1) {
	canvas.drawCircle(x_stop + mStrokeWidth * 4, baseline + mStrokeWidth + 8, mStrokeWidth * 4, mPaint);
	notePointX = x_stop + mStrokeWidth * 4;
	notePointY = baseline + mStrokeWidth + 8;
	Log.e(TAG, "onDraw: x=" + (x_stop + mStrokeWidth * 4) + "y=" + (baseline + mStrokeWidth + 8));
	float[] pts = {x_stop + mStrokeWidth * 2, baseline + mStrokeWidth + 8, x_stop + mStrokeWidth * 4, baseline + mStrokeWidth + 8, x_stop + mStrokeWidth * 6, baseline + mStrokeWidth + 8};
	canvas.drawPoints(pts, mPointPaint);
}

如果是最后一行的,在本行的结束位置开始绘制笔记图标。

使用canvas.drawCircle绘制圆圈,而圆的圆形坐标可以下划线最后的位置进行绘制。

再用另一条画笔绘制三个白点,这个白点可以使用canvas.drawPoints绘制,传入一个float类型数组,下标是奇数,表示点的x值,下表为偶数,表示点的y值,也就是说float数组的个数必须是偶数个,或者说是点数的两倍。

图标点击

    @Override
    public boolean onTouchEvent(MotionEvent event) {
        int action = event.getAction();
        switch (action) {
            case MotionEvent.ACTION_DOWN:
                Log.e(TAG, "onTouchEvent: " + event.getX() + "   " + event.getY());
                Log.e(TAG, "onTouchEvent: " + event.getX() + "   " + getScrollY());

                if (Math.abs(event.getX() - notePointX) <= 30 && Math.abs(event.getY() - notePointY) <= 30) {
                    JsPopupWindow popWindow = new JsPopupWindow.Builder()
                            .setContentViewId(R.layout.dialog_popupwindow) // 设置布局
                            .setContext(getContext()) // 设置上下文
                            .setOutSideCancle(true) // 点击外部消失
                            .setHeight(LinearLayout.LayoutParams.WRAP_CONTENT) // 设置高度
                            .setWidth(LinearLayout.LayoutParams.WRAP_CONTENT) // 设置宽度
                            .setAnimation(R.style.anim_pop) // 设置动画
                            .build() // 构建
                            .showAtLocation(this, Gravity.TOP | Gravity.LEFT, (int) notePointX, (int) notePointY - scrollY);

                    TextView tv_pop = (TextView) popWindow.getItemView(R.id.tv_pop);
                    tv_pop.setText("我爱北京天安门,天安门上太阳升");
                }

                break;
            case MotionEvent.ACTION_MOVE:
                break;
            case MotionEvent.ACTION_UP:
                break;
        }
        return true;
    }

在上一步绘制小图标时,就将图标的x和y值保存,在onTouchEvent中,判断按下的位置是否在小图标位置的“附近”,是的话就弹框显示笔记内容。

这里的弹框用的是我之前封装的JsPopupWindow,有兴趣的话可以点击阅读github.com/shuaijia/Js…

这里需要注意,如果TextView外层被ScrollView包裹,在弹框是就需要纵轴方向上减去ScrollView的偏移量。也就是TextView需要知道ScrollView的纵向偏移量,这里我设置了方法,将ScrollView的偏移量传入。

scroll_rich.setOnScrollChangeListener(new View.OnScrollChangeListener() {
	@Override
	public void onScrollChange(View view, int i, int i1, int i2, int i3) {
		tv_rich_note.setMScrollY(i1);
	}
});

这样就实现了我们如上图展示的,给TextView绘制下划线和图标点击,弹框的效果。

想获取更多精彩,请关注我的微信公众号——Android机动车