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

開始前

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

image

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

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

實現(xiàn)思路

基本的實現(xiàn)思路就是將每個文字進行排版布局,計算出當前文字的位置吧享,繪制在 View 上:

image

很明顯魏割,我們重點要放在排版上,通過分析使用場景钢颂,需要注意以下幾點:

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

準備知識點

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

文字繪制位置

ClickMoreTextView 實現(xiàn)

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

準備工作

首先寫一個 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ù)要操作每一個字符操灿,所以聲明一個 Char 數(shù)組锯仪,設置文本時為其賦值:

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

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

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

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

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

排版

給文字排版首先需要拿到當前布局的寬度用于判斷文字需要折行的位置本缠,所以選擇在 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 會有多次調(diào)用斥扛,故設置一個防止重復排版的 flag:

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

另外需要注意的是當 View 確實需要重排時要將排版標識重置,所以重寫 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//指定頂點坐標
    val size = textCharArray.size
    var i = 0
    while (i < size) {
        val textPosition = TextPosition()
        val c = textCharArray.get(i)
        val cW = textPaint.measureText(c.toString())
        //位置保存點
        textPosition.x = curX
        textPosition.y = curY
        textPosition.text = c.toString()
        //curX 向右移動一個字
        curX += cW
        if (isParagraph(i) ||//段落內(nèi)
            isNeedNewLine(i, curX, availableWidth)
        ) { //折行
            textLineYs.add(curY)
            //斷行需要回溯
            curX = initX
            curY += lineHeight * lineSpacingMultiplier
        }
        textPositions.add(textPosition)
        i++//移動游標
        //記錄 MoreText位置
        recordMoreTextPosition(availableWidth, curX, curY, i)
    }
    //最后一行
    textLineYs.add(curY)
    curY += paddingBottom
    layoutHeight = curY + textFontMetrics.bottom//應加上后面的Bottom
    checkMoreTextShouldShow()//排版結(jié)束后丹锹,檢查MoreText 是否應該展示
    if (DEBUG) {
        Log.d(TAG, "總行數(shù): ${getLines()}")
    }
}

其中有幾個方法需要額外說一下:

isParagraph(i) 用于判斷當前是為段落的方法(其實就是檢查是否包含\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) 用于判斷是否需要新起一行,先拿下一個字符做越界檢查峻村,發(fā)現(xiàn)越界就折行麸折,否則繼續(xù)向右排:

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

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

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

        //點擊區(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]) {//判斷當前字符是否為 \n
        return true
    }
    return false
}

checkMoreTextShouldShow() 排版結(jié)束后要根據(jù)排版計算的行數(shù)和設置的最大行數(shù)來判斷是否應該展示 MoreText粘昨,同時根據(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é)束后會生成布局高度 layoutHeight垢啼,然后設置給 View。需要注意的是為了可以讓 ClickMoreTextView 支持在 ScrollView 這種滾動布局中使用需要通過 setMeasuredDimension 方法設置寬高张肾。

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)
}

最后一個 if 語句中代碼主要用于解決當用戶設置了最大高度時芭析,布局應該設置的高度。

繪制

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

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)
    }
}

點擊事件

重寫 onTouchEvent 方法監(jiān)聽用戶的觸摸事件芍秆,判斷是否在 moreTextClickArea 點擊區(qū)域內(nèi)(排版時已通過 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ào)")
                    }
                    moreTextClickListener?.onClick(this)
                    return false
                }
            }
            else -> {}
        }
    }
    return false
}

Demo 地址

https://github.com/changer0/ClickMoreTextView

最后編輯于
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末惯疙,一起剝皮案震驚了整個濱河市,隨后出現(xiàn)的幾起案子妖啥,更是在濱河造成了極大的恐慌霉颠,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,490評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件荆虱,死亡現(xiàn)場離奇詭異蒿偎,居然都是意外死亡,警方通過查閱死者的電腦和手機怀读,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,581評論 3 395
  • 文/潘曉璐 我一進店門诉位,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人菜枷,你說我怎么就攤上這事苍糠。” “怎么了犁跪?”我有些...
    開封第一講書人閱讀 165,830評論 0 356
  • 文/不壞的土叔 我叫張陵椿息,是天一觀的道長。 經(jīng)常有香客問我坷衍,道長寝优,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,957評論 1 295
  • 正文 為了忘掉前任枫耳,我火速辦了婚禮乏矾,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘。我一直安慰自己钻心,他們只是感情好,可當我...
    茶點故事閱讀 67,974評論 6 393
  • 文/花漫 我一把揭開白布捷沸。 她就那樣靜靜地躺著摊沉,像睡著了一般。 火紅的嫁衣襯著肌膚如雪痒给。 梳的紋絲不亂的頭發(fā)上说墨,一...
    開封第一講書人閱讀 51,754評論 1 307
  • 那天,我揣著相機與錄音苍柏,去河邊找鬼试吁。 笑死棺棵,一個胖子當著我的面吹牛,可吹牛的內(nèi)容都是我干的熄捍。 我是一名探鬼主播烛恤,決...
    沈念sama閱讀 40,464評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼柜裸,長吁一口氣:“原來是場噩夢啊……” “哼!你這毒婦竟也來了疙挺?” 一聲冷哼從身側(cè)響起扛邑,我...
    開封第一講書人閱讀 39,357評論 0 276
  • 序言:老撾萬榮一對情侶失蹤,失蹤者是張志新(化名)和其女友劉穎铐然,沒想到半個月后蔬崩,有當?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體,經(jīng)...
    沈念sama閱讀 45,847評論 1 317
  • 正文 獨居荒郊野嶺守林人離奇死亡搀暑,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點故事閱讀 37,995評論 3 338
  • 正文 我和宋清朗相戀三年沥阳,在試婚紗的時候發(fā)現(xiàn)自己被綠了。 大學時的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片自点。...
    茶點故事閱讀 40,137評論 1 351
  • 序言:一個原本活蹦亂跳的男人離奇死亡桐罕,死狀恐怖,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情功炮,我是刑警寧澤溅潜,帶...
    沈念sama閱讀 35,819評論 5 346
  • 正文 年R本政府宣布,位于F島的核電站薪伏,受9級特大地震影響滚澜,放射性物質(zhì)發(fā)生泄漏。R本人自食惡果不足惜嫁怀,卻給世界環(huán)境...
    茶點故事閱讀 41,482評論 3 331
  • 文/蒙蒙 一设捐、第九天 我趴在偏房一處隱蔽的房頂上張望。 院中可真熱鬧眶掌,春花似錦挡育、人聲如沸。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,023評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至召噩,卻和暖如春母赵,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背具滴。 一陣腳步聲響...
    開封第一講書人閱讀 33,149評論 1 272
  • 我被黑心中介騙來泰國打工凹嘲, 沒想到剛下飛機就差點兒被人妖公主榨干…… 1. 我叫王不留,地道東北人构韵。 一個月前我還...
    沈念sama閱讀 48,409評論 3 373
  • 正文 我出身青樓周蹭,卻偏偏與公主長得像,于是被迫代替她去往敵國和親疲恢。 傳聞我的和親對象是個殘疾皇子凶朗,可洞房花燭夜當晚...
    茶點故事閱讀 45,086評論 2 355