携手创作,共同成长!这是我参与「掘金日新计划 · 8 月更文挑战」的第1天,点击查看活动详情
本文已授权
[郭霖]
公众号独家发布
前言
现在的 App 基本都需要同意用户协议和隐私政策,通常会用富文本 Span 来实现局部点击。而 Span 的代码经常会写一大堆,如何封装简化代码个人思考了很久。最终选择基于官方的 core-ktx 库进行封装,实现一套完整好用的 Span API,并且会给出每个 Span 对应的效果图。
接下来和大家分享个人的封装思路和最终实现的效果。
基础用法
我们用一个同意隐私协议的需求作为例子,这里假设“隐私协议”不仅需要局部点击,还要加斜体,效果如下:
要实现这个效果也不难,稍微学习下 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 的扩展太少了,仅有 italic
、bold
、underline
等 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
表达式会让不少人很困惑为什么这么写,代码可读性不是很好,能用但不太好用。个人又思考了很久还有没更好的使用方式,最后想到了一个妙计,把 ClickableSpan
和 append(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
- 段落级 Span
实战
最后我们来实现一个仿微博的展开收起效果。
展开和收起按钮都是接在文字末尾的,只能用富文本来实现。我们来看下实现代码:
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 或者加我微信直接反馈。
- 掘金:juejin.cn/user/419539…
- GitHub:github.com/DylanCaiCod…
- 微信号:DylanCaiCoding