背景
项目需求需要实现在文本末尾显示一个icon,如果文本很长时则在省略号后面显示icon,使用TextView自带的drawableEnd可以实现,但是如果文本换行了则会显示在TextView垂直居中的位置,不满足要求,于是有了本篇的自定义View
效果
原理分析
在setText的时候计算icon插入的位置,这里采用文本预加载,才能让DynamicLayout计算出准确的行数
override fun setText(text: CharSequence, type: BufferType) {mOrigText = textmBufferType = typesetTextInternal(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 = indexEndTrimmedif (remainWidth > widthTailReplaced) {var extraOffset = 0var extraWidth = 0while (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 = 0var extraWidth = 0while (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? = nullprivate var mGapToExpandHint: String? = GAP_TO_EXPAND_HINTprivate var mMaxLinesOnShrink = MAX_LINES_ON_SHRINKprivate var mBufferType = BufferType.NORMALprivate var mTextPaint: TextPaint? = nullprivate var mLayout: Layout? = nullprivate var mTextLineCount = -1private var mLayoutWidth = 0private var mFutureTextViewWidth = 0private var mEllipsisIcon: Int = 0private var mOrigText: CharSequence? = nullprivate var bitmap1: Bitmap? = nullprivate var imgSpan1: ImageSpan? = nullprivate var isIconAlign = falseinit {var ellipsisIconWidth = 0var ellipsisIconHeight = 0if (attrs != null) {val a = context.obtainStyledAttributes(attrs, R.styleable.EllipsisIconTextView)val n = a.indexCountfor (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 = futureTextViewWidthsetText(text, BufferType.NORMAL)}fun updateForRecyclerView(text: CharSequence, type: BufferType, futureTextViewWidth: Int) {mFutureTextViewWidth = futureTextViewWidthsetText(text, type)}fun setMaxLinesOnShrink(text: CharSequence, mMaxLinesOnShrink: Int) {this.mMaxLinesOnShrink = mMaxLinesOnShrinksetText(text, BufferType.NORMAL)}private fun fixTextInternal(): CharSequence? {if (TextUtils.isEmpty(mOrigText)) {return mOrigText}mLayout = layoutif (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 = paintmTextLineCount = -1val 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 = nullmLayout = DynamicLayout(tmpSSb,mTextPaint!!,mLayoutWidth,Layout.Alignment.ALIGN_NORMAL,1.0f,0.0f,false)mTextLineCount = mLayout!!.lineCountif (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 = indexEndTrimmedif (remainWidth > widthTailReplaced) {var extraOffset = 0var extraWidth = 0while (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 = 0var extraWidth = 0while (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: Layoutget() = if (mLayout != null) mLayout!! else layoutoverride fun setText(text: CharSequence, type: BufferType) {mOrigText = textmBufferType = typesetTextInternal(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 = drawableval fm = paint.fontMetricsIntval 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.EllipsisIconTextViewandroid: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.EllipsisIconTextViewandroid: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.EllipsisIconTextViewandroid: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>