编辑 | 显示 |
---|---|
![]() |
![]() |
demo地址在文末。
首先要知道两个知识点(以下内容都基于这两个知识点):
方式1 思路简单清晰但同时我也遇到好多坑。以下的内容都需要了解开篇说的那两个知识点,不然可能看不太懂。
有些Span是没有OnClick()接口的比如ImageSpan。解决方法有两个
第一种方法没有什么好讲的,说一下第二种:
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)
并且模仿 links 给 imageSpans 添加了相应的逻辑。
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
关于这个问题,我尝试在写一个类 ClickableImageSpan 这个类继承于ImageSpan 然后重写它的 draw() 函数。
在重写 draw() 过程中我尝试两种方法实现圆角图片
val path = Path()
path.reset()
path.addRoundRect(rectF, 30f, 30f, Path.Direction.CCW)
canvas.clipPath(path)
drawable.draw(canvas)
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()的逻辑
}
}
直接一行代码
text = Html.toHtml(editText.editableText)
save(text)
这个没什么坑,正常继承Html.ImageGetter,实现它的抽象函数 getDrawable 就行了。
前面我们把Span转成Html文本保存好了,显示再TextView的话,正常来说就一句代码:
textView.text = Html.fromHtml(htmlText)
Html.fromHtml()可以把html文本解析并拼接成Span,我们把它直接设置到textView就行。只不过坑的地方就在于:它能够解析的html元素和属性都不多。
官方应该也考虑到了这个问题,它会将无法解析的标签传到Html.TagHandler里面,我们只要继承这个类,重写它的函数处理无法识别的标签就行。
至于怎么做呢,思路我是参考这篇博客的。传送门
我主要的目的是自定义TagHandler处理两个内容:
下面贴出代码:
首先把之前保存的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的方式。所以其实这两种方式实现富文本都是殊途同归。
为了方便使用,基于上文思路,写了一适用于AndroidX的demo【RichTextX】, 有兴趣可以给个star,传送门:https://github.com/shine56/RichTextX