当前位置: 首页 > article >正文

Android笔记(三十四):封装带省略号图标结尾的TextView

背景

项目需求需要实现在文本末尾显示一个icon,如果文本很长时则在省略号后面显示icon,使用TextView自带的drawableEnd可以实现,但是如果文本换行了则会显示在TextView垂直居中的位置,不满足要求,于是有了本篇的自定义View

效果

在这里插入图片描述

原理分析

在setText的时候计算icon插入的位置,这里采用文本预加载,才能让DynamicLayout计算出准确的行数

override fun setText(text: CharSequence, type: BufferType) {
        mOrigText = text
        mBufferType = type
        setTextInternal(fixTextInternal(), type)
        post {
            setTextInternal(fixTextInternal(), mBufferType)
            alpha = 1f
        }
    }

这里“+”用于图片占位符

val tmpSSb = SpannableStringBuilder(mOrigText)
        tmpSSb.append(getContentOfString(mGapToExpandHint))
        if (imgSpan1 != null) {
            tmpSSb.append("+")
            tmpSSb.setSpan(
                imgSpan1,
                tmpSSb.length - 1,
                tmpSSb.length,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }

这个算出最后一行除去占位icon的文本索引起始点和末尾点

val indexEnd = validLayout.getLineEnd(mMaxLinesOnShrink - 1)
val indexStart = validLayout.getLineStart(mMaxLinesOnShrink - 1)
var indexEndTrimmed = (indexEnd
	- getLengthOfString(mEllipsisHint)
	- getLengthOfString(mGapToExpandHint))
if (indexEndTrimmed <= indexStart) {
	indexEndTrimmed = indexEnd
}

indexEndTrimmed为去掉省略号图标后的文本末尾索引,以下需要进一步修正该索引,得出准确的值indexEndTrimmedRevised,将mOrigText进行文本裁剪再加上省略号图标后返回出去

        val remainWidth = validLayout.width - (mTextPaint!!.measureText(
            mOrigText!!.subSequence(indexStart, indexEndTrimmed).toString()
        ) + 0.5).toInt() - (bitmap1?.width ?: 0)
        val widthTailReplaced = mTextPaint!!.measureText(
            getContentOfString(mEllipsisHint)
                    + getContentOfString(mGapToExpandHint)
        )
        var indexEndTrimmedRevised = indexEndTrimmed
        if (remainWidth > widthTailReplaced) {
            var extraOffset = 0
            var extraWidth = 0
            while (remainWidth > widthTailReplaced + extraWidth) {
                extraOffset++
                extraWidth = if (indexEndTrimmed + extraOffset <= mOrigText!!.length) {
                    (mTextPaint!!.measureText(
                        mOrigText!!.subSequence(indexEndTrimmed, indexEndTrimmed + extraOffset)
                            .toString()
                    ) + 0.5).toInt()
                } else {
                    break
                }
            }
            indexEndTrimmedRevised += extraOffset - 1
        } else {
            var extraOffset = 0
            var extraWidth = 0
            while (remainWidth + extraWidth < widthTailReplaced) {
                extraOffset--
                extraWidth = if (indexEndTrimmed + extraOffset > indexStart) {
                    (mTextPaint!!.measureText(
                        mOrigText!!.subSequence(
                            indexEndTrimmed + extraOffset,
                            indexEndTrimmed
                        ).toString()
                    ) + 0.5).toInt()
                } else {
                    break
                }
            }
            indexEndTrimmedRevised += extraOffset
        }

完整源码

class EllipsisIconTextView @JvmOverloads constructor(
    context: Context, attrs: AttributeSet? = null,
    defStyleAttr: Int = 0
) : AppCompatTextView(context, attrs, defStyleAttr) {

    companion object {
        private const val GAP_TO_EXPAND_HINT = " "
        private const val MAX_LINES_ON_SHRINK = 3
    }

    private var mEllipsisHint: String? = null
    private var mGapToExpandHint: String? = GAP_TO_EXPAND_HINT
    private var mMaxLinesOnShrink = MAX_LINES_ON_SHRINK
    private var mBufferType = BufferType.NORMAL
    private var mTextPaint: TextPaint? = null
    private var mLayout: Layout? = null
    private var mTextLineCount = -1
    private var mLayoutWidth = 0
    private var mFutureTextViewWidth = 0
    private var mEllipsisIcon: Int = 0
    private var mOrigText: CharSequence? = null
    private var bitmap1: Bitmap? = null
    private var imgSpan1: ImageSpan? = null
    private var isIconAlign = false


    init {
        var ellipsisIconWidth = 0
        var ellipsisIconHeight = 0
        if (attrs != null) {
            val a = context.obtainStyledAttributes(attrs, R.styleable.EllipsisIconTextView)
            val n = a.indexCount
            for (i in 0 until n) {
                when (val attr = a.getIndex(i)) {
                    R.styleable.EllipsisIconTextView_maxLinesOnShrink -> {
                        mMaxLinesOnShrink = a.getInteger(attr, MAX_LINES_ON_SHRINK)
                    }
                    R.styleable.EllipsisIconTextView_ellipsisHint -> {
                        mEllipsisHint = a.getString(attr)
                    }
                    R.styleable.EllipsisIconTextView_gapToExpandHint -> {
                        mGapToExpandHint = a.getString(attr)
                    }
                    R.styleable.EllipsisIconTextView_ellipsisIcon -> {
                        mEllipsisIcon = a.getResourceId(attr, 0)
                    }
                    R.styleable.EllipsisIconTextView_ellipsisIconAlign -> {
                        isIconAlign = a.getBoolean(attr, false)
                    }
                    R.styleable.EllipsisIconTextView_ellipsisIconWidth -> {
                        ellipsisIconWidth = a.getDimensionPixelSize(attr, 0)
                    }
                    R.styleable.EllipsisIconTextView_ellipsisIconHeight -> {
                        ellipsisIconHeight = a.getDimensionPixelSize(attr, 0)
                    }
                }
            }
            a.recycle()
        }

        bitmap1 = BitmapFactory.decodeResource(resources, mEllipsisIcon)
        val drawable = if (mEllipsisIcon == 0) null else AppCompatResources.getDrawable(context, mEllipsisIcon)
        drawable?.let {
            if (ellipsisIconWidth > 0 && ellipsisIconHeight > 0) {
                drawable.setBounds(0, 0, ellipsisIconWidth, ellipsisIconHeight)
            } else {
                drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
            }
            imgSpan1 = if (isIconAlign) CenteredImageSpan(drawable) else ImageSpan(drawable)
        }
        alpha = 0f
    }

    fun updateForRecyclerView(text: CharSequence, futureTextViewWidth: Int) {
        mFutureTextViewWidth = futureTextViewWidth
        setText(text, BufferType.NORMAL)
    }

    fun updateForRecyclerView(text: CharSequence, type: BufferType, futureTextViewWidth: Int) {
        mFutureTextViewWidth = futureTextViewWidth
        setText(text, type)
    }

    fun setMaxLinesOnShrink(text: CharSequence, mMaxLinesOnShrink: Int) {
        this.mMaxLinesOnShrink = mMaxLinesOnShrink
        setText(text, BufferType.NORMAL)
    }

    private fun fixTextInternal(): CharSequence? {
        if (TextUtils.isEmpty(mOrigText)) {
            return mOrigText
        }
        mLayout = layout
        if (mLayout != null) {
            mLayoutWidth = mLayout!!.width
        }
        if (mLayoutWidth <= 0) {
            mLayoutWidth = if (width == 0) {
                if (mFutureTextViewWidth == 0) {
                    return mOrigText
                } else {
                    mFutureTextViewWidth - paddingLeft - paddingRight
                }
            } else {
                width - paddingLeft - paddingRight
            }
        }
        mTextPaint = paint
        mTextLineCount = -1
        val tmpSSb = SpannableStringBuilder(mOrigText)
        tmpSSb.append(getContentOfString(mGapToExpandHint))
        if (imgSpan1 != null) {
            tmpSSb.append("+")
            tmpSSb.setSpan(
                imgSpan1,
                tmpSSb.length - 1,
                tmpSSb.length,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }
        mLayout = null
        mLayout = DynamicLayout(
            tmpSSb,
            mTextPaint!!,
            mLayoutWidth,
            Layout.Alignment.ALIGN_NORMAL,
            1.0f,
            0.0f,
            false
        )
        mTextLineCount = mLayout!!.lineCount
        if (mTextLineCount <= mMaxLinesOnShrink) {
            return tmpSSb
        }
        val indexEnd = validLayout.getLineEnd(mMaxLinesOnShrink - 1)
        val indexStart = validLayout.getLineStart(mMaxLinesOnShrink - 1)
        var indexEndTrimmed = (indexEnd
                - getLengthOfString(mEllipsisHint)
                - getLengthOfString(mGapToExpandHint))
        if (indexEndTrimmed <= indexStart) {
            indexEndTrimmed = indexEnd
        }
        val remainWidth = validLayout.width - (mTextPaint!!.measureText(
            mOrigText!!.subSequence(indexStart, indexEndTrimmed).toString()
        ) + 0.5).toInt() - (bitmap1?.width ?: 0)
        val widthTailReplaced = mTextPaint!!.measureText(
            getContentOfString(mEllipsisHint)
                    + getContentOfString(mGapToExpandHint)
        )
        var indexEndTrimmedRevised = indexEndTrimmed
        if (remainWidth > widthTailReplaced) {
            var extraOffset = 0
            var extraWidth = 0
            while (remainWidth > widthTailReplaced + extraWidth) {
                extraOffset++
                extraWidth = if (indexEndTrimmed + extraOffset <= mOrigText!!.length) {
                    (mTextPaint!!.measureText(
                        mOrigText!!.subSequence(indexEndTrimmed, indexEndTrimmed + extraOffset)
                            .toString()
                    ) + 0.5).toInt()
                } else {
                    break
                }
            }
            indexEndTrimmedRevised += extraOffset - 1
        } else {
            var extraOffset = 0
            var extraWidth = 0
            while (remainWidth + extraWidth < widthTailReplaced) {
                extraOffset--
                extraWidth = if (indexEndTrimmed + extraOffset > indexStart) {
                    (mTextPaint!!.measureText(
                        mOrigText!!.subSequence(
                            indexEndTrimmed + extraOffset,
                            indexEndTrimmed
                        ).toString()
                    ) + 0.5).toInt()
                } else {
                    break
                }
            }
            indexEndTrimmedRevised += extraOffset
        }
        val fixText = removeEndLineBreak(mOrigText!!.subSequence(0, indexEndTrimmedRevised))
        val ssbShrink = SpannableStringBuilder(fixText)
        if (mEllipsisHint != null) {
            ssbShrink.append(mEllipsisHint)
        }
        ssbShrink.append(getContentOfString(mGapToExpandHint))
        if (imgSpan1 != null) {
            ssbShrink.append("+")
            ssbShrink.setSpan(
                imgSpan1,
                ssbShrink.length - 1,
                ssbShrink.length,
                Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }
        return ssbShrink
    }

    private fun removeEndLineBreak(text: CharSequence): String {
        var str = text.toString()
        while (str.endsWith("\n")) {
            str = str.substring(0, str.length - 1)
        }
        val mLayout: Layout = DynamicLayout(
            str,
            mTextPaint!!,
            mLayoutWidth,
            Layout.Alignment.ALIGN_NORMAL,
            1.0f,
            0.0f,
            false
        )
        if (mLayout.lineCount > mMaxLinesOnShrink) {
            if (str.contains("\n")) {
                str = str.substring(0, str.lastIndexOf("\n"))
            }
        }
        return str
    }

    private val validLayout: Layout
        get() = if (mLayout != null) mLayout!! else layout

    override fun setText(text: CharSequence, type: BufferType) {
        mOrigText = text
        mBufferType = type
        setTextInternal(fixTextInternal(), type)
        post {
            setTextInternal(fixTextInternal(), mBufferType)
            alpha = 1f
        }
    }

    private fun setTextInternal(text: CharSequence?, type: BufferType) {
        super.setText(text, type)
    }

    private fun getLengthOfString(string: String?): Int {
        return string?.length ?: 0
    }

    private fun getContentOfString(string: String?): String {
        return string ?: ""
    }

    internal class CenteredImageSpan(drawableRes: Drawable) : ImageSpan(
        drawableRes
    ) {
        override fun draw(
            canvas: Canvas, text: CharSequence,
            start: Int, end: Int, x: Float,
            top: Int, y: Int, bottom: Int, paint: Paint
        ) {
            val b = drawable
            val fm = paint.fontMetricsInt
            val transY = ((y + fm.descent + y + fm.ascent) / 2 - b.bounds.bottom / 2)
            canvas.save()
            canvas.translate(x, transY.toFloat())
            b.draw(canvas)
            canvas.restore()
        }
    }

}
<?xml version="1.0" encoding="utf-8"?>
<resources>

    <declare-styleable name="EllipsisIconTextView">
        <attr name="maxLinesOnShrink" format="reference|integer" />
        <attr name="ellipsisHint" format="reference|string" />
        <attr name="gapToExpandHint" format="reference|string" />
        <attr name="ellipsisIcon" format="reference"/>
        <attr name="ellipsisIconAlign" format="boolean"/>
        <attr name="ellipsisIconWidth" format="dimension"/>
        <attr name="ellipsisIconHeight" format="dimension"/>
    </declare-styleable>

</resources>
  • 测试代码
<?xml version="1.0" encoding="utf-8"?>
<androidx.constraintlayout.widget.ConstraintLayout xmlns:android="http://schemas.android.com/apk/res/android"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    xmlns:app="http://schemas.android.com/apk/res-auto">

    <com.mask_boy.test.myapplication.EllipsisIconTextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:gravity="center"
        android:text="My name is Masked Boy, My name is Masked Boy"
        android:textSize="18sp"
        app:ellipsisIconAlign="true"
        app:ellipsisIconHeight="15dp"
        app:ellipsisIconWidth="15dp"
        app:ellipsisHint="..."
        app:gapToExpandHint="More"
        app:layout_constraintBottom_toTopOf="@+id/ellipsisIconTextView"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:maxLinesOnShrink="1" />

    <com.mask_boy.test.myapplication.EllipsisIconTextView
        android:id="@+id/ellipsisIconTextView"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:gravity="center"
        android:text="My name is Masked Boy, My name is Masked Boy"
        android:textSize="18sp"
        app:ellipsisIcon="@drawable/ic_lock_tips_arrow"
        app:ellipsisIconAlign="true"
        app:ellipsisIconHeight="15dp"
        app:ellipsisIconWidth="15dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        app:maxLinesOnShrink="2" />

    <com.mask_boy.test.myapplication.EllipsisIconTextView
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        android:layout_marginHorizontal="40dp"
        android:gravity="center"
        android:text="My name is Masked Boy, My name is Masked Boy"
        android:textSize="18sp"
        app:ellipsisIcon="@drawable/ic_lock_tips_arrow"
        app:ellipsisIconAlign="true"
        app:ellipsisIconHeight="15dp"
        app:ellipsisIconWidth="15dp"
        app:layout_constraintBottom_toBottomOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintTop_toBottomOf="@+id/ellipsisIconTextView"
        app:maxLinesOnShrink="1" />
</androidx.constraintlayout.widget.ConstraintLayout>

http://www.kler.cn/a/417054.html

相关文章:

  • 微服务..
  • JS宏进阶:XMLHttpRequest对象
  • 内存飚⾼问题定位
  • 【文档智能多模态】英伟达ECLAIR-端到端的文档布局提取,并集成阅读顺序方法
  • 基于html写一个音乐动态爱心盒子有音乐和导航基本功能实现
  • C++基础系列【8】如何解决编译器报的错误
  • 电机瞬态分析基础(6):坐标变换(续)
  • 从0到1搭建webpack
  • ESLint 配置文件全解析:格式、层叠与扩展(3)
  • 将大模型指令微调数据从parquet转为json格式
  • 大数据新视界 -- Hive 与其他大数据工具的集成:协同作战的优势(上)(13/ 30)
  • Flink随笔 20241129 流数据处理:以生产线烤鸡为例理解 Flink
  • Socket编程(TCP/UDP详解)
  • Windows 平台使用 podofo.dll 异常,需要安装一下库:Win64OpenSSL_Light-3_3_2.msi
  • 配置泛微e9后端开发环境
  • 学习C#中的反射
  • 【Yarn Bug】 yarn 安装依赖出现的网络连接问题
  • Java抛出自定义运行运行
  • 后端-mybatis的一对一查询
  • 准确--在 AlmaLinux 9.2 上快速搭建 FTP 服务器
  • AI潮汐日报1128期:马斯克计划推出Grok挑战GPT宝座、实时数字孪生心脏模拟、大模型竟也会产生焦虑和偏见
  • SpringBoot 架构的新冠密接者跟踪系统:安全防护体系深度解读
  • 学习ASP.NET Core的身份认证(基于Session的身份认证3)
  • Next.js 中 API 路由与 Actions 的使用选择与比较
  • linux centos nginx编译安装
  • 【人工智能-科普】深度森林:传统机器学习与深度学习的创新结合