如何優(yōu)雅的實(shí)現(xiàn)“查看更多”

開始前

大家做一些文本簡介展示需求時(shí)可能會(huì)遇到文本過長的場景,這時(shí)視覺同學(xué)可能會(huì)要求設(shè)置最大行數(shù)并在末尾展示"查看更多"(后面簡稱 MoreText)。廢話不多說,先看下要求實(shí)現(xiàn)的效果(圖為實(shí)現(xiàn)后的Demo效果):

image

通過看效果很明顯簡單的使用 TextView 或者布局堆疊是沒法實(shí)現(xiàn)這樣的效果了,索性就自定義一個(gè) View。

功能實(shí)現(xiàn)本身非常簡單,本文也只是簡單記錄下實(shí)現(xiàn)過程順便復(fù)習(xí)一下文本相關(guān)的自定義 View。 文章代碼過多可結(jié)合 Demo 查看

實(shí)現(xiàn)思路

基本的實(shí)現(xiàn)思路就是將每個(gè)文字進(jìn)行排版布局,計(jì)算出當(dāng)前文字的位置,繪制在 View 上:

image

很明顯,我們重點(diǎn)要放在排版上,通過分析使用場景,需要注意以下幾點(diǎn):

  • MoreText 文字樣式與普通文字不同需要使用單獨(dú)的 TextPaint
  • "..." 需要跟隨最大行文本末尾展示且與普通文字樣式相同
  • 需要考慮最大行位置中存在 \n 的場景

準(zhǔn)備知識(shí)點(diǎn)

給一張文字繪制位置的示例圖,其他請參考之前的文章 支持段落的 TextView

文字繪制位置

ClickMoreTextView 實(shí)現(xiàn)

結(jié)合上面的內(nèi)容,我們就可以實(shí)現(xiàn)一個(gè)支持 MoreText 的 TextView 了。

準(zhǔn)備工作

首先寫一個(gè) ClickMoreTextView 繼承自 View ,重寫其必要方法:

class ClickMoreTextView : View {
    override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
        super.onMeasure(widthMeasureSpec, heightMeasureSpec)
        //...
    }
    
    override fun draw(canvas: Canvas?) {
        super.draw(canvas)
        //...
    }
}

由于后續(xù)要操作每一個(gè)字符,所以聲明一個(gè) Char 數(shù)組,設(shè)置文本時(shí)為其賦值:

private var textCharArray = charArrayOf()
/**
 * 文本內(nèi)容
 */
var text = ""
    set(value) {
        field = value
        textCharArray = value.toCharArray()
    }

為普通文字和 MoreText 聲明不同的 TextPaint,并在構(gòu)造方法中做相應(yīng)初始化操作,例如:文字顏色、大小、是否加粗等等。特別的,我們將其聲明為 public 是為了方便用戶可以直接修改相應(yīng)文字屬性:

public var textPaint: TextPaint = TextPaint()
public var moreTextPaint: TextPaint = TextPaint()

另外為方便繪制我們聲明一個(gè)用來描述文字位置的內(nèi)部類 TextPosition,并創(chuàng)建一個(gè)該類型的集合 textPositions:

/**
 * 文字位置
 */
private val textPositions = ArrayList<TextPosition>()
/**
 * 當(dāng)前文字位置
 */
class TextPosition {
    var text = ""
    var x = 0f
    var y = 0f
}

排版

給文字排版首先需要拿到當(dāng)前布局的寬度用于判斷文字需要折行的位置,所以選擇在 onMeasure 中處理:

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val width = MeasureSpec.getSize(widthMeasureSpec)
    var height = MeasureSpec.getSize(heightMeasureSpec)
    breadText(width)
    //...
}

但是考慮到 onMeasure 會(huì)有多次調(diào)用,故設(shè)置一個(gè)防止重復(fù)排版的 flag:

private var isBreakFlag = false//排版標(biāo)識(shí)
private fun breadText(w: Int) {
    if (isBreakFlag) {
        return
    }
    isBreakFlag = true
    //...
}

另外需要注意的是當(dāng) View 確實(shí)需要重排時(shí)要將排版標(biāo)識(shí)重置,所以重寫 requestLayout() 方法來重置:

override fun requestLayout() {
    super.requestLayout()
    isBreakFlag = false
}

完整排版代碼:

private fun breadText(w: Int) {
    if (w <= 0) {
        return
    }
    if (isBreakFlag) {
        return
    }
    if (DEBUG) {
        Log.d(TAG, "breadText: 開始排版")
    }
    moreTextW = moreTextPaint.measureText(moreText)
    isBreakFlag = true
    val availableWidth = w - paddingRight
    textLineYs.clear()
    textPositions.clear()
    //x 的初始化位置
    val initX = paddingLeft.toFloat()
    var curX = initX
    var curY = paddingTop.toFloat()
    val textFontMetrics = textPaint.fontMetrics
    textPaintTop = textFontMetrics.top
    val lineHeight = textFontMetrics.bottom - textFontMetrics.top
    curY -= textFontMetrics.top//指定頂點(diǎn)坐標(biāo)
    val size = textCharArray.size
    var i = 0
    while (i < size) {
        val textPosition = TextPosition()
        val c = textCharArray.get(i)
        val cW = textPaint.measureText(c.toString())
        //位置保存點(diǎn)
        textPosition.x = curX
        textPosition.y = curY
        textPosition.text = c.toString()
        //curX 向右移動(dòng)一個(gè)字
        curX += cW
        if (isParagraph(i) ||//段落內(nèi)
            isNeedNewLine(i, curX, availableWidth)
        ) { //折行
            textLineYs.add(curY)
            //斷行需要回溯
            curX = initX
            curY += lineHeight * lineSpacingMultiplier
        }
        textPositions.add(textPosition)
        i++//移動(dòng)游標(biāo)
        //記錄 MoreText位置
        recordMoreTextPosition(availableWidth, curX, curY, i)
    }
    //最后一行
    textLineYs.add(curY)
    curY += paddingBottom
    layoutHeight = curY + textFontMetrics.bottom//應(yīng)加上后面的Bottom
    checkMoreTextShouldShow()//排版結(jié)束后,檢查MoreText 是否應(yīng)該展示
    if (DEBUG) {
        Log.d(TAG, "總行數(shù): ${getLines()}")
    }
}

其中有幾個(gè)方法需要額外說一下:

isParagraph(i) 用于判斷當(dāng)前是為段落的方法(其實(shí)就是檢查是否包含\n),如果是段落則直接折行,反之繼續(xù)向右排:

private fun isParagraph(curIndex: Int): Boolean {
    if (textCharArray.size <= curIndex) {
        return false
    }
    if (textCharArray[curIndex] == '\n') {
        return true
    }
    return false
}

isNeedNewLine(i, curX, availableWidth) 用于判斷是否需要新起一行,先拿下一個(gè)字符做越界檢查,發(fā)現(xiàn)越界就折行,否則繼續(xù)向右排:

private fun isNeedNewLine(
    curIndex: Int,
    curX: Float,
    maxWith: Int
): Boolean {
    if (textCharArray.size <= curIndex + 1) {//需要判斷下一個(gè) char
        return false
    }
    //判斷下一個(gè) char 是否到達(dá)邊界
    if (curX + textPaint.measureText(textCharArray[curIndex + 1].toString()) > maxWith) {
        return true
    }
    if (curX > maxWith) {
        return true
    }
    return false
}

recordMoreTextPosition(availableWidth, curX, curY, i) 用于記錄 MoreText 的位置信息,其中包括它的點(diǎn)擊區(qū)域:

private fun recordMoreTextPosition(availableWidth: Int, curX: Float, curY: Float, index: Int) {
    if (isShowMore.not() || maxLines == Int.MAX_VALUE) {
        return
    }
    //只記錄符合要求的第一個(gè)位置的
    if (dotIndex > 0 || index >= textCharArray.size) {
        return
    }
    val lines = getLines()
    if (lines != maxLines - 1) {
        return
    }
    val dotLen = textPaint.measureText("...")
    //目前在最后一行
    if (checkMoreTextForEnoughLine(curX, dotLen, availableWidth)//這一行滿足一行時(shí)
        || checkMoreTextForParagraph(index)//當(dāng)前是換行符
    ) {
        dotPosition.x = curX
        dotPosition.y = curY
        dotIndex = textPositions.size

        //點(diǎn)擊區(qū)域
        val moreTextFontMetrics = moreTextPaint.fontMetrics
        moreTextClickArea.top = curY + moreTextFontMetrics.top
        moreTextClickArea.right = availableWidth.toFloat()
        moreTextClickArea.bottom = curY + moreTextFontMetrics.bottom
        moreTextClickArea.left = curX
    }
}
private fun checkMoreTextForEnoughLine(
    curX: Float,
    dotLen: Float,
    availableWidth: Int
) = curX + moreTextW + dotLen + textPaint.measureText("中") > availableWidth

private fun checkMoreTextForParagraph(index: Int): Boolean {
    if ('\n' == textCharArray[index]) {//判斷當(dāng)前字符是否為 \n
        return true
    }
    return false
}

checkMoreTextShouldShow() 排版結(jié)束后要根據(jù)排版計(jì)算的行數(shù)和設(shè)置的最大行數(shù)來判斷是否應(yīng)該展示 MoreText,同時(shí)根據(jù) recordMoreTextPosition() 方法記錄的 MoreText 位置給 textPositions 賦值 "...":

private fun checkMoreTextShouldShow() {
    if (isShowMore.not()) {
        return
    }
    if (getLines() <= maxLines || maxLines == Int.MAX_VALUE) {
        isShouldShowMore = false
        return
    }
    if (dotIndex < 0) {
        return
    }
    isShouldShowMore = true
    textPositions.add(dotIndex, dotPosition)
    val temp = arrayListOf<TextPosition>()
    for (textPosition in textPositions.withIndex()) {
        if (textPosition.index == dotIndex) {
            temp.add(dotPosition)
            break
        }
        temp.add(textPosition.value)
    }
    textPositions.clear()
    textPositions.addAll(temp)
}

測量

排版結(jié)束后會(huì)生成布局高度 layoutHeight,然后設(shè)置給 View。需要注意的是為了可以讓 ClickMoreTextView 支持在 ScrollView 這種滾動(dòng)布局中使用需要通過 setMeasuredDimension 方法設(shè)置寬高。

override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
    super.onMeasure(widthMeasureSpec, heightMeasureSpec)
    val width = MeasureSpec.getSize(widthMeasureSpec)
    var height = MeasureSpec.getSize(heightMeasureSpec)
    breadText(width)
    if (layoutHeight > 0) {
        height = layoutHeight.toInt()
    }
    if (DEBUG) {
        Log.d(
            TAG, "onMeasure: getLines():${getLines()} maxLines: $maxLines width:$width height:$height"
        )
    }
    if (getLines() > maxLines && maxLines - 1 > 0) {
        val textBottomH = textPaint.fontMetrics.bottom.toInt()
        height = (textLineYs[maxLines - 1]).toInt() + paddingBottom + textBottomH
    }
    setMeasuredDimension(width, height)
}

最后一個(gè) if 語句中代碼主要用于解決當(dāng)用戶設(shè)置了最大高度時(shí),布局應(yīng)該設(shè)置的高度。

繪制

繪制要相對簡單些,根據(jù)之前生成的 textPositions,取出對應(yīng) textPosition 繪制到 canvas 上。其他注意事項(xiàng)參考注釋:

override fun onDraw(canvas: Canvas) {
    super.onDraw(canvas)
    if (DEBUG) {
        Log.d(TAG, "onDraw: ")
    }
    val posSize = textPositions.size
    for (i in 0 until posSize) {
        val textPosition = textPositions[i]
        //如果發(fā)現(xiàn)已經(jīng)超過布局高度了就不再繪制了
        if (textPosition.y + textPaintTop > height - paddingBottom) {
            break
        }
        canvas.drawText(textPosition.text, textPosition.x, textPosition.y, textPaint)
    }
    //繪制 MoreText
    if (isShouldShowMore) {
        val moreTextY = dotPosition.y
        val moreTextX = width - moreTextW - paddingRight
        canvas.drawText(moreText, moreTextX, moreTextY, moreTextPaint)
    }
}

點(diǎn)擊事件

重寫 onTouchEvent 方法監(jiān)聽用戶的觸摸事件,判斷是否在 moreTextClickArea 點(diǎn)擊區(qū)域內(nèi)(排版時(shí)已通過 recordMoreTextPosition() 方法記錄):

private val moreTextClickArea = RectF()

private var lastDownX = -1f
private var lastDownY = -1f

override fun onTouchEvent(event: MotionEvent?): Boolean {
    if (isShouldShowMore.not()) {
        return false
    }
    event?.let {
        val x = event.x
        val y = event.y
        if (DEBUG) {
            Log.d(TAG, "onTouchEvent: x: $x y:$y event: ${event.action}")
        }
        when (it.action) {
            MotionEvent.ACTION_DOWN -> {
                lastDownX = x
                lastDownY = y
                if (moreTextClickArea.contains(lastDownX, lastDownY)) {
                    return true
                }
            }
            MotionEvent.ACTION_UP -> {
                if (moreTextClickArea.contains(x, y)) {
                    if (DEBUG) {
                        Log.d(TAG, "onTouchEvent: 點(diǎn)擊更多回調(diào)")
                    }
                    moreTextClickListener?.onClick(this)
                    return false
                }
            }
            else -> {}
        }
    }
    return false
}

Demo 地址

https://github.com/changer0/ClickMoreTextView

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡書系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

友情鏈接更多精彩內(nèi)容