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

背景

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

效果

原理分析

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

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

这里"+"用于图片占位符

kotlin 复制代码
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的文本索引起始点和末尾点

kotlin 复制代码
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进行文本裁剪再加上省略号图标后返回出去

kotlin 复制代码
        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
        }

完整源码

kotlin 复制代码
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 复制代码
<?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 复制代码
<?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>
相关推荐
安安csdn23 分钟前
系统架构设计师考前冲刺笔记-第1章-系统工程与信息系统基础
笔记·系统架构
ladymorgana1 小时前
【日常笔记】wps如何将值转换成东西南北等风向汉字
笔记·wps
Lester_11012 小时前
嵌入式学习笔记 - STM32 U(S)ART 模块HAL 库函数总结
笔记·学习
Dovis(誓平步青云)2 小时前
探索C++面向对象:从抽象到实体的元规则(上篇)
开发语言·c++·经验分享·笔记·学习方法
zimoyin3 小时前
kotlin Android AccessibilityService 无障碍入门
android·开发语言·kotlin
小葡萄20253 小时前
黑马程序员C++2024新版笔记 第三章 数组
笔记·算法·c++20
wishfly5 小时前
vscode - 笔记
ide·笔记·vscode
黄鹂绿柳6 小时前
Vue+Vite学习笔记
vue.js·笔记·学习
FakeOccupational11 小时前
计算机科技笔记: 容错计算机设计05 n模冗余系统 TMR 三模冗余系统
笔记·科技
海棠蚀omo12 小时前
C++笔记-红黑树
开发语言·c++·笔记