如何更简洁地实现富文本 Span

·  阅读 3034

携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情

本文已授权[郭霖]公众号独家发布

前言

现在的 App 基本都需要同意用户协议和隐私政策,通常会用富文本 Span 来实现局部点击。而 Span 的代码经常会写一大堆,如何封装简化代码个人思考了很久。最终选择基于官方的 core-ktx 库进行封装,实现一套完整好用的 Span API,并且会给出每个 Span 对应的效果图。

接下来和大家分享个人的封装思路和最终实现的效果。

基础用法

我们用一个同意隐私协议的需求作为例子,这里假设“隐私协议”不仅需要局部点击,还要加斜体,效果如下:

image.png

要实现这个效果也不难,稍微学习下 SpannableString 就能实现出来。

val spannableString = SpannableString("已同意隐私政策")
val urlSpan = URLSpan("https://xxxxxxxx.com") // 超链接
val italicSpan = StyleSpan(Typeface.ITALIC)  // 斜体
spannableString.setSpan(urlSpan, 3, 7, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
spannableString.setSpan(italicSpan, 3, 7, Spanned.SPAN_INCLUSIVE_EXCLUSIVE)
textView.movementMethod = LinkMovementMethod.getInstance() // 设置了才能点击
textView.text = spannableString
复制代码

讲下其中的 flags 参数,有以下类型:

flags 类型含义
Spanned.SPAN_EXCLUSIVE_EXCLUSIVE开开区间 (start, end)
Spanned.SPAN_EXCLUSIVE_INCLUSIVE开闭区间 (start, end]
Spanned.SPAN_INCLUSIVE_EXCLUSIVE闭开区间 [start, end)
Spanned.SPAN_INCLUSIVE_INCLUSIVE闭闭区间 [start, end]

有这么多选择,到底用哪一个比较好呢?原则上是哪个方便用哪个,一般会用 Spanned.SPAN_INCLUSIVE_EXCLUSIVE,后面会讲原因。

如果要支持多国语言,还需要考虑语序问题。假设有 a、b 两个词语要实现富文本,有可能因为语法不同导致 b 在 a 前面。此时占位符不要直接使用 %s,用 %1$s%2$s,其中的 1$2$ 是指明用第几个参数,语序不同时把 %2$s 写在前面。还有一种做法是结合 Html 标签直接用一个字符串来实现富文本,不过原生支持的 Html 标签不全,有些效果没法实现,个人觉得不如直接用 Span。

富文本功能实现下来代码会挺多的,很多人会封装一下,我也不例外,早就想好了一个结合 Kotlin 高阶函数封装的 DSL 用法。不过个人之前一篇封装工具类的文章提到一点观点,不要重复造轮子,封装工具类之前最好先了解一下 Android KTX 库和 Kotlin 的标准库有没实现相同的功能。所以当时就先看一下 core-ktx 有没富文本的功能,没想到居然还真有,赶紧学习下怎么使用。

core-ktx 用法

学习一下发现官方的 core-ktx 把 DSL 用法实现了,用法如下:

dependencies {
    implementation 'androidx.core:core-ktx:1.7.0'
}
复制代码
textView.movementMethod = LinkMovementMethod.getInstance() // 设置了才能点击
textView.text = buildSpannedString {
  append("已同意")
  inSpans(URLSpan("https://xxxxxxxx.com")) { // 设置超链接
    italic { // 设置斜体
      append("隐私政策")
    }
  }
}
复制代码

通过 buildSpannedString {...} 函数创建 SpannedString 对象,在函数内调用 append(text) 或 appendLine(text) 添加文字。添加文字时可以用 inSpans(span) {...} 函数包裹可以设置 Span,官方还提供了 italic {...}bold {...} 等扩展函数,使用起来更加简洁。

DSL 用法挺不错的,但是不适用于多国语言。由于语法不同,富文本的位置可能在语句的中间或末尾,这样就需要计算富文本的起始和结束位置了。那就改成下面的用法:

val privacyText = getString(R.string.privacy_policy) // 隐私政策
val agreedText = getString(R.string.agreed_privacy_policy, privacyText) // 已同意隐私政策
val start = agreedText.indexOf(privacyText)
val end = start + privacyText.length 

val spannable = agreedText.toSpannable() // 转为 Spannable
spannable[start, end] = URLSpan("https://xxxxxxxx.com") // 设置超链接
spannable[start, end] = StyleSpan(Typeface.ITALIC)  // 设置斜体
textView.movementMethod = LinkMovementMethod.getInstance() // 设置了才能点击
textView.text = spannable
复制代码

通过 toSpannable() 扩展把一个字符串转为 Spannable 对象,用 spannable[start, end] 直接在一个区间类设置 Span,比原来的用法简洁很多。而 Spannable 是 CharSequence 的子类,可以直接设置给 TextView。

注意 core-ktx 默认用了闭开区间 Spanned.SPAN_INCLUSIVE_EXCLUSIVE,这样可以直接用 String#indexOf() 计算起始位置,结束位置用起始位置加上字符串长度。如果用其它区间还需要加一或减一,计算起来麻烦一点。

其实两种用法还能混着用的,比如不仅有多国语言的富文本,前面还有个标题:

textView.text = buildSpannedString {
  appendLine(titleText)
  val spannable = contextText.toSpannable()
  spannable[start, end] = // ...
  spannable[start, end] = // ...
  append(spannable)
}
复制代码

以上就是 core-ktx 主要的富文本功能了,官方的 API 还是很好用的。美中不足的是 DSL 的扩展太少了,仅有 italicboldunderline 等 9 个扩展函数,连个局部点击的扩展都没有。虽说可以用 inSpans(span) {...} 添加,但代码不够简洁,和我想象中的用法稍有出入,所以个人还是决定封装一下。

封装思路

个人最早想到的以下 DSL 用法:

textView.setText {
  append("已同意")
  append("隐私政策") {
    url("https://xxxxxxxx.com") // 设置超链接
    italic() // 设置斜体
  }
}
复制代码

看过不少人也是类似的封装,先用 append(text) 添加内容,再设置需要的 Span 效果,最多两级嵌套。这个层级关系和官方的有点区别, core-ktx 是先设置 Span 效果,再 append(text),可能会有多级嵌套。如果 Span 设置得比较多,代码会像火箭一样。比如:

inSpans(URLSpan("https://xxxxxxxx.com")) {
  bold { // 加粗
    italic { // 斜体
      backgroundColor(color) { // 背景色
        scale(1.2f) { // 等比例放大
          append("隐私政策")
        }
      }
    }
  }
}
复制代码

这样看下来官方的用法貌似没那么好?但是多数人能想到的 DSL 用法,官方不可能想不到呀,官方的会这么选择应该有其它的考虑。个人想了很久后,发现这么设计确实有道理的,连续的文字复用 Span 更方便。比如有一段文字都要加粗斜体,其中一部分要改颜色,下面是两种 DSL 用法的实现方式:

textView.setText {
  append("aaaa") {
    bold()
    italic()
  }
  append("bbbb") {
    color(color)
    bold()
    italic()
  }
  append("cccc") {
    bold()
    italic()
  }
}
复制代码
textView.text = buildSpannedString {
  bold {
    italic {
      append("aaaa")
      color(color) { append("bbbb") }
      append("cccc")
    }
  }
}
复制代码

可以看到官方的 DSL 只设置了一次加粗斜体,三段文字共用,复用性更好。如果 Span 效果不多,官方的 DSL 可以写成一行,代码可读性也还行,比如:

textView.text = buildSpannedString {
  bold { italic { append("xxxx") } }
}
复制代码

所以个人最终决定基于官方的 DSL 用法去补充更多的扩展。那么怎么封装呢?其实只要写个扩展去调用 inSpans(span, builderAction),我们可以给已有的 Span 都写个扩展。

inline fun SpannableStringBuilder.size(
  size: Int,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = inSpans(AbsoluteSizeSpan(size), builderAction)

inline fun SpannableStringBuilder.blur(
  radius: Float,
  style: BlurMaskFilter.Blur = BlurMaskFilter.Blur.NORMAL,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = maskFilter(BlurMaskFilter(radius, style), builderAction)

inline fun SpannableStringBuilder.maskFilter(
  filter: MaskFilter,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = inSpans(MaskFilterSpan(filter), builderAction)

inline fun SpannableStringBuilder.fontFamily(
  family: String?,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = inSpans(TypefaceSpan(family), builderAction)

inline fun SpannableStringBuilder.url(
  url: String,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = inSpans(URLSpan(url), builderAction)

inline fun SpannableStringBuilder.alignCenter(
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = alignment(Layout.Alignment.ALIGN_CENTER, builderAction)

inline fun SpannableStringBuilder.alignOpposite(
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = alignment(Layout.Alignment.ALIGN_OPPOSITE, builderAction)

inline fun SpannableStringBuilder.alignment(
  alignment: Layout.Alignment,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = inSpans(AlignmentSpan.Standard(alignment), builderAction)

inline fun SpannableStringBuilder.leadingMargin(
  first: Float,
  rest: Float = first,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = leadingMargin(first.toInt(), rest.toInt(), builderAction)

inline fun SpannableStringBuilder.leadingMargin(
  first: Int,
  rest: Int = first,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = inSpans(LeadingMarginSpan.Standard(first, rest), builderAction)

inline fun SpannableStringBuilder.bullet(
  gapWidth: Float,
  @ColorInt color: Int? = null,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder = bullet(gapWidth.toInt(), color, builderAction)

inline fun SpannableStringBuilder.bullet(
  gapWidth: Int = BulletSpan.STANDARD_GAP_WIDTH,
  @ColorInt color: Int? = null,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder =
  inSpans(if (color == null) BulletSpan(gapWidth) else BulletSpan(gapWidth, color), builderAction)

inline fun SpannableStringBuilder.quote(
  @ColorInt color: Int? = null,
  builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder =
  inSpans(if (color == null) QuoteSpan() else QuoteSpan(color), builderAction)
复制代码

后面会给出这些扩展的效果图,这里先讲下用法,比如前面设置隐私政策超链接的代码比较长,我们可以用 url(url) {...} 扩展进行简化。

textView.text = buildSpannedString {
  append("已同意")
  italic { url("https://xxxxxxxx.com") { append("隐私政策") } }
}
复制代码

其实上面还少了几个扩展,比如 ClickableSpan,虽然可以依葫芦画瓢写个 clickable(onClick) {...} 扩展,但是用起来就很奇怪了。

textView.text = buildSpannedString {
  append("已同意")
  clickable(::onPrivacyPolicyClick) { italic { append("隐私政策") } }
}

private fun onPrivacyPolicyClick() {
  // 跳转 WebViewActivity
}
复制代码

其中的 ::onPrivacyPolicyClick 表达式会让不少人很困惑为什么这么写,代码可读性不是很好,能用但不太好用。个人又思考了很久还有没更好的使用方式,最后想到了一个妙计,把 ClickableSpanappend(text) 一起封装了:

fun SpannableStringBuilder.appendClickable(
  text: CharSequence?,
  @ColorInt color: Int? = null,
  isUnderlineText: Boolean = true,
  onClick: (View) -> Unit
): SpannableStringBuilder = inSpans(ClickableSpan(color, isUnderlineText, onClick)) { append(text) }

fun ClickableSpan(
  @ColorInt color: Int? = null,
  isUnderlineText: Boolean = true,
  onClick: (View) -> Unit,
): ClickableSpan = object : ClickableSpan() {
  override fun onClick(widget: View) = onClick(widget)

  override fun updateDrawState(ds: TextPaint) {
    ds.color = color ?: ds.linkColor
    ds.isUnderlineText = isUnderlineText
  }
}
复制代码

添加文字的时候顺便把点击事件一起设置了,Lambda 表达式很自然的挪到了后面,这样使用起来更加简洁。

textView.text = buildSpannedString {
  append("已同意")
  italic {  
    appendClickable("隐私政策") {
      // 跳转 WebViewActivity
    } 
  }
}
复制代码

由于 core-ktx 还有 toSpannable() 的用法,个人在前面还封装了一个 ClickableSpan() 函数。通常函数名大写开头会有警告,而这并没有提示,因为函数名是返回值的类名,可以理解为另外声明了一个 ClickableSpan() 的扩展函数,Kotlin 是支持这么用的。

原来的 ClickableSpan() 代码会比较多:

val spannable = "已同意隐私政策".toSpannable()
spannable[3, 7] = object : ClickableSpan() {
  override fun onClick(widget: View) {
    // 跳转 WebViewActivity
  }
  
  override fun updateDrawState(ds: TextPaint) {
    super.updateDrawState(ds)
    ds.isUnderlineText = false // 不要下划线
  }
}
复制代码

封装后就很简洁了。

val spannable = "已同意隐私政策".toSpannable()
spannable[3, 7] = ClickableSpan(isUnderlineText = false) { // 不要下划线
  // 跳转 WebViewActivity
}
复制代码

剩下的还有 ImageSpan 没封装,需要把一段文字替换成图片,同理可以和 append(text) 一起封装,替换掉一段固定的文字。

private const val IMAGE_SPAN_TEXT = "<img/>"

fun SpannableStringBuilder.append(
  context: Context,
  @DrawableRes resourceId: Int
): SpannableStringBuilder = inSpans(ImageSpan(context, resourceId)) { append(IMAGE_SPAN_TEXT) }
复制代码
textView.text = buildSpannedString {
  append(context, R.drawable.ic_delete)
}
复制代码

最终效果

以上的封装代码都能在个人的 Kotlin 工具库 Longan 找到,可以直接添加依赖进行使用。目前有超过 500 个常用方法或属性,能有效提高开发效率

allprojects {
    repositories {
        // ...
        maven { url 'https://www.jitpack.io' }
    }
}
复制代码
dependencies {
    implementation 'androidx.core:core-ktx:1.7.0'
    implementation 'com.github.DylanCaiCoding.Longan:longan:1.1.1'
}
复制代码

也可以拷贝一个文件去使用。下面是完整的 Span 文档,包含了 Span 类和对应扩展的效果。

Span 文档

下面用一句《蜗牛》的歌词作为效果展示。

  • 字符级 Span

image.png

  • 段落级 Span

image.png

实战

最后我们来实现一个仿微博的展开收起效果。

GIF.gif

展开和收起按钮都是接在文字末尾的,只能用富文本来实现。我们来看下实现代码:

fun TextView.setExpandableText(content: CharSequence, maxLine: Int, expandText: String, shrinkText: String, isExpand: Boolean = false) {
  viewTreeObserver.addOnGlobalLayoutListener(object : ViewTreeObserver.OnGlobalLayoutListener {
    override fun onGlobalLayout() {
      viewTreeObserver.removeOnGlobalLayoutListener(this)
      val availableWith = width - compoundPaddingLeft - compoundPaddingRight
      val layout = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) {
        StaticLayout.Builder.obtain(content, 0, content.length, paint, availableWith).build()
      } else {
        @Suppress("DEPRECATION")
        StaticLayout(content, 0, content.length, paint, availableWith, Layout.Alignment.ALIGN_NORMAL, 1.0f, 0f, false)
      }
      if (layout.lineCount > maxLine) {
        val lastLineStart = layout.getLineStart(maxLine - 1)
        val ellipsize = content.subSequence(0, lastLineStart).toString() +
            TextUtils.ellipsize(
              content.subSequence(lastLineStart, content.length), paint, availableWith - paint.measureText(expandText), TextUtils.TruncateAt.END
            )
        setText(isExpand, ellipsize)
        movementMethod = LinkMovementMethod.getInstance()
        transparentHighlightColor()
      } else {
        text = content
      }
    }

    private fun setText(isExpand: Boolean, ellipsize: CharSequence) {
      text = buildSpannedString {
        append(if (isExpand) content else ellipsize)
        appendClickable(if (isExpand) shrinkText else expandText, isUnderlineText = false) {
          setText(!isExpand, ellipsize)
        }
      }
    }
  })
}
复制代码

前面的很多代码是用 StaticLayout 计算折叠后的几行文字有什么内容,有兴趣的自行了解一下。

我们主要看下面的一个函数,最开始的时候个人写了不少代码来实现点击切换原内容或折叠内容,而封装后仅用了 8 行代码就能实现了,非常舒服~

private fun setText(isExpand: Boolean, ellipsize: CharSequence) {
  text = buildSpannedString {
    append(if (isExpand) content else ellipsize)
    appendClickable(if (isExpand) shrinkText else expandText, isUnderlineText = false) {
      setText(!isExpand, ellipsize)
    }
  }
}
复制代码

之后设置一下展开文字即可。

textView.setExpandableText(shuDaoNanStr, 3, "展开", "收起")
复制代码

总结

本文介绍了 Android 富文本 Span 的基础用法,还介绍官方 core-ktx 库的 DSL 用法。在分析了两种 DSL 使用方式的优劣后,选择基于 core-ktx 库的 DSL 用法进行封装,补充了更多的 Span 扩展,使其更加简洁易用。如果觉得封装麻烦,可以使用个人封装的 Kotlin 工具库 Longan

关于我

一个兴趣使然的程序“工匠”  。有代码洁癖,喜欢封装,对封装有一定的个人见解,有不少个人原创的封装思路。GitHub 有分享一些帮助搭建开发框架的开源库,有任何使用上的问题或者需求都可以提 issues 或者加我微信直接反馈。

讲解封装思路的文章

分类:
Android
标签:
收藏成功!
已添加到「」, 点击更改