背景
项目需求需要实现在文本末尾显示一个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>