0%

Android自定义View-可高亮滚动文本

概述

实现效果如下图:

  • 能够显示指定的文本(如果是 String 列表呢),超出的内容可滚动(自动或手动)显示
  • 高亮固定的行
  • 能够调节字体大小,高亮字体颜色,滚动速度

自定义RecyclerView

最先想到的是这种方式,因为 RecyclerView 已经帮我们做了手动滚动逻辑,至于高亮的话可以根据数据源在 Adapter.onBindViewHolder 中设置样式:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 数据源对象
data class LineWord(var word: String = "", var highLight: Boolean = false)

// ViewHolder
class LinesHolder(itemView: View) : RecyclerView.ViewHolder(itemView) {
private val linesTV = itemView.findViewById<TextView>(R.id.line_tv)

fun bindData(lineWord: LineWord) {
linesTV.text = lineWord.word
if (lineWord.highLight) {
linesTV.setTextColor(Color.BLUE)
linesTV.typeface = Typeface.DEFAULT_BOLD
} else {
linesTV.setTextColor(Color.BLACK)
linesTV.typeface = Typeface.DEFAULT
}
}
}

如果是那种类似于歌词控件的功能,每行文字都已经切割好了,那么用这种自定义 RecyclerView 的方案貌似比较简单。

然而如果我们的数据源是一段文本,所以需要实现文本分割,而且由于可以动态调节字体大小,每次调节字体大小后都需要重新分割文本,相当于实现了 TextView 的功能。每次调节字体大小后都需要重新分割文本并重设 RecyclerView 的数据源,然后刷新 UI, 想想就头大。

自定义View

考虑直接自定义一个全新的 View 控件来实现这个功能。对于输入源是文本的场景,它也要面临跟上面一样切割文本的问题,切割文本需要考虑的问题太多了,中英文,单词,逗号等,都是问题。我对这种方式比较好奇,也大致写一下思路,如果是类似歌词控件的功能,那也是一种不错的实现方案。

因此不考虑文本分割,直接用一个 String 列表表示数据源。

这种方案的重点在于 onDraw 方法的处理,那我们可以通过定义一个偏移量 offset 来表示文本的偏移,自动滚动或手动滚动时修改这个 offset 值,然后触发 View 的重绘:

1
2
3
4
5
6
7
8
9
10
11
12
override fun onDraw(canvas: Canvas?) {
super.onDraw(canvas)
var y: Float = paddingTop.toFloat() + marginTop
val x: Float = paddingLeft.toFloat() + marginLeft
for (i in 0 until linesData.size) {
// 计算当前行的文本的 y 轴绘制偏移
y += getTextHeight(i) + lineSpace
// 当前是否为高亮行,从而设置不同的画笔样式
setPaintStyle(textPaint, isHighLightLine(i))
canvas?.drawText(linesData[i], x, y - offset, textPaint)
}
}

自动滚动可以通过定时修改 offset 来实现,手动滚动则需要重写 onTouchEvent 方法,在 move 事件里更新 offset 并处理边界问题,最后通过 invalidateView 方法重绘。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
// 自动滚动
private val scrollBySmoothRunnable = object : Runnable {
override fun run() {
offset += speedSmooth / FREQUENCY
// 如果滚到了最后
if (offset > getLineOffsetY(linesData.size - 1)) {
// 是否循环滚动
if (repeatable) {
offset = 0f
} else {
pauseBySmooth()
return
}
}
// 计算当前高亮起始行
currentLine = (offset / (getTextHeight(0) + lineSpace)).toInt()
invalidateView()
if (isUserScroll) {
// 如果在手动滚动则移除自动滚动任务
pauseBySmooth()
} else {
// 发起下次任务
ViewCompat.postOnAnimationDelayed(this@ScrollView, this, (1000 / FREQUENCY).toLong())
}
}
}

// 手动滚动
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (linesData.isEmpty()) {
return super.onTouchEvent(event)
}
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
pause()
lastMotionY = event.y
isUserScroll = true
}
MotionEvent.ACTION_MOVE -> {
offset -= (event.y - lastMotionY)
lastMotionY = event.y
currentLine = (offset / (getTextHeight(0) + lineSpace)).toInt()
invalidateView()
}
MotionEvent.ACTION_CANCEL, MotionEvent.ACTION_UP -> {
isUserScroll = false
start()
// 处理边界
if (offset < 0) {
currentLine = 0
scrollToLine(0)
}
if (offset > getLineOffsetY(linesData.size - 1)) {
currentLine = linesData.size - 1
scrollToLine(linesData.size - 1)
}
}
}
return true
}

自定义TextView

上面的方式都只适用于文本已经被分割了的情况,如歌词控件等。对于输入数据源是一段文本的场景,我们需要处理分割逻辑,极其麻烦,因此考虑直接自定义 TextView 来实现,借用 TextView 自身的功能来处理文本分割。

滚动内容

接下来需要看看滚动逻辑的实现,这里可以使用 View.scrollTo() 和 View.scrollBy() 方法来实现文本的滚动。这两个方法会移动 View 的内容,但不会改变其位置,更具体而言,就是这两个方法会修改 View 中的 mScrollX 和 mScrollY 值,并触发重绘,在绘制过程中, canvas 画布会通过 canvas.translate() 方法去平移画布,然后绘制内容。至于方向,当 mScroll(X/Y) 值为负数时,则内容会向右下平移;当 mScroll(X/Y) 为正数时,则内容会向左上平移,而 View 本身的位置不变。相关的内容可以自行查看 View 绘制的源码。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
// 自动滚动任务
override fun run() {
if (!isUserScroll && scroll()) {
// 发送下一次的 scroll 任务
postDelayed(this, refreshRate)
}
}

private fun scroll(): Boolean {
// 是否已经滚动到底部
if (offset >= maxBottomOffset()) {
if (isScrollRepeatable) {
// 如果开启了循环滚动,则使其滚动到顶部内容
if (!isUserScroll) {
postDelayed({
// 滚动到顶部后,重新 resume 开始自动滚动
scrollTo(minTopOffset())
resume()
}, 1000)
}
return false
}
isAutoScroll = false
return false
} else {
// 没有滚动到底部,则接着修改 offset 值,并滚动内容
offset += speed
scrollBy(0, speed)
// 设置高亮行
setupHighLightLines()
return true
}
}

/**
* 滚动到指定偏移 [offsetY] 位置,并高亮行
*/
private fun scrollTo(offsetY: Int) {
offset = offsetY
scrollTo(0, offset)
setupHighLightLines(true)
}

上面自动滚动其实就是定时 post 一个滚动任务,并判断边界以及处理高亮逻辑。至于手动滚动,则是重写 onTouchEvent 方法,并根据手指滑动距离来修改 offset 偏移,并调用 scroll 方法处理内容滚动,然后处理滚动内容的边界。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
override fun onTouchEvent(event: MotionEvent?): Boolean {
if (text.isNullOrEmpty()) {
return super.onTouchEvent(event)
}
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
isUserScroll = true
lastY = event.y
// 有手动滚动则暂停自动滚动的任务
pauseOnly()
}
MotionEvent.ACTION_MOVE -> {
val dy = event.y - lastY
lastY = event.y
// scroll 方法中会实时处理高亮行逻辑
if (!scroll(-dy.toInt())) {
// 超过边界了
scrollBy(0, -dy.toInt())
offset -= dy.toInt()
}
}
MotionEvent.ACTION_UP -> {
isUserScroll = false
if (correctOffset()) {
// 超过边界了,则滚回去
scrollTo(offset)
}
if (isAutoScroll) {
// 如果手动滚动前处于自动滚动状态,则手动滚动后恢复自动滚动
resume(400)
}
}
}
return true
}

高亮行

接下来是如何处理高亮行(这里我们指定高亮第二和第三行位置),分为两点:

  • 计算当前高亮行
  • 高亮指定行

计算当前高亮行

在之前的滚动逻辑里,我们定义了一个 offset 偏移量,并在滚动过程中实时更新它,它的作用便是用来计算高亮行位置的:

1
2
3
4
5
6
7
8
9
10
11
/**
* 根据 [offset] 偏移量计算当前应高亮的行
*/
private fun shouldHighLightLine(): Int {
return if (offset < 0) {
offset / lineHeight
} else {
// HIGH_LIGHT_LINE_START: 高亮起始行, 取 1
offset / lineHeight + HIGH_LIGHT_LINE_START
}
}

高亮指定行

在得到需要高亮的行后,我们可以通过 SpannableStringBuilder 来设置指定位置文本的样式,

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
/**
* 高亮逻辑,[force] 是否强制执行高亮逻辑,否则如当前高亮行未变化则跳过高亮逻辑
*/
private fun setupHighLightLines(force: Boolean = false) {
// 计算当前高亮起始行
val line = shouldHighLightLine()
val layout = layout
if (layout == null || (!force && line == curHighLightLine)) {
return
}
// 更新当前高亮起始行 curHighLightLine 和当前高亮的文本起始下标 curHighLightIndex
curHighLightLine = line
if (curHighLightLine < 0) {
curHighLightLine = 0
curHighLightIndex = 0
}
// getLineStart 是系统方法,用来得到指定行对应的文本起始下标
// 这里通过该方法可以得到应该高亮的文本的开始与结束下标
curHighLightIndex = layout.getLineStart(curHighLightLine)
val start = layout.getLineStart(curHighLightLine)
// HIGH_LIGHT_LINE_COUNT: 高亮行数, 默认取 2
val end = if (curHighLightLine + HIGH_LIGHT_LINE_COUNT > lineCount) {
layout.getLineStart(lineCount)
} else {
layout.getLineStart(curHighLightLine + HIGH_LIGHT_LINE_COUNT)
}
val colorText = SpannableStringBuilder(text.toString())
// 避免粗体导致自动换行
colorText.setSpan(
HighLightSpan(Typeface.DEFAULT_BOLD, SMALL_LETTER_SPACING, highLightColor),
start,
end,
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
text = colorText
}

上面通过计算后得到高亮行的文本开始与结束的下标后,通过设置自定义的 HighLightSpan 样式,即可达到高亮效果:

1
2
3
4
5
6
7
class HighLightSpan(private val typeface: Typeface, private val spacing: Float, color: Int) : ForegroundColorSpan(color) {
override fun updateDrawState(textPaint: TextPaint) {
super.updateDrawState(textPaint)
textPaint.typeface = typeface
textPaint.letterSpacing = spacing
}
}

注意这里有个问题,由于高亮行是粗体,因此当某一行高亮后,该行的内容宽度会变长,因此可能会触发 TextView 的自动换行操作,导致高亮效果变得奇怪,因此这里的解决方式是通过设置一个比正常状态下略小的字间距 letterSpacing, 来缩短高亮行的内容宽度。也因为这个原因,这种方案暂不支持高亮行和正常行使用不同的字体大小。

总结

  • 文本已经切割好(如歌词控件等):可以使用自定义 RecyclerView 和 View 的方式,对于自定义 View 的方式,如果 drawText 时文本超过了一行,则可以借助 StaticLayout 来绘制文本,它会自动处理绘制换行的问题;
  • 文本未切割:可以使用自定义 TextView 的方式实现。

上述三种方式各有优点,可以针对不同的需求场景使用不同的方式,代码已经传到了 github 上,有更好实现方式的同学欢迎提建议~由于时间原因,这次我的主要目的是输入一段文本显示,因此重点写的是自定义 TextView 的方式,至于前两种方式有些细节还没有处理,不过不影响整体逻辑。