一、概述
就到国庆节了,上一次写还是五一的时候,真的好快好快,时间按周的单位在奔跑,一周,一周,又一周…
七天,没有计划,人多,哪里都堵,还有疫情(主要还是没人一起玩😂)就窝在小出租屋里写写代码,看看书好了。
祝祖国繁荣昌盛,世界和平共处。
今天写的是 TextView 相关的效果,是之前项目中写过的,感觉还是有必要记录一下,还是花了一些时间,现在整理一下,方便以后使用。如果你看到了觉得有用那更好。
看下效果1:
在文本尾部显示一个icon。如果文本过长,后面部分ellipsize,超过TextView的最大行数,也会在尾部显示icon,并且icon可以点击:
效果2:
显示文本。如果文本过长,超过TextView的最大行数,后面部分ellipsize,在尾部追加 “全文”,并可以点击全文展开,再点击恢复初始:
二、思路
两个效果都是使用 Spannable 实现。基本逻辑:
- 给 TextView setText。效果1是在源文本尾部增加了几个字符,是为了给icon占位(因为可能文本刚好显示完,这时再去拼接一定会导致再次省略)。效果2是直接设置源文本;
- 等到布局完成后,获取 TextView 的 layout ,判断是否有省略的文字;
- 有省略的文字的话,就获取未省略的文字个数,把个数减去一点点(为了能放的下后面要拼接的icon或文本),从文本中截取0到个数这段文字,然后再拼接icon或文本;
- 没有省略的文字的话,效果1是直接设置ImageSpan,效果2是不做处理。
- 设置 ClickableSpan,把组装好的 Spannable 设置给 TextView ;
- 再次获取 TextView 的 layout ,判断是否有省略的文字,因为有可能上面的 “ 减去一点点 ” 减的太少了,再次导致出现省略。所以这次减多一点,再执行拼接,设置 Spannable 。
所以至少是两次 setText ,第一次是为了在第二次能通过layout拿到出省略的位置,好拼接icon和文本。
具体看看下面实现代码。
三、实现代码
布局:
<TextView
android:id="@+id/textView"
android:layout_width="0dp"
android:layout_height="match_parent"
android:layout_marginStart="20dp"
android:layout_marginEnd="20dp"
android:layout_marginTop="28dp"
android:textColor="@color/black"
android:textSize="20sp"
android:textColorHighlight="#00ffffff"
android:maxLines="3"
android:ellipsize="end"/>
android:textColorHighlight 属性需要设置为透明,不然点击尾部的 icon 或 “全文” 会有背景色。
android:ellipsize=“end” 和 android:maxLines = “n” 也是必须要设置的。
TextEllipsizeSpanUtil ,处理逻辑都封装在这个类中,方便外部使用。
object TextEllipsizeSpanUtil {
/**
* 在文本尾部显示一个icon。如果文本过长超过TextView的最大行数,后面部分ellipsis,也会在尾部显示icon
* 并且icon可以点击
*/
fun setTextEndImageSpan(textView: TextView, text: String, drawable: Drawable?) {
//拼接一个空格和两个点,空格为文本和icon分割,两个点为icon占位,后面会把这两个点替换为icon
val showText = "$text .."
textView.text = showText
val layout = textView.layout
if (layout == null) {//还没有布局完成,等到布局完成再执行
textView.viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
textView.viewTreeObserver.removeOnGlobalLayoutListener(this)
setImageSpan(textView, text, showText, drawable)
}
})
} else {
setImageSpan(textView, text, showText, drawable)
}
}
/**
* 执行设置imageSpan
*/
private fun setImageSpan(textView: TextView, text: String, showText: String, drawable: Drawable?) {
var layout = textView.layout ?: return
if (drawable != null && layout.lineCount > 0) {
setTouchListener(textView)
val line = layout.lineCount - 1 //最后一行
var ellipsisStart = layout.getEllipsisStart(line) //从哪里开始省略的索引值
var count = layout.getEllipsisCount(line) //省略的文字数
val span = SpannableStringBuilder(showText)
//省略行之前的总字数
val lineEnd = if (line - 1 >= 0) { layout.getLineEnd(line - 1) } else { 0 }
var offset = 2//imageSpan从倒数第几个开始放置
if (count > 0) { //文字过长,有省略的文字
offset = 1
val subEndIndex = lineEnd + ellipsisStart //未省略的字的最后一个的索引值
if (subEndIndex < showText.length) {
span.clear()
if (count <= 3) { //省略的刚好是拼接的空格和两个点,说明text刚刚好显示完,再加一个字符就会导致省略
span.append(showText.substring(0, subEndIndex - 2))
} else { //源文本就很长,被省略了
//截取显示的文本。-2少截取两个,以便有位置拼接省略号和icon
span.append(showText.substring(0, subEndIndex - 1))
}
span.append("....") //拼接四个省略号,最后一个点会被icon替换
}
}
drawable.setBounds(0, 0, drawable.intrinsicWidth, drawable.intrinsicHeight)
val imageSpan = ImageSpan(drawable)
span.setSpan(imageSpan, span.length - offset, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//设置icon可点击
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {//点击事件
Toast.makeText(textView.context, "copy success", Toast.LENGTH_SHORT).show()
}
override fun updateDrawState(ds: TextPaint) {//不设置颜色和下划线
}
}
span.setSpan(clickableSpan, span.length - 2, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = span
//再次判断是否还有省略,有省略的话,说明imageSpan没显示全
layout = textView.layout ?: return
count = layout.getEllipsisCount(line) //省略的文字数
if (count > 0) {//如果还有省略的文字,说明上面showText.substring截出来的文字太长了,要少截一点
ellipsisStart = layout.getEllipsisStart(line) //从哪里开始省略的索引值
val subEndIndex = lineEnd + ellipsisStart //未省略的字的最后一个的索引值
if (subEndIndex < showText.length) {
span.clear()
span.append(showText.substring(0, subEndIndex - 3))//减去3,少截取一些,保证不再出现省略
span.append("....") //拼接省略号和两个点,两个点会被icon替换
span.setSpan(imageSpan, span.length - offset, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
span.setSpan(clickableSpan, span.length - offset, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = span
}
}
} else {
textView.text = text
}
}
/**
* 显示文本。如果文本过长超过TextView的最大行数,后面部分ellipsis,在尾部追加 “全文”,并可以点击全文展开
*/
fun setTextEndTextSpan(textView: TextView, text: String, endText: String) {
textView.text = text
val layout = textView.layout
if (layout == null) {
textView.viewTreeObserver.addOnGlobalLayoutListener(object :
ViewTreeObserver.OnGlobalLayoutListener {
override fun onGlobalLayout() {
textView.viewTreeObserver.removeOnGlobalLayoutListener(this)
setTextSpan(textView, text, endText)
}
})
} else {
setTextSpan(textView, text, endText)
}
}
/**
* 执行设置span
*/
private fun setTextSpan(textView: TextView, text: String, endText: String) {
var layout = textView.layout ?: return
if (layout.lineCount > 0) {
setTouchListener(textView)
val line = layout.lineCount - 1 //最后一行
var ellipsisStart = layout.getEllipsisStart(line) //从哪里开始省略的索引值
var count = layout.getEllipsisCount(line) //省略的文字数
//省略行之前的总字数
val lineEnd = if (line - 1 >= 0) { layout.getLineEnd(line - 1) } else { 0 }
if (count > 0) { //文字过长,有省略的文字
var subEndIndex = lineEnd + ellipsisStart //未省略的字的最后一个的索引值
if (subEndIndex < text.length) {
val span = SpannableStringBuilder()
subEndIndex = subEndIndex - 1 - endText.length
if (subEndIndex > 0) {
span.append(text.substring(0, subEndIndex))
}
span.append("...$endText") //拼接省略号和endText
//设置endText可点击
val clickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
val maxLine = textView.maxLines
textView.maxLines = Int.MAX_VALUE
textView.text = text//展示全文
textView.setOnClickListener {
textView.setOnClickListener(null)
textView.maxLines = maxLine//恢复初始
setTextEndTextSpan(textView, text, endText)
}
}
}
span.setSpan(clickableSpan, span.length - endText.length, span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
textView.text = span
//再次判断是否还有省略,有省略的话,说明imageSpan没显示全
layout = textView.layout ?: return
count = layout.getEllipsisCount(line) //省略的文字数
if (count > 0) {//如果还有省略的文字,说明上面text.substring截的文字太长了,要少截一点,不然endText显示不全
ellipsisStart = layout.getEllipsisStart(line) //从哪里开始省略的索引值
subEndIndex = lineEnd + ellipsisStart //未省略的字的最后一个的索引值
if (subEndIndex < text.length) {
span.clear()
subEndIndex = subEndIndex - 5 - endText.length
if (subEndIndex > 0) {
span.append(text.substring(0, subEndIndex))
}
span.append("...$endText") //拼接省略号和两个点,两个点会被icon替换
span.setSpan(
clickableSpan, span.length - endText.length,
span.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
)
textView.text = span
}
}
}
}
}
}
/**
* 设置触摸事件,让TextView响应ClickableSpan。此处代码参考LinkMovementMethod.onTouchEvent方法
* 直接给TextView设置LinkMovementMethod后,文本过长时可以滑动,与ellipsize冲突
*/
@SuppressLint("ClickableViewAccessibility")
private fun setTouchListener(textView: TextView) {
textView.setOnTouchListener(object : View.OnTouchListener {
var downX = 0f
var downY = 0f
override fun onTouch(v: View?, event: MotionEvent?): Boolean {
val spanned = textView.text as? Spanned ?: return false
when (event?.action) {
MotionEvent.ACTION_DOWN -> {
downX = event.x
downY = event.y
return true//返回true,接受后续的UP事件
}
MotionEvent.ACTION_UP -> {
var x = event.x.toInt()
var y = event.y.toInt()
//判断一下按下和抬起的位置,相差太大就不处理
if (abs(downX - x) > 8 || abs(downY - y) > 8) {
return false
}
//下面代码都是照搬LinkMovementMethod.onTouchEvent
x -= textView.totalPaddingLeft
y -= textView.totalPaddingTop
x += textView.scrollX
y += textView.scrollY
val layout: Layout = textView.layout
val line = layout.getLineForVertical(y)
val off = layout.getOffsetForHorizontal(line, x.toFloat())
val links: Array<ClickableSpan> =
spanned.getSpans(off, off, ClickableSpan::class.java)
if (links.isNotEmpty()) {
links[0].onClick(textView)
return true
}
}
}
return false
}
})
}
}
这里没有使用 LinkMovementMethod ,而是给TextView 设置触摸监听,参考了 LinkMovementMethod 的代码处理 ClickableSpan 。如果给TextView 设置 LinkMovementMethod ,文本过长时可以滑动,与ellipsize冲突
四、使用
val drawable = ContextCompat.getDrawable(context, R.drawable.icon_copy)
//效果1
TextEllipsizeSpanUtil.setTextEndImageSpan(textView, text, drawable)
//效果2
//TextEllipsizeSpanUtil.setTextEndTextSpan(textView, text, "全文")
我自己写了文本测试,暂时还没有发现问题。如果你发现问题,可以改改里面的一些代码来解决。或许有些场景,上面的实现无法满足,这只是我想到的办法,可能有更好的方案来实现。