開始前
大家做一些文本簡介展示需求時可能會遇到文本過長的場景努释,這時視覺同學可能會要求設置最大行數(shù)并在末尾展示"查看更多"(后面簡稱 MoreText)俄周。廢話不多說搂橙,先看下要求實現(xiàn)的效果(圖為實現(xiàn)后的Demo效果):
通過看效果很明顯簡單的使用 TextView 或者布局堆疊是沒法實現(xiàn)這樣的效果了京革,索性就自定義一個 View端幼。
功能實現(xiàn)本身非常簡單,本文也只是簡單記錄下實現(xiàn)過程順便復習一下文本相關(guān)的自定義 View簇宽。 文章代碼過多可結(jié)合 Demo 查看
實現(xiàn)思路
基本的實現(xiàn)思路就是將每個文字進行排版布局,計算出當前文字的位置吧享,繪制在 View 上:
很明顯魏割,我們重點要放在排版上,通過分析使用場景钢颂,需要注意以下幾點:
- 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
}