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>
相关推荐
StickToForever12 分钟前
第4章 信息系统架构(五)
经验分享·笔记·学习·职场和发展
敲敲敲-敲代码4 小时前
【SQL实验】触发器
数据库·笔记·sql
Moonnnn.4 小时前
51单片机学习——动态数码管显示
笔记·嵌入式硬件·学习·51单片机
simplepeng7 小时前
我的天,我真是和androidx的字体加载杠上了
android
电棍2337 小时前
verilog笔记
笔记·fpga开发
让我安静会7 小时前
Obsidian·Copilot 插件配置(让AI根据Obsidian笔记内容进行对话)
人工智能·笔记·copilot
世事如云有卷舒8 小时前
FreeRTOS学习笔记
笔记·学习
小猫猫猫◍˃ᵕ˂◍8 小时前
备忘录模式:快速恢复原始数据
android·java·备忘录模式
CYRUS_STUDIO10 小时前
使用 AndroidNativeEmu 调用 JNI 函数
android·逆向·汇编语言
梦否10 小时前
【Android】类加载器&热修复-随记
android