有意思的自定义View | 高亮滚动文本

2,028 阅读7分钟

概述

最近想做一个如下控件:

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

实现效果如下图:

自定义RecyclerView

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

// 数据源对象
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 的重绘:

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 方法重绘。

// 自动滚动
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 绘制的源码。

// 自动滚动任务
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 方法处理内容滚动,然后处理滚动内容的边界。

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 偏移量,并在滚动过程中实时更新它,它的作用便是用来计算高亮行位置的:

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

高亮指定行

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

/**
 * 高亮逻辑,[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 样式,即可达到高亮效果:

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 的方式,至于前两种方式有些细节还没有处理,不过不影响整体逻辑。

文中内容如有错误欢迎指出,共同进步!觉得不错的彦祖杰伦亦菲热巴等留个再走哈~

博文链接