产品对于列表的展示出了个需求,要满足下面这些条件:
①满3行自动省略,末尾...
②如果大于3行内容,下一行需要显示一个全文的按钮
③内容中如果有话题(#话题#)需要高亮显示并可以点击跳转
④内容中如果有@用户(@用户名)需要高亮显示并可以点击跳转
⑤需要在列表中使用,如recyclerView
其实问题主要就是3个:
1、怎么计算是否大于3行内容,并根据内容的状态暴露回调接口,方便执行其他操作。
2、寻找内容中的高亮内容,并增加对应的点击回调接口。
3、处理特殊内容,如emoji、特殊符号等。
自定义view,继承AppCompatTextView,重写onMeasure方法
override fun onMeasure(widthMeasureSpec: Int, heightMeasureSpec: Int) {
super.onMeasure(widthMeasureSpec, heightMeasureSpec)
// 文字计算辅助工具
val sl = StaticLayout(
mText, paint, measuredWidth - paddingLeft - paddingRight
, Layout.Alignment.ALIGN_CENTER, 1F, 0F, false
)
// 总计行数
var lineCount = sl.lineCount
//比较总行数和设定的最大行数
if (lineCount > maxLineCount) {
if (mExpanded) {//展开状态
text = handleTopicAndAt(mText)
mCallback?.onExpand()
} else {//缩略状态
lineCount = maxLineCount
// 省略文字的宽度
val dotWidth = paint.measureText(ellipsizeText)
// 找出第 showLineCount 行的文字
val start = sl.getLineStart(lineCount - 1)
val end = sl.getLineEnd(lineCount - 1)
val lineText = if (mText.isNotEmpty()) {
mText.substring(start, end)
} else {
""
}
// 将第 showLineCount 行最后的文字替换为 ellipsizeText
var endIndex = 0
for (i in lineText.length - 1 downTo 0) {
val str = if (lineText.isNotEmpty()) {
lineText.substring(i, lineText.length)
} else {
""
}
// 找出文字宽度大于 ellipsizeText 的字符
if (paint.measureText(str) >= dotWidth) {
endIndex = i
break
}
}
// 新的第 showLineCount 的文字
if (lineText.isNotEmpty()){
val lastCodePoint = lineText.codePointAt(endIndex)
if (isEmojiCharacter(lastCodePoint)) endIndex--
}
val newEndLineText = if (lineText.isNotEmpty()) {
lineText.substring(0, endIndex) + ellipsizeText
} else {
""
}
// 最终显示的文字
val totalStr = if (mText.isNotEmpty()){
mText.substring(0, start) + newEndLineText
}else{
""
}
text = handleTopicAndAt(totalStr)
mCallback?.onCollapse()
}
} else {//不满限制的最大行数
text = handleTopicAndAt(mText)
mCallback?.onLoss()
}
setMeasuredDimension(measuredWidth, measuredHeight)
}
先通过StaticLayout来获得行数,然后根据行数和展开状态来处理内容。
文字不满3行:直接全部显示;
文字大于3行并展开状态:全部显示;
文字大于3行并隐藏状态:找出最后一行文字的开始和结束位置,用省略号替换文字末尾,拼接生成3行内容+省略号的最终文本进行显示。
高亮处理@和##话题
private fun handleTopicAndAt(handleText: String): SpannableStringBuilder {
val spannableStringBuilder = SpannableStringBuilder(handleText)
if (mTopicList.isNullOrEmpty() && mAtList.isNullOrEmpty()) return spannableStringBuilder
//处理话题
rendererTopicSpan(handleText, spannableStringBuilder, mTopicList)
//处理@用户
rendererAtSpan(handleText, spannableStringBuilder, mAtList)
return spannableStringBuilder
}
private fun rendererTopicSpan(contentText: String, spannableStringBuilder: SpannableStringBuilder, topicList: List) {
if (topicList.isEmpty()) {
return
}
//查找##
//+ 匹配前面的子表达式一次或多次(大于等于1次)。例如,“zo+”能匹配“zo”以及“zoo”,但不能匹配“z”。+等价于{1,}。
//[^xyz] 负值字符集合。匹配未包含的任意字符。例如,“[^abc]”可以匹配“plain”中的“plin”任一字符。
// val pattern = Pattern.compile("#[^#]+#") // #[^#]+# #[^\s]+?#
// val matcherSub = pattern.matcher(contentText)
val topics = HashMap()
for (topic in topicList) {
topics[topic.id] = "#" + topic.title + "#"
}
for ((key, value) in topics) {
var startIndex = 0
val pKey = Pattern.compile(escapeExprSpecialWord(value), Pattern.CASE_INSENSITIVE)
val mKey = pKey.matcher(contentText)
while (startIndex < contentText.length) {
if (mKey.find(startIndex)) {
val start = mKey.start()
val end = mKey.end()
startIndex = end
val clickableSpan: ClickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
topicList.find { "#" + it.title + "#" == value }?.run {
onTextClickListener?.onTopicClick(this)
}
}
override fun updateDrawState(ds: TextPaint) {
ds.color = topicColor
ds.isUnderlineText = false
}
}
spannableStringBuilder.setSpan(clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannableStringBuilder.setSpan(ForegroundColorSpan(topicColor), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
} else {
startIndex = spannableStringBuilder.length
}
}
}
}
private fun rendererAtSpan(contentText: String, spannableStringBuilder: SpannableStringBuilder, atList: List) {
if (atList.isEmpty()) {
return
}
//查找@
atList.forEach {
var startIndex = 0
val keyss = escapeExprSpecialWord(it.userName)
// val pKey = Pattern.compile("@$keyss", Pattern.CASE_INSENSITIVE)
val pKey = Pattern.compile(keyss, Pattern.CASE_INSENSITIVE)
val mKey = pKey.matcher(contentText)
while (startIndex < contentText.length) {
if (mKey.find(startIndex)) {
val start = mKey.start()
val end = mKey.end()
startIndex = end
val clickableSpan: ClickableSpan = object : ClickableSpan() {
override fun onClick(widget: View) {
onTextClickListener?.onAtClick(it.userName, it.userId)
}
override fun updateDrawState(ds: TextPaint) {
ds.color = atColor
ds.isUnderlineText = false
}
}
spannableStringBuilder.setSpan(clickableSpan, start, end, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
spannableStringBuilder.setSpan(ForegroundColorSpan(atColor), start, end, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
} else {
startIndex = spannableStringBuilder.length
}
}
}
}
这里的逻辑也很好理解,无非就是遍历内容,找出需要高亮的词,为其改变颜色和添加点击响应方法。
处理特殊符号
文本中的特殊内容需要单独处理,如末尾的省略号长度计算时,需要判断最后的是否emoji表情,控制好长度,避免把emoji表情从中间截取了,造成最后的文本乱码;还有匹配@和##的时候,也需要对内容中的特殊符号进行处理,不然会程序出错。
private fun isEmojiCharacter(codePoint: Int): Boolean {
return (codePoint in 0x2600..0x27BF // 杂项符号与符号字体
|| codePoint == 0x303D || codePoint == 0x2049 || codePoint == 0x203C || codePoint in 0x2000..0x200F //
|| codePoint in 0x2028..0x202F //
|| codePoint == 0x205F //
|| codePoint in 0x2065..0x206F //
/* 标点符号占用区域 */
|| codePoint in 0x2100..0x214F // 字母符号
|| codePoint in 0x2300..0x23FF // 各种技术符号
|| codePoint in 0x2B00..0x2BFF // 箭头A
|| codePoint in 0x2900..0x297F // 箭头B
|| codePoint in 0x3200..0x32FF // 中文符号
|| codePoint in 0xD800..0xDFFF // 高低位替代符保留区域
|| codePoint in 0xE000..0xF8FF // 私有保留区域
|| codePoint in 0xFE00..0xFE0F // 变异选择器
|| codePoint >= 0x10000) // Plane在第二平面以上的,char都不可以存,全部都转
}
/**
* 转义正则特殊字符 ($()*+.[]?\^{},|)
*
* @param keyword
* @return
*/
private fun escapeExprSpecialWord(keyword: String): String {
var word = keyword
if (!TextUtils.isEmpty(keyword)) {
val fbsArr = arrayOf("\\", "$", "(", ")", "*", "+", ".", "[", "]", "?", "^", "{", "}", "|")
for (key in fbsArr) {
if (keyword.contains(key)) {
word = word.replace(key, "\\" + key)
}
}
}
return word
}
基本的关键代码就是这些,例子代码我放到Git以供参考:
https://github.com/TonyDash/MultiTextView