Android TextView SpannableString 如何插入自定义View

前言

在Android中实现富文本可以使用SpannableString来实现,它能满足我们大部分的需求,改变字体颜色、加链接、加图标等等。或者通俗的讲,我们可以在TextView中单独实现更换部分字体颜色,插入图片等操作。那么如果我要在TextView中插入自定义View呢?

之前也简单介绍过SpannableString juejin.cn/post/719843...

为什么会在TextView中插入自定义View,这一开始就是一个很奇怪的功能。如果让我们正常去做,我们一般都可以用 TextView + CustomView + TextView这样把文档拆两段TextView去实现。

但是因为一些原因,会限制TextView做拆分,比如要做国际化,拆分在某种情况下就不好进行兼容。比如我自定义View后面的text内容需要换行,如果你使用上面的方法来做,最终的效果会是

后面的TextView换行无法接着最左边的位置进行。

简单的实现方案

最初我看到这个问题,想到了两种方法来解决,一种就是用FlowLayout的方式,做流式布局,那上面的情况就会变成

最多只会在自定义View的右边留出间距,相对于上面的显示肯定是好多了。

第二种方式就是用SpannableString去实现,虽然SpannableString在现成的API中没有能在TextView中插入View的方式,但是有插入图片,而我又有办法把控件画成图片(像截屏之类的都会用到这类技术),我把控件画成drawable,再通过ImageSpan加入到SpannableString不就行了?完美

然后就拉上产品设计讨论这个实现方案,然后设计说"啊?我这个控件有可能会有动画"

我当时的心情

SpannableString插入自定义View

如果会存在动图的情况,那把view画成drawable的方式自然不好实现。那么还是得把注意力放在SpannableString怎么实现插入自定义View

这时有个同事说能否仿照ImageSpan去做自定义,我就按着他的这个方向去探索

可以看到ImageSpan的源码是继承DynamicDrawableSpan,DynamicDrawableSpan继承ReplacementSpan,然后是在draw方法中去把图片的drawable画到canvas的,我第一眼看这个canvas就感觉是TextView的canvas,那么方向来了,可以先了解下这个ReplacementSpan

我们了解到ReplacementSpan能做一个替换TextView中的某个位置,核心有两个方法,getSize来设置你要占位的宽度,draw是做填充你的内容。

(基础知识)我们知道,View是可以添加到canvas中的,在draw方法中又能拿到canvas,那这件事不就好办了(注意:这里先不考虑动画的情况,一步步来,得先实现插入自定义View,再去进一步探讨插入会动的自定义View)

按照上面的思路我们写个自定义ReplacementSpan

kotlin 复制代码
class CustomViewSpanOld(private val customView: View) : ReplacementSpan() {
    private val width: Int
    private val height: Int

    init {
        width = customView.measuredWidth
        height = customView.measuredHeight
    }

    override fun getSize(
        paint: Paint,
        text: CharSequence,
        start: Int,
        end: Int,
        fm: FontMetricsInt?
    ): Int {
        return width // 返回自定义 View 的宽度
    }

    override fun draw(
        canvas: Canvas,
        text: CharSequence,
        start: Int,
        end: Int,
        x: Float,
        top: Int,
        y: Int,
        bottom: Int,
        paint: Paint
    ) {
        // 绘制自定义 View
        canvas.save()
        canvas.translate(x, top.toFloat())
        customView.draw(canvas)
        canvas.restore()
    }
}

然后写个Demo看看效果

ini 复制代码
val customView: View = LayoutInflater.from(this).inflate(R.layout.test_view2, null)
val customViewSpan = CustomViewSpanOld(customView)
val spannableString = SpannableString("一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二...")
spannableString.setSpan(  
    customViewSpan,  
    3,  
    4,  
    Spanned.SPAN_EXCLUSIVE_EXCLUSIVE  
)
tv.text = spannableString

test_view2是这样

最终看看效果

可以发现TextView中的间隙是腾出来了,但是并没有显示控件。说明getSize()生效了,但是draw()没生效,原因是这个控件没有被绘制出来,可以看看View.draw(canvas)的源码,看到里面只调用了draw方法(如果我没看错的话),没有执行正常的绘制流程。

我们需要让该View执行正常的绘制流程,最简单的方法就是addView()添加root链上的viewgroup中

调用就

css 复制代码
val ll_content : LinearLayout = findViewById(R.id.ll_content)
ll_content.addView(customView)

我这里写一个LinearLayout放在TextView的底层,而且只有1dp,这样就能保证addView也不会在TextView能看到,可以看看效果(高度的问题后面说,先能看到view能正常展示出来)

这里再演示一下为什么这个viewgroup要用1dp,假如我设置成自适应

能看到显示了两次,左边那个其实就是addView显示的,右边的就是在CustomViewSpanOld中customView.draw(canvas)显示的。所以要把viewgroup设置成1dp(这个其实有点像那个方框的验证码的思路(方框底层暗藏一个EditText))

OK,接下来我们看看高度的问题,可以看到当View高度超过TextView的行高度时,也是直接显示完,因为是draw到canvas嘛,那怎么进行处理呢?我们可以参照ImageSpan,如果你用ImageSpan来测试一个高度较大的图,是能看到行高度会自适应的(这里就不做演示了)

可以看源码发现是在这里取设置行高度自适应

那我们直接抄过来,代码改成

kotlin 复制代码
override fun getSize(
    paint: Paint,
    text: CharSequence,
    start: Int,
    end: Int,
    fm: FontMetricsInt?
): Int {
    // 设置行间距
    if (fm != null) {
        fm.ascent = -height
        fm.descent = 0
        fm.top = fm.ascent
        fm.bottom = 0
    }

    return width // 返回自定义 View 的宽度
}

看看效果

这样就实现了在TextView中插入自定义View的效果,最后把源码贴出来

kotlin 复制代码
class CustomViewSpanOld(private val customView: View) : ReplacementSpan() {
    private val width: Int
    private val height: Int

    init {
//        width = customView.measuredWidth
//        height = customView.measuredHeight
        width = 120  //todo 方便测试这里写死
        height = 260 //todo 方便测试这里写死
    }

    override fun getSize(
        paint: Paint,
        text: CharSequence,
        start: Int,
        end: Int,
        fm: FontMetricsInt?
    ): Int {
        // 设置行间距
        if (fm != null) {
            fm.ascent = -height
            fm.descent = 0
            fm.top = fm.ascent
            fm.bottom = 0
        }

        return width // 返回自定义 View 的宽度
    }

    override fun draw(
        canvas: Canvas,
        text: CharSequence,
        start: Int,
        end: Int,
        x: Float,
        top: Int,
        y: Int,
        bottom: Int,
        paint: Paint
    ) {
        // 绘制自定义 View
        canvas.save()
        canvas.translate(x, top.toFloat())
        customView.draw(canvas)
        canvas.restore()
    }
}

SpannableString 插入带动画效果的自定义View

上面的方式,如果你给ImageView加一个动图webp,会发现并不能达到我们正常ImageView显示动图的效果,原理也很简单,是因为上面的方法也是draw到canvas上的,和最上面说的把view画成drawable再通过ImageSpan去实现的方法有异曲同工。

那么要怎么做呢?这里就是我觉得我的思路比较6的一个地方,给你基础的零件,你通过自己的想法,能把零件拼接成一个很6的东西

我的思路就是,通过自定义ReplacementSpan替代一块空白的区域进去,空白的区域的大小和我们要的自定义View的大小一样,然后把View放到TextView的上一层,再做偏移

这时,我们的布局会变成

ini 复制代码
    <TextView
        android:id="@+id/tv"
        android:layout_width="0dp"
        android:layout_height="wrap_content"
        app:layout_constraintStart_toStartOf="parent"
        app:layout_constraintEnd_toEndOf="parent"
        app:layout_constraintTop_toTopOf="parent"
        />

    <FrameLayout
        android:id="@+id/fl_content"
        android:layout_width="0dp"
        android:layout_height="0dp"
        app:layout_constraintStart_toStartOf="@+id/tv"
        app:layout_constraintEnd_toEndOf="@+id/tv"
        app:layout_constraintTop_toTopOf="@+id/tv"
        app:layout_constraintBottom_toBottomOf="@+id/tv"
        />

</androidx.constraintlayout.widget.ConstraintLayout>

看到加了一层FrameLayout,宽高位置都和TextView一样。然后在draw方法中绘制一块空白的区域

scss 复制代码
canvas.save()
canvas.translate(x, translateTop)
paint.color = Color.TRANSPARENT // 设置为透明
paint.style = Paint.Style.FILL // 填充样式
// 绘制一个矩形,覆盖整个区域
canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), paint)
canvas.restore()
adapter.bind(x.toInt(), top)

View的偏移就更好弄了,直接调用view.translationX和view.translationY

为了兼容上面添加静态的View和这里动态的View的情况,这边的代码直接一次性做了适配。

做一些封装,先写一个Adapter

kotlin 复制代码
class CustomSpanAdapter(viewGroup : ViewGroup, view : View, type : Int = 0 , width : Int = 0, height : Int = 0) {

    companion object {
        const val TYPE_VIEW = 0
        const val TYPE_CUSTOM = 1
    }

    var mViewGroup : ViewGroup
    var mView : View
    var mType : Int = 0
    var mWidth : Int = 0
    var mHeight : Int = 0

    init {
        mViewGroup = viewGroup
        mView = view
        mType = type
        mWidth= width
        mHeight = height
        create()
    }

    private fun create(){
        mViewGroup.addView(mView)
    }

    fun bind(left:Int, top: Int){
        if (mType == TYPE_CUSTOM){
            mView.translationX = left.toFloat()
            mView.translationY = top.toFloat()
        }
    }

    fun getWidth() : Int {
        return if (mWidth > 0) mWidth else mView.measuredWidth ?:0
    }

    fun getHeight() : Int {
        return if (mHeight > 0) mHeight else mView.measuredHeight ?:0
    }

}

这里定义了两个type,0表示静态的类型,1表示动态的类型。

再看看自定义ReplacementSpan

kotlin 复制代码
class CustomViewSpan(private val adapter: CustomSpanAdapter) : ReplacementSpan() {
    private val width: Int = adapter.getWidth()
    private val height: Int = adapter.getHeight()
    val rectPaint = Paint()

    override fun getSize(
        paint: Paint,
        text: CharSequence,
        start: Int,
        end: Int,
        fm: FontMetricsInt?
    ): Int {
        // 设置行间距
        if (fm != null) {
            fm.ascent = -height
            fm.descent = 0
            fm.top = fm.ascent
            fm.bottom = 0
        }

        return width // 返回自定义 View 的宽度
    }

    override fun draw(
        canvas: Canvas,
        text: CharSequence,
        start: Int,
        end: Int,
        x: Float,
        top: Int,
        y: Int,
        bottom: Int,
        paint: Paint
    ) {
        val translateTop: Float =
            if ((bottom - top) > height) {
                // 行间距比控件大的情况
                val transY = (bottom - top - height) / 2
                (top + transY).toFloat()
            } else {
                top.toFloat()
            }

        if (adapter.mType == CustomSpanAdapter.TYPE_VIEW) {
            // 传入自定义View的情况
            canvas.save()
            canvas.translate(x, translateTop)
            adapter.mView.draw(canvas)
            canvas.restore()
            adapter.bind(x.toInt(), top)
        } else {
            // 传入自定义区间
            canvas.save()
            canvas.translate(x, translateTop)
            rectPaint.color = Color.TRANSPARENT // 设置为透明
            rectPaint.style = Paint.Style.FILL // 填充样式
            // 绘制一个矩形,覆盖整个区域
            canvas.drawRect(0f, 0f, width.toFloat(), height.toFloat(), rectPaint)
            canvas.restore()
            adapter.bind(x.toInt(), top)
        }
    }
}

可以看到draw方法中有判断type来做不同的绘制,最后都会调用adapter.bind传左边和顶边的距离,在adapter里面做偏移的操作

如果调用type为1的情况

kotlin 复制代码
val circleCrop = CenterCrop()
val customView: View = LayoutInflater.from(this).inflate(R.layout.test_view2, ll_content, false)
val iv: ImageView = customView.findViewById(R.id.iv)
Glide.with(this)
    .load("............")
    .optionalTransform(circleCrop)
    .optionalTransform(WebpDrawable::class.java, WebpDrawableTransformation(circleCrop))
    .addListener(object : RequestListener<Drawable> {
        override fun onResourceReady(
            resource: Drawable,
            model: Any,
            target: com.bumptech.glide.request.target.Target<Drawable>?,
            dataSource: com.bumptech.glide.load.DataSource,
            isFirstResource: Boolean
        ): Boolean {
            if(resource is WebpDrawable){
                resource.loopCount  = LOOP_FOREVER
            }
            return false
        }

        override fun onLoadFailed(
            e: GlideException?,
            model: Any?,
            target: Target<Drawable>,
            isFirstResource: Boolean
        ): Boolean {
            return false
        }

    })
    .into(iv)
val adapter = CustomSpanAdapter(fl_content, customView, CustomSpanAdapter.TYPE_CUSTOM)

customView.post {
    // 创建并设置 ReplacementSpan
    val customViewSpan = CustomViewSpan(adapter)
    val spannableString = SpannableString("一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十一二三四五六七八九十")
    spannableString.setSpan(
        customViewSpan,
        90,
        91,
        Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
    )

    tv.text = spannableString
}

我这里就是加载了个webp的图片(没有找到网上的webp资源,拿的公司的资源测试,所以和谐一下)

是能正常显示动图的,我这里没法展示实际的效果。感兴趣的可以直接试试。

虽然写了Adapter,但是有个需要注意的点,如果用type==0的方式添加View,此时的ViewGroup需要放到TextView下层,并且设置宽高1像素等方式防止addView的控件也显示。如果用type==1的方式添加View,此时的ViewGroup需要放到自定义View的上层,因为需要让View偏移来覆盖空白区域。

总结

如果view只是纯粹的view,不需要更新数据,没有动画,则可以使用type == 0的方式,否则都算作动态View。

还有一点需要注意,如果View的宽度是自适应,我们需要根据服务端下发的内容去做宽度变化要怎么弄,其实也很简单,在更新View的同时,重新创建CustomViewSpan去让这个TextView去setText就行

相关推荐
androidwork7 小时前
Android LinearLayout、FrameLayout、RelativeLayout、ConstraintLayout大混战
android·java·kotlin·androidx
每次的天空7 小时前
Android第十三次面试总结基础
android·面试·职场和发展
wu_android7 小时前
Android 相对布局管理器(RelativeLayout)
android
李斯维9 小时前
循序渐进 Android Binder(二):传递自定义对象和 AIDL 回调
android·java·android studio
androidwork9 小时前
OkHttp 3.0源码解析:从设计理念到核心实现
android·java·okhttp·kotlin
像风一样自由10 小时前
【001】frida API分类 总览
android·frida
casual_clover10 小时前
Android 之 kotlin 语言学习笔记四(Android KTX)
android·学习·kotlin
移动开发者1号12 小时前
Android 大文件分块上传实战:突破表单数据限制的完整方案
android·java·kotlin
移动开发者1号12 小时前
单线程模型中消息机制解析
android·kotlin
每次的天空14 小时前
Android第十五次面试总结(第三方组件和adb命令)
android