如何优雅的实现“查看更多”(代码片段)

张鹿鹿 张鹿鹿     2022-12-09     704

关键词:

开始前

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

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

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

实现思路

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

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

  • 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)
    breakText(width)
    //...

但是考虑到 onMeasure 会有多次调用,故设置一个防止重复排版的 flag:

private var isBreakFlag = false//排版标识
private fun breakText(w: Int) 
    if (isBreakFlag) 
        return
    
    isBreakFlag = true
    //...

另外需要注意的是当 View 确实需要重排时要将排版标识重置,所以重写 requestLayout() 方法来重置:

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

完整排版代码:

private fun breakText(w: Int) 
    if (w <= 0) 
        return
    
    if (isBreakFlag) 
        return
    
    if (DEBUG) 
        Log.d(TAG, "breakText: 开始排版")
    
    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)
    breakText(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

以上就是本节内容,欢迎大家关注👇👇👇

一文详解|如何写出优雅的代码(代码片段)

谈到好代码,我的第一想法就是优雅,那我们如何该写出好的代码,让阅读的人感受到优雅呢?首先简单探讨一下优雅代码的定义。关于好代码的定义,各路大神都给出了自己的定义和见解整洁的代码如同优美的散文。——GradyB... 查看详情

如何优雅的实现数据脱敏(代码片段)

如何优雅的实现数据脱敏Jackson序列化中脱敏自定义脱敏序列化改造脱敏注解使用参考很多时候我们从ORM查询到的数据有其它逻辑要处理,比如根据电话号查询用户信息,你脱敏了就没有办法来处理该逻辑了。所以脱敏这... 查看详情

如何优雅地运用位运算实现产品需求?(代码片段)

如何优雅地运用位运算实现产品需求?在开始正文之前,我们先来说一下Linux的系统权限设计。在Linux系统中,为了保证文件的安全,对文件所有者、同组用户、其他用户的访问权限进行了分别管理。其中,文件所有者,即建立... 查看详情

开源:如何优雅的实现一个操作日志组件(代码片段)

和操作日志系统日志:主要用于开发者调试排查系统问题的,不要求固定格式和可读性操作日志:主要面向用户的,要求简单易懂,反映出用户所做的动作。通过操作日志可追溯到某人在某时干了某事情,如:租户操作人时间操... 查看详情

如何优雅地实现环形缓冲区?(代码片段)

循环缓冲区是嵌入式软件工程师在日常开发过程中的关键组件。多年来,互联网上出现了许多不同的循环缓冲区实现和示例。我非常喜欢这个模块,可以GitHub上找到这个开源的CBUF.h模块。地址:https://github.com/barraq/BRBr... 查看详情

springboot优雅的实现重处理功能(代码片段)

点击上方关注“终端研发部”设为“星标”,和你一起掌握更多数据库知识在实际工作中,重处理是一个非常常见的场景,比如:发送消息失败。调用远程服务失败。争抢锁失败。这些错误可能是因为网络波动造成的&#... 查看详情

优雅的实现对外接口,要注意哪些问题?(代码片段)

博主之前做过XX银行代收付系统(相当于支付接口),包括现在的oltpapi交易接口和虚拟业务的对外提供数据接口。总之,当你做了很多项目写了很多代码的时候,就需要回过头来,多总结总结,这样你... 查看详情

优雅的实现对外接口,要注意哪些问题?(代码片段)

点击关注公众号,Java干货及时送达博主之前做过XX银行代收付系统(相当于支付接口),包括现在的oltpapi交易接口和虚拟业务的对外提供数据接口。总之,当你做了很多项目写了很多代码的时候,就需要... 查看详情

如何优雅地使用命令行设置windows文件关联(代码片段)

如何优雅地使用命令行设置windows文件关联使用ftype查看帮助设置关联所需命令有ftypeassoc,需要管理员权限。如果忘记使用方法可通过ftype的帮助获取查看方法C:WINDOWSsystem32>ftype/?显示或修改用在文件扩展名关联中的文件类型FTYPE... 查看详情

秒杀系统实战|如何优雅的实现订单异步处理(代码片段)

前言我回来啦,前段时间忙得不可开交。这段时间终于能喘口气了,继续把之前挖的坑填起来。写完上一篇秒杀系统(四):数据库与缓存双写一致性深入分析后,感觉文章深度一下子被我抬高了一些,现在构思新文章的时候,... 查看详情

如何实现asp.netcore安全优雅退出?(代码片段)

咨询区AppDeveloper我想问一个老生常谈的问题,如何可以保证程序优雅的退出,这里用优雅的目的是因为我想在退出之前做一些小动作。用户场景:希望在程序退出之前可以从Consul上解注册,下面是我的模板代码。public ... 查看详情

如何优雅地关闭资源(代码片段)

很多时候我们都会用到io资源,比如文件、网络、各种连接等。比如有时候我们需要从一个文本文件中读取数据,一般的步骤是:用FileReader打开文件包装成BufferReader循环地从BufferReader中读取内容,直接读出来的内容为空关闭Buffer... 查看详情

如何使用prometheus和grafana优雅的实现服务器可视化(代码片段)

1Prometheus简介Prometheus是一个开源监控工具,实现了高维数据模型。Prometheus有多种数据可视化模式,其中一种是集成Grafana。Prometheus以高效的自定义格式将时间序列数据存储在内存和本地磁盘上。Prometheus有许多客户端可用于轻松... 查看详情

如何让日志打印更加优雅和实现数据链路追踪?(代码片段)

我是3y,一年CRUD经验用十年的markdown程序员👨🏻‍💻常年被誉为优质八股文选手今天来聊些大家都用得上的东西:数据链路追踪。之前引入了系统的监控来快速定位应用操作系统上的问题,而业务问题呢&... 查看详情

如何让日志打印更加优雅和实现数据链路追踪?(代码片段)

我是3y,一年CRUD经验用十年的markdown程序员👨🏻‍💻常年被誉为优质八股文选手今天来聊些大家都用得上的东西:数据链路追踪。之前引入了系统的监控来快速定位应用操作系统上的问题,而业务问题呢&... 查看详情

如何写出优雅的代码?

...来的维护人员,更多时候是一段时间后的作者本人。如何能够写出优雅整洁且不让人【哔——】的代码?今天我们邀请了4名淘系技术工程师,结合他们自身在写码过程中的感 查看详情

springboot实战:在requestbody中优雅的使用枚举参数(代码片段)

...到优雅的使用枚举参数和实现原理,本文继续说一下如何在RequestBody中优雅使用枚举。本文先上实战,说一下如何实现。在优雅的使用枚举参数代码的基础上,我们继续实现。如果想要获取源码,可以关注公号「... 查看详情

如何优雅的将图片文字上传至服务器?(代码片段)

...求分析@RequestPart解决问题一ResourceUtils解决问题二效果如何限制上传文件的大小并返回优雅的提示?附录:多文件上传实现前言可能很多小伙伴们在学习JAVAWEB的时候,都或多或少的接触过IO流等相关方面的知识,从最... 查看详情