Android实现编辑和显示富文本——Span方式

大概效果

编辑显示
图片名称图片名称

思路

demo地址在文末。

首先要知道两个知识点(以下内容都基于这两个知识点):

  • EditText/TextView可以通过添加Span的方式显示富文本。有些博客已经讲的很好了:传送门
  • EditText/TextView可以解析并显示Html格式的文本传送门

知道这两点后,我大概整理了两个思路。
在这里插入图片描述
我的想法是:

  • 如果你仅仅想实现图文混排,那就选方式2,因为思路简单,只需用到插入Span这个知识就行了。
  • 但当想实现较复杂的富文本时,比如给文本中某一小段实现字号变大,同时加粗,同时斜体时,那么给他添加标签以及解析时就变得复杂。并且这些标签是不能显示在界面的,那么意味着要想办法在用户编辑时进行同步添加/删除标签。关于这点我没有想到很好的解决方法。
  • 综上,我选择了方式1来实现富文本的编辑和显示。

遇到的问题

方式1 思路简单清晰但同时我也遇到好多坑。以下的内容都需要了解开篇说的那两个知识点,不然可能看不太懂。

步骤1的问题一:imageSpan点击事件

有些Span是没有OnClick()接口的比如ImageSpan。解决方法有两个

  • 在相同的位置设置ClickableSpan
  • 自定义LinkMovementMethod

第一种方法没有什么好讲的,说一下第二种:
edittext的Clickable是通过这句代码来实现点击事件的绑定。

editText.movementMethod = LinkMovementMethod.getInstance()

打开LinkMovementMethod的源码,观察一番可以见到老朋友 onTouchEvent()

@Override
public boolean onTouchEvent(TextView widget, Spannable buffer,
                            MotionEvent event) {
    int action = event.getAction();

    if (action == MotionEvent.ACTION_UP || action == MotionEvent.ACTION_DOWN) {
        int x = (int) event.getX();
        int y = (int) event.getY();

        x -= widget.getTotalPaddingLeft();
        y -= widget.getTotalPaddingTop();

        x += widget.getScrollX();
        y += widget.getScrollY();

        Layout layout = widget.getLayout();
        int line = layout.getLineForVertical(y);
        int off = layout.getOffsetForHorizontal(line, x);

        ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);

        if (links.length != 0) {
            ClickableSpan link = links[0];
            if (action == MotionEvent.ACTION_UP) {
                if (link instanceof TextLinkSpan) {
                    ((TextLinkSpan) link).onClick(
                            widget, TextLinkSpan.INVOCATION_METHOD_TOUCH);
                } else {
                    link.onClick(widget);
                }
            } else if (action == MotionEvent.ACTION_DOWN) {
                if (widget.getContext().getApplicationInfo().targetSdkVersion
                        >= Build.VERSION_CODES.P) {
                    // Selection change will reposition the toolbar. Hide it for a few ms for a
                    // smoother transition.
                    widget.hideFloatingToolbar(HIDE_FLOATING_TOOLBAR_DELAY_MS);
                }
                Selection.setSelection(buffer,
                        buffer.getSpanStart(link),
                        buffer.getSpanEnd(link));
            }
            return true;
        } else {
            Selection.removeSelection(buffer);
        }
    }

    return super.onTouchEvent(widget, buffer, event);
}

尽管很多逻辑,但是可以找到关键(有点眼熟)的几句:

...
ClickableSpan[] links = buffer.getSpans(off, off, ClickableSpan.class);
...
if (links.length != 0) {
	ClickableSpan link = links[0];
	if (action == MotionEvent.ACTION_UP) {
    ...
        link.onClick(widget);
    ...
	} else if (action == MotionEvent.ACTION_DOWN) {
	...
        Selection.setSelection(buffer,
                buffer.getSpanStart(link),
                buffer.getSpanEnd(link));
     }
     return true
}

显然这个类是通过这些代码实现Clickable的onCLick()接口的。那么我写了一个类 MyMovementMethod 继承于 LinkMovementMethod,重写它的 onTouchEvent() 函数,当然我们是直接把原函数的代码copy过去然后修改一些地方,修改地方如下:

...
val links = buffer!!.getSpans(off, off, ClickableSpan::class.java)
val imageSpans = buffer!!.getSpans(off, off, ClickableImageSpan::class.java)
...
when {
	links.isNotEmpty() -> {
	    val link = links[0]
	     if (action == MotionEvent.ACTION_UP) {
	         link.onClick(widget)
	     } else if (action == MotionEvent.ACTION_DOWN) {
	         Selection.setSelection(
	             buffer,
	             buffer!!.getSpanStart(link),
	             buffer!!.getSpanEnd(link)
	         )
	     }
	     return true
	 }
	imageSpans.isNotEmpty() -> {
	
	     val link = imageSpans[0]
	
	     if (action == MotionEvent.ACTION_UP) {
	         link.onClick(widget)
	     }else if (action == MotionEvent.ACTION_DOWN) {
	//                        Selection.setSelection(
	//                            buffer,
	//                            buffer!!.getSpanStart(link),
	//                            buffer!!.getSpanEnd(link)
	//                        )
	     }
	     return true
	 }
}

用的kotlin重写,但是可以看到其实就是照Clickable画瓢,模仿它添加了

val imageSpans = buffer!!.getSpans(off, off, ClickableImageSpan::class.java)

并且模仿 linksimageSpans 添加了相应的逻辑。

imageSpans.isNotEmpty() -> {
	
	val link = imageSpans[0]
	
	if (action == MotionEvent.ACTION_UP) {
	   link.onClick(widget)
	}else if (action == MotionEvent.ACTION_DOWN) {
	//                        Selection.setSelection(
	//                            buffer,
	//                            buffer!!.getSpanStart(link),
	//                            buffer!!.getSpanEnd(link)
	//                        )
	}
	return true
}

我注释掉了一部分代码,那部分代码是给图片设置选中状态的,而我不想要这个效果就注释了。

但是有一点我们要知道就是原生的ImageSpan是没有onClick()接口的,那么我们还要写一个类继承于ImageSpan给他添加 onCLick() 函数,可以从上面的代码中看到我使用的是ClickableImageSpan, 这个类是我自己定义的。

class ClickableImageSpan(drawable: Drawable, private val imgUrl: String) :
    ImageSpan(drawable, imgUrl) {

    private var onClick: ((view: View, imgUrl: String) -> Unit)? = null
    
    fun setOnCLickListener(listener: (view: View, imgUrl: String) -> Unit){
        onClick = listener
    }

    fun onClick(view: View){
        onClick?.invoke(view, imgUrl)
    }
}

逻辑很短,就是用lambda添加一个onClick().,这样我们就可以在插入ImageSpan是这样调用:

val imageSpan = ClickableImageSpan(mDrawable, imagePath)
val spannableString = SpannableString(imagePath)
spannableString.setSpan(
    imageSpan,
    0,
    spannableString.length,
    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
)
            
editText.setText(spannableString)
//点击事件          
imageSpan.setOnCLickListener{ view, imgUrl ->
	//onClick()的逻辑
}

当然记得设置自己定义的

editText.movementMethod = MyMovementMethod.instance
步骤1的问题二 圆角图片

关于这个问题,我尝试在写一个类 ClickableImageSpan 这个类继承于ImageSpan 然后重写它的 draw() 函数。
在重写 draw() 过程中我尝试两种方法实现圆角图片

  • 直接裁剪画布为圆角矩形画布
val path = Path()
path.reset()
path.addRoundRect(rectF, 30f, 30f, Path.Direction.CCW)
canvas.clipPath(path)
drawable.draw(canvas)
  • 设置paint的着色器,在画布上画出圆角矩形图片
val mBitmapShader = BitmapShader(bitmap, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP)
paint.setShader(mBitmapShader)
canvas.drawRoundRect(rectF, 30f, 30f, paint)

然而两种方法都出现一个问题,图片确实变原角了,但是插入多张图片时,从第二张图片开始就不会正常显示图片,会被拉伸。我尝试一点一点扣源码,还是没找到完备的解决方法。

最后的解决方法是:不在ImageSpan里面处理(无法解决问题就避免问题…尬笑)
而是在图片传入ImageSpan之前就把它处理成圆角,我直接用Glide处理,同时防止图片过大而OOM,一举两得。

不知道怎么利用Glide裁剪圆角:传送门

Glide获取裁剪之后的bitmap或者drawable是耗时操作需要异步的,我用的kotlin的协程来实现这部分逻辑关于kotlin协程传送门

还是贴一下简单示例吧

val job = Job()
val scope = CoroutineScope(job)
//预处理图片
val deferred = scope.async(Dispatchers.IO) {
	Glide.with(editText.context)
		.load(bitmap)
		.transform(MyTransform(20f, editText.context, true))
		.submit()
		.get()
}
//设置到edit
scope.launch(Dispatchers.Main) {
	val imageSpan = ClickableImageSpan(deferred.await(), imagePath)
	val spannableString = SpannableString(imagePath)
	spannableString.setSpan(
	    imageSpan,
	    0,
	    spannableString.length,
	    Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
	)
	            
	editText.setText(spannableString)
	//点击事件          
	imageSpan.setOnCLickListener{ view, imgUrl ->
		//onClick()的逻辑
	}
}
步骤2没有什么问题

直接一行代码

text = Html.toHtml(editText.editableText)
save(text)
步骤3问题一:自定义Html.ImageGetter

这个没什么坑,正常继承Html.ImageGetter,实现它的抽象函数 getDrawable 就行了。

步骤3问题二:自定义Html.TagHandler

前面我们把Span转成Html文本保存好了,显示再TextView的话,正常来说就一句代码:

textView.text = Html.fromHtml(htmlText)

Html.fromHtml()可以把html文本解析并拼接成Span,我们把它直接设置到textView就行。只不过坑的地方就在于:它能够解析的html元素和属性都不多

官方应该也考虑到了这个问题,它会将无法解析的标签传到Html.TagHandler里面,我们只要继承这个类,重写它的函数处理无法识别的标签就行。
至于怎么做呢,思路我是参考这篇博客的。传送门

我主要的目的是自定义TagHandler处理两个内容:

  • 字号大小,原因:Html.fromHtml()无法解析 font-size
  • 图片, 原因:Html.fromHtml()解析拼接的是ImageSpan,而我需要的是自己定义可点击的ClickableImageSpan

下面贴出代码:

首先把之前保存的html的文本取出来,然后预处理一下,将"span"和"img",换成Html.fromHtml()肯定不会被处理的标签,这样才会传到我们自己定义的TagHandler

text = text.replace("span", "myspan")
text = text.replace("img", "myimg")
class CustomTagHandler(
    private val mImageGetter: ImageGetter,
) : Html.TagHandler {
	
    private val attributes: HashMap<String, String> = HashMap()
    private var startIndex = 0
    private var stopIndex = 0

	//lambda实现图片的onClick
    private var onClick: ((view: View, source: String) -> Unit)? = null
    fun setOnCLickListener(listener: (view: View, imgUrl: String) -> Unit){
        onClick = listener
    }

    override fun handleTag(
        opening: Boolean, tag: String, output: Editable, xmlReader: XMLReader
    ) {
        processAttributes(xmlReader)
        if (tag == "myspan" || tag == "myimg") {
            if (opening) {
                startSpan(tag, output)
            } else {
                endSpan(output)
                attributes.clear()
            }
        }
    }

    private fun startSpan(
        tag: String,
        output: Editable
    ) {
        startIndex = output.length
        if (tag.equals("myimg", ignoreCase = true)) {
            startImg(output, mImageGetter)
        }
    }

    private fun endSpan( output: Editable) {
        stopIndex = output.length
        var size = attributes["size"]
        val style = attributes["style"]
        if (!TextUtils.isEmpty(style)) {
            analysisStyle(startIndex, stopIndex, output, style)
        }
        if (!TextUtils.isEmpty(size)) {
            size = size!!.split("px".toRegex()).toTypedArray()[0]
        }
        if (!TextUtils.isEmpty(size)) {
            val fontSizePx = size!!.toInt()
            output.setSpan(
                AbsoluteSizeSpan(fontSizePx, true),
                startIndex,
                stopIndex,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }
    }

    private fun startImg(text: Editable, img: ImageGetter) {
        val src = attributes["src"]

        val d = img.getDrawable(src)
        val imageSpan = ClickableImageSpan(d, src?:"null")

        val spannableString = SpannableString(src)
        spannableString.setSpan(
            imageSpan,
            0,
            spannableString.length,
            Spannable.SPAN_EXCLUSIVE_EXCLUSIVE
        )
        text.append(spannableString)

        onClick?.let {
            imageSpan.setOnCLickListener (it)
        }
    }

    private fun processAttributes(xmlReader: XMLReader) {
        try {
            val elementField =
                xmlReader.javaClass.getDeclaredField("theNewElement")
            elementField.isAccessible = true
            val element = elementField[xmlReader]
            val attsField = element.javaClass.getDeclaredField("theAtts")
            attsField.isAccessible = true
            val atts = attsField[element]
            val dataField = atts.javaClass.getDeclaredField("data")
            dataField.isAccessible = true
            val data =
                dataField[atts] as Array<String>
            val lengthField = atts.javaClass.getDeclaredField("length")
            lengthField.isAccessible = true
            val len = lengthField[atts] as Int
            /**
             * MSH: Look for supported attributes and add to hash map.
             * This is as tight as things can get :)
             * The data index is "just" where the keys and values are stored.
             */
            for (i in 0 until len) attributes[data[i * 5 + 1]] = data[i * 5 + 4]
        } catch (e: Exception) {
        }
    }

    /**
     * 解析style属性
     * @param startIndex
     * @param stopIndex
     * @param editable
     * @param style
     */
    private fun analysisStyle(
        startIndex: Int,
        stopIndex: Int,
        editable: Editable,
        style: String?
    ) {
        val attrArray = style?.split(";".toRegex())?.toTypedArray()
        val attrMap: MutableMap<String, String> =
            HashMap()
        if (null != attrArray) {
            for (attr in attrArray) {
                val keyValueArray = attr.split(":".toRegex()).toTypedArray()
                if (keyValueArray.size == 2) {
                    // 去除前后空格
                    attrMap[keyValueArray[0].trim { it <= ' ' }] =
                        keyValueArray[1].trim { it <= ' ' }
                }
            }
        }

        var fontSize = attrMap["font-size"]
        if (!TextUtils.isEmpty(fontSize)) {
            fontSize = fontSize!!.split("px".toRegex()).toTypedArray()[0]
        }
        if (!TextUtils.isEmpty(fontSize)) {
            val fontSizePx = fontSize!!.toInt()

            editable.setSpan(
                AbsoluteSizeSpan(fontSizePx, true),
                startIndex,
                stopIndex,
                Spanned.SPAN_EXCLUSIVE_EXCLUSIVE
            )
        }
    }
}

使用时

text = text.replace("span", "myspan")
text = text.replace("img", "myimg")

val tagHandler = CustomTagHandler(MyImageGetter(this))
tagHandler.setOnCLickListener { view, imgUrl ->
	//onClick()逻辑
}
writeEdit.setText(Html.fromHtml(text, null, tagHandler))

最后说一下,看了源码可以发现textView用Html.fromHtml()方式去显示html文本,最后也是通过设置Span的方式。所以其实这两种方式实现富文本都是殊途同归。

RichTextX

为了方便使用,基于上文思路,写了一适用于AndroidX的demo【RichTextX】, 有兴趣可以给个star,传送门:https://github.com/shine56/RichTextX

  • 1
    点赞
  • 11
    收藏
    觉得还不错? 一键收藏
  • 1
    评论
评论 1
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值