编辑
在 Android 开发中,如果你接到一个需求:服务端返回带 <em>、<span> 等 HTML 标签的文本,要求按段落展示,且特定标签需要圆角背景 并能点击 ,同时段落本身也要支持点击选中------你会怎么做?Html.fromHtml 无法自定义圆角背景,ClickableSpan 又容易和 RecyclerView 的 item 点击冲突。本文记录一套基于自定义 ReplacementSpan + 标签解析的完整实现方案。
本项目采用mvvm架构 使用kotlin语言,这篇属于音频的数据处理和ui展示部分,其他播放逻辑放在后续篇幅讲解。代码属于公司资产,个人无权分享太多,所以只讲重点细节和思路。
1、先贴一下相关bean文件
kotlin
//圆角背景
class RoundBackgroundColorSpan(val bgColor: Int, val textColor: Int, val padding: Float, val radius: Float) : ReplacementSpan() {
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int {
return (paint.measureText(text, start, end) + padding * 2).toInt()
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
val originalColor = paint.color
paint.color = bgColor
val width = paint.measureText(text, start, end) + padding * 2
val height = bottom - top
canvas.drawRoundRect(x, top.toFloat(), x + width, bottom.toFloat(), radius, radius, paint)
paint.color = textColor
canvas.drawText(text!!, start, end, x + padding, y.toFloat(), paint)
paint.color = originalColor
}
}
//源数据
class ReadTextBean {
var text: String? = null
var details: String? = null
var select: Boolean = false //当前选中
}
//段落数据
class ParagraphBean {
var select: Boolean = false //当前选中
var list: List<ReadTextBean>? = null
var startIdx: Int = -1 //当前段落开始索引
var endIdx: Int = -1 //当前段落结束索引
var fullText: String? = null //当前段落完整文本
var spans: List<TextSpan>? = null //当前段落文本标签
data class TextSpan(
var text: String, // 文本内容
var start: Int, // 起始位置
var end: Int, // 结束位置
var isItalic: Boolean, // 是否为斜体
var isSpan: Boolean, // 是否为span标签(需要圆角背景)
)
}
2、将源数据 拼接段落打上标签 组成列表数据 在vm类中实现 ,其中标签的嵌套和 重复处理比较麻烦 当时也是费了很多心思,逻辑也比较复杂
ini
var readList = MutableLiveData<List<ReadTextBean>>() // 阅读列表
var paragraphList = MutableLiveData<List<ParagraphBean>?>() // 段落列表
//获取段落
fun getParagraph() { // 段落
launch({
val dataList = readList.value // 数据源列表
if (dataList.isNullOrEmpty()) {
paragraphList.value = mutableListOf()
return@launch
}
paragraphList.postValue(null)
val endIndices = ArrayList<Int>() // 存储段落结束位置索引
for (i in dataList.indices) {
val text = dataList[i].text
if (!text.isNullOrBlank() && text.endsWith("\n")) {
endIndices.add(i)
}
}
val result = mutableListOf<ParagraphBean>()
if (endIndices.isNotEmpty()) {
var prevEnd = -1
for (idx in endIndices) {
val start = prevEnd + 1
if (start <= idx) {
val readList = dataList.subList(start, idx + 1)
val spanText = getFullText(readList)
val showText = spanText.replace(Regex("</?(em|span)[^>]*>"), "")
result.add(
ParagraphBean().apply {
list = readList
select = false
startIdx = start
endIdx = idx
fullText = showText
spans = getSpans(spanText)
}
)
}
prevEnd = idx
}
// 处理最后剩余部分(如果有)
val lastEnd = prevEnd
val remainingStart = lastEnd + 1
if (remainingStart < dataList.size) {
val readList = dataList.subList(remainingStart, dataList.size)
val spanText = getFullText(readList)
val showText = spanText.replace(Regex("</?(em|span)[^>]*>"), "")
result.add(
ParagraphBean().apply {
list = readList
select = false
startIdx = remainingStart
endIdx = dataList.size - 1
fullText = showText
spans = getSpans(spanText)
}
)
}
} else {
val spanText = getFullText(dataList)
val showText = spanText.replace(Regex("</?(em|span)[^>]*>"), "")
result.add(
ParagraphBean().apply {
list = dataList
select = false
startIdx = 0
endIdx = dataList.size - 1
fullText = showText
spans = getSpans(spanText)
}
)
}
this.paragraphList.postValue(result)
}, {
// Log.e(TAG, "getParagraph: $it")
}, {
Log.e(TAG, "getParagraph: err${it.message}")
})
}
val BULLET_UNICODE = "\uF0B7" //表示"•"符号
val NEW_LINE = "\n"
fun getFullText(list: List<ReadTextBean>?): String {
var text = StringBuilder().apply {
if (list != null) {
for (element in list) {
append(element.text)
}
}
}.toString()
text = text.replace(Regex("[$BULLET_UNICODE$NEW_LINE]"), "").trim() // 去掉换行符
// println("text : $text")
return text
}
//处理标签 其中标签的嵌套和 重复处理比较麻烦
fun getSpans(str: String): List<ParagraphBean.TextSpan>{
var text = str
val spans = mutableListOf<ParagraphBean.TextSpan>()
if(text.contains("<span>")){ // 包含 <span>
var matchesEm: List<MatchResult>? = null
if(text.contains("<em>")){ // 嵌套包含 <em>
val regex = Regex("<em>(.*?)</em>")
// 查找所有 <em> 标签内的内容
matchesEm = regex.findAll(text).toList()
text = text.replace("<em>", "")
text = text.replace("</em>", "")
}
val regex = Regex("<span>(.*?)</span>")
// 查找所有 <span> 标签内的内容
val matchesSpan = regex.findAll(text).toList()
text = text.replace("<span>", "")
text = text.replace("</span>", "")
if (matchesEm != null) {
for (match in matchesEm) {
// println("Em content: ${match.groupValues[1]}")
// println("Start index: ${match.range.first}")
// println("End index: ${match.range.last}")
val emText = match.groupValues[1]
val emStart = text.indexOf(emText)
val emEnd = emStart + emText.length
if (emStart != -1 && emEnd != -1) {
spans.add(ParagraphBean.TextSpan(emText, emStart, emEnd, true, false))
}
}
}
for (match in matchesSpan) {
// println("Span content: ${match.groupValues[1]}")
// println("Start index: ${match.range.first}")
// println("End index: ${match.range.last}")
val spanText = match.groupValues[1]
val spanStart = text.indexOf(spanText)
val spanEnd = spanStart + spanText.length
if (spanStart != -1 && spanEnd != -1) {
// wordSpans.add(match.groupValues[1])
var isItalic = false // 是否为斜体
val iterator = spans.iterator()
while (iterator.hasNext()) { // 遍历已添加的 span
val span = iterator.next()
if (spanText.contains(span.text)) { // 如果包含
isItalic = true // 设置为斜体
iterator.remove() // 从列表中删除
break
}
}
spans.add(ParagraphBean.TextSpan(spanText, spanStart, spanEnd, isItalic, true))
}
}
}else if(text.contains("<em>")){ // 包含 <em>
// 定义正则表达式
val regex = Regex("<em>(.*?)</em>")
// 查找所有 <em> 标签内的内容
val matchesEm = regex.findAll(text).toList()
text = text.replace("<em>", "")
text = text.replace("</em>", "")
for (match in matchesEm) {
// println("Em content: ${match.groupValues[1]}")
// println("Start index: ${match.range.first}")
// println("End index: ${match.range.last}")
val emStart = text.indexOf(match.groupValues[1])
val emEnd = emStart + match.groupValues[1].length
if (emStart != -1 && emEnd != -1) {
spans.add(ParagraphBean.TextSpan(match.groupValues[1], emStart, emEnd, true, false))
}
}
}
return spans
}
3、activity中的就很简单
javascript
mViewModel.paragraphList.observe(this) {
adapter?.setList(it)
}
mViewModel.readList.observe(this) {
if (it.isNotEmpty()) {
mViewModel.getParagraph() // 获取段落信息
}
}
4、adapter中的ui处理
kotlin
class ReadTextAdapter(data: MutableList<ParagraphBean>) : BaseQuickAdapter<ParagraphBean, BaseDataBindingHolder<ItemReadTextBinding>>(R.layout.item_read_text, data) {
private var defSel = -1
private var wordListener: OnWordListener? = null
private var itemListener: OnItemListener? = null
@RequiresApi(Build.VERSION_CODES.N)
override fun convert(holder: BaseDataBindingHolder<ItemReadTextBinding>, item: ParagraphBean) {
holder.dataBinding?.let { it ->
it.bean = item
val text = item.fullText?: ""
if(item.spans != null){
val spannableString = SpannableString(text)
for(span in item.spans!!){
if(span.isItalic){
spannableString.setSpan(StyleSpan(Typeface.ITALIC), span.start, span.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)// 斜体
}
if(span.isSpan){
val color = if (item.select) {
context.resources.getColor(R.color.col_00ADEF)
} else {
context.resources.getColor(R.color.col_333333)
}
val spanBg = RoundBackgroundColorSpan(
bgColor = context.resources.getColor(R.color.col_00ADEF_20), // 背景色
textColor = color, // 文字颜色
padding = context.resources.getDimensionPixelOffset(com.jxw.jetpackmvvm.R.dimen.x10).toFloat(), // 水平内边距
radius = context.resources.getDimensionPixelOffset(com.jxw.jetpackmvvm.R.dimen.x34).toFloat(), // 圆角半径
)
spannableString.setSpan(spanBg, span.start, span.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
spannableString.setSpan(object : ClickableSpan() {
override fun onClick(widget: View) {
wordListener?.click(span.text)
}
override fun updateDrawState(ds: TextPaint) {
// 移除下划线并保持文字颜色
ds.isUnderlineText = false
}
}, span.start, span.end, Spanned.SPAN_INCLUSIVE_INCLUSIVE)
}
}
it.readText.text = spannableString
it.readText.movementMethod = LinkMovementMethod.getInstance() // 使链接可点击
// 3. 处理TextView的点击事件(不会干扰ClickableSpan)
it.readText.setOnTouchListener { v, event ->
val textView = v as TextView
val spannable = textView.text as SpannableString
if (event.action == MotionEvent.ACTION_UP) {
val x = event.x.toInt() - textView.totalPaddingLeft + textView.scrollX
val y = event.y.toInt() - textView.totalPaddingTop + textView.scrollY
val layout = textView.layout ?: return@setOnTouchListener false
val line = layout.getLineForVertical(y)
val offset = layout.getOffsetForHorizontal(line, x.toFloat())
val spans = spannable.getSpans(offset, offset, ClickableSpan::class.java)
if (spans.isEmpty()) {
// 没有点击到可点击文本,执行TextView点击事件
itemListener?.click(holder.position)
return@setOnTouchListener true
}
}
false
}
}
}
}
fun setSel(pos: Int) {
if (data.size <= 0 || pos < 0 || pos >= data.size) {
return
}
if (defSel != -1 && defSel >= 0 && defSel < data.size) {
data[defSel].select = false
notifyItemChanged(defSel)
}
defSel = pos
if (defSel != -1) {
data[defSel].select = true
notifyItemChanged(defSel)
}
}
interface OnWordListener {
fun click(text: String)
}
fun setWordListener(listener: OnWordListener){
wordListener = listener
}
interface OnItemListener {
fun click(poi: Int)
}
fun setItemListener(listener: OnItemListener){
itemListener = listener
}
}