如何优雅的实现“查看更多”

开始前

大家做一些文本简介展示需求时可能会遇到文本过长的场景,这时视觉同学可能会要求设置最大行数并在末尾展示"查看更多"(后面简称 MoreText)。废话不多说,先看下要求实现的效果(图为实现后的Demo效果):

image

通过看效果很明显简单的使用 TextView 或者布局堆叠是没法实现这样的效果了,索性就自定义一个 View。

功能实现本身非常简单,本文也只是简单记录下实现过程顺便复习一下文本相关的自定义 View。 文章代码过多可结合 Demo 查看

实现思路

基本的实现思路就是将每个文字进行排版布局,计算出当前文字的位置,绘制在 View 上:

image

很明显,我们重点要放在排版上,通过分析使用场景,需要注意以下几点:

  • MoreText 文字样式与普通文字不同需要使用单独的 TextPaint
  • "..." 需要跟随最大行文本末尾展示且与普通文字样式相同
  • 需要考虑最大行位置中存在 \n 的场景

准备知识点

给一张文字绘制位置的示例图,其他请参考之前的文章 支持段落的 TextView

文字绘制位置

ClickMoreTextView 实现

结合上面的内容,我们就可以实现一个支持 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)
        //...
    }
}

由于后续要操作每一个字符,所以声明一个 Char 数组,设置文本时为其赋值:

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

为普通文字和 MoreText 声明不同的 TextPaint,并在构造方法中做相应初始化操作,例如:文字颜色、大小、是否加粗等等。特别的,我们将其声明为 public 是为了方便用户可以直接修改相应文字属性:

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

另外为方便绘制我们声明一个用来描述文字位置的内部类 TextPosition,并创建一个该类型的集合 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 会有多次调用,故设置一个防止重复排版的 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) ||//段落内
            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()//排版结束后,检查MoreText 是否应该展示
    if (DEBUG) {
        Log.d(TAG, "总行数: ${getLines()}")
    }
}

其中有几个方法需要额外说一下:

isParagraph(i) 用于判断当前是为段落的方法(其实就是检查是否包含\n),如果是段落则直接折行,反之继续向右排:

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

isNeedNewLine(i, curX, availableWidth) 用于判断是否需要新起一行,先拿下一个字符做越界检查,发现越界就折行,否则继续向右排:

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 的位置信息,其中包括它的点击区域:

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

        //点击区域
        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() 排版结束后要根据排版计算的行数和设置的最大行数来判断是否应该展示 MoreText,同时根据 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)
}

测量

排版结束后会生成布局高度 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 语句中代码主要用于解决当用户设置了最大高度时,布局应该设置的高度。

绘制

绘制要相对简单些,根据之前生成的 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]
        //如果发现已经超过布局高度了就不再绘制了
        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 方法监听用户的触摸事件,判断是否在 moreTextClickArea 点击区域内(排版时已通过 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: 点击更多回调")
                    }
                    moreTextClickListener?.onClick(this)
                    return false
                }
            }
            else -> {}
        }
    }
    return false
}

Demo 地址

https://github.com/changer0/ClickMoreTextView

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 204,921评论 6 478
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 87,635评论 2 381
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 151,393评论 0 338
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,836评论 1 277
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,833评论 5 368
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,685评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,043评论 3 399
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,694评论 0 258
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 42,671评论 1 300
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,670评论 2 321
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,779评论 1 332
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,424评论 4 321
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,027评论 3 307
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,984评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,214评论 1 260
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 45,108评论 2 351
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,517评论 2 343