概述
实现效果如下图:
- 能够显示指定的文本(如果是 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)
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 += 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()) { postDelayed(this, refreshRate) } }
private fun scroll(): Boolean { if (offset >= maxBottomOffset()) { if (isScrollRepeatable) { if (!isUserScroll) { postDelayed({ scrollTo(minTopOffset()) resume() }, 1000) } return false } isAutoScroll = false return false } else { offset += speed scrollBy(0, speed) setupHighLightLines() return true } }
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 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
|
private fun shouldHighLightLine(): Int { return if (offset < 0) { offset / lineHeight } else { 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
|
private fun setupHighLightLines(force: Boolean = false) { val line = shouldHighLightLine() val layout = layout if (layout == null || (!force && line == curHighLightLine)) { return } curHighLightLine = line if (curHighLightLine < 0) { curHighLightLine = 0 curHighLightIndex = 0 } curHighLightIndex = layout.getLineStart(curHighLightLine) val start = layout.getLineStart(curHighLightLine) 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 的方式,至于前两种方式有些细节还没有处理,不过不影响整体逻辑。