Android 图文混排方式分享
在开发过程中,我们经常会遇到在一段文字中插入提示性或展示性图片需求,如显示带图标的提示语,
或者显示tab标签页,
Android官方对TextView的图文混排提供了支持,我们可以从以下四种方式实现TextView的图文混排:
-
View+ImageView
首先,便是最简单的实现方式直接使用两个控件,一个ImageView用于显示图片 + 一个TextView用于显示文字,然后把他们丢到一个LinearLayout中,xml实现如下:
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center">
<ImageView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:src="@mipmap/ic_warning" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="这里是提示语" />
</LinearLayout>
效果如图:
若需实现场景二,一般通过结合RecyclerView和adpter来实现,比较简单,这里不进行代码展示。
考虑到层级和资源使用情况,一般不推荐使用该方案。
-
Compound Drawable
在TextView中使用Compound Drawable属性可以在文字的上下左右放置drawable,相较方案一而言,View个数更少,层级更少,是优化层级的常用方法。Compound Drawable的xml属性如下:
参数 | 说明 |
android:drawableLeft | 在文字左边设置图片 |
android:drawableTop | 在文字上边设置图片 |
android:drawableRight | 在文字右边设置图片 |
android:drawableBottom | 在文字下边设置图片 |
android:drawableStart | API 17后生效,LTR时在左边,RTL时在右边 |
android:drawableEnd | API 17后生效,LTR时在右边,RTL时在左边 |
android:drawablePadding | 图片和文字的间距 |
xml实现如下:
<TextView
android:id="@+id/tvTest"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:drawableTop="@mipmap/ic_warning"
android:drawableLeft="@mipmap/ic_warning"
android:drawableRight="@mipmap/ic_warning"
android:drawableBottom="@mipmap/ic_warning"
android:drawablePadding="10dp"
android:text="这里是提示语" />
实现效果如图:
- 遇到的一些问题:
- 这样设置的drawable并不能自行设置大小,在XML是无法直接设置的?
解决方法:此时需要在代码中对drawable进行修改,如下:
val drawable: Array<Drawable> = tv.compoundDrawables
// 数组下表0~3,依次是:左上右下
// 数组下表0~3,依次是:左上右下
drawable[1].setBounds(0, 0, 50, 200)
tv.setCompoundDrawables(drawable[0], drawable[1], drawable[2], drawable[3])
实现效果如图:
代码分析:
- Drawable[] drawable = txtZQD.getCompoundDrawables( ); 获得四个不同方向上的图片资源,数组元素依次是:左上右下的图片
- drawable[1].setBounds(100, 0, 200, 200); 接着获得资源后,可以调用setBounds设置左上右下坐标点,比如这里设置了代表的是: 长是:从离文字最左边开始0dp处到50dp处 宽是:从文字上方0dp处往上延伸200dp!
- tv.setCompoundDrawables(drawable[0], drawable[1], drawable[2], drawable[3]);为TextView重新设置drawable数组,没有图片可以用null代替。
- 另外,从上方代码可以看出,我们能够直接在代码中使用setCompoundDrawables为 TextView设置图片。
- 当TextView设置成固定大小时,由于文字距离边界的距离过大,会导致文字与图片之间设置的间距无效?
解决方法1:
通过设置paddingLeft、paddingRight、paddingTop、paddingBottom来缩写这个间距。
解决方法2:
- 先自定义属性iconPadding来设置间距,并提供方法给外部调用。
- 重写setCompoundDrawablesWithIntrinsicBounds()方法来获取我们设置的drawable宽高。
- 最后重写onLayout方法。
-
Html富文本
对于存在多行文字,且只需要图片在第一行显示时,便无法使用方案二了,此时方案二的显示效果如图,与需求不符:
或者在需要将图片嵌入在文字内的时候,方案二也无法实现。这时需要考虑使用其他的方案,如HTml富文本。Android 中的 TextView组件除了可以显示文本外,还可以用于显示Html富文本。
但需要注意的是,并不是所有的 HTML 标签在 TextView 中都是支持的,且官方文档并没有明确的说明支持 HTML 标签列表,通过查看 Android 源代码,可以得到简单的支持列表如下:
<br>,< p>,< div align=>,< strong>, <b>, <em>, <cite>, <dfn>, <i>, <big>, <small>, <font size=>, <font color=>, <blockquote>, <tt>, <a href=>,<u>, <sup>, <sub>, <h1>,<h2>,<h3>,<h4>,<h5>,<h6>, <img src=>, <strike>
在一些情况下,TextView中含有图片和链接,此时便推荐使用富文本来处理。本分享仅展示图片插入,代码实现如下:
val s1 = "<img src = 'ic_launcher_round'/>这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语这里是提示语"
tv.text = Html.fromHtml(s1, { source ->
var draw: Drawable? = null
try {
val field = R.mipmap::class.java.getField(source)
val resourceId: Int = field.get(null)?.toString()?.toInt() ?: 0
draw = resources.getDrawable(resourceId)
draw.setBounds(0, 0, draw.intrinsicWidth, draw.intrinsicHeight)
} catch (e: Exception) {
e.printStackTrace()
}
draw!!
}, null)
效果如图:
代码分析:
- fromHtml方法
fromHtml(String source, ImageGetter imageGetter,TagHandler tagHandler)
- source:就是包含 HTML 内容的字符串。Html.ImageGetter 和 Html.TagHandler 是两个接口,提供给开发者继承使用。
- imageGetter: 如果要显示图片是需要被继承的,重写 getDrawable(String source)方法,用于获取 HTML 里面的图片来显示在 TextView 中。
- tagHandler:其作用是把 HTML 带标记的文本内容字符串转化成可以显示效果的 Spanned 字符串 。由于并非所有的 HTML 标签都可以转化,所以在使用时,用户需要自己添加一些必要的标签和处理方法时才会继承使用的。
- ImageGetter
继承于 ImageGetter,重写 getDrawable (String source) 方法。通过异步操作,读取本地/网络资源,获得drawable对象。
- 继承TagHandler
继承于 TagHandler,重写了 handleTag()方法。为了支持更多的标签,例如为了支持<ul><ol><dd>和<li>标签,这四个标签是在 formHtml()方法中本身是不支持。
若认为TagHandler 提供的默认标签解析已经够用,直接在 fromHtml()方法中第三个参数的地方填写 null 既可。
- 最后,通过 formHtml()方法将 HTML 内容转化为可供显示的 SpannableString,将 SpannableString 通过 setText 方法放入 TextView 中,就可以显示图文并茂的内容了
扩展:
Android 提供了 LinkMovementMethod 类以实现了对于文本内容中超链接的遍历,并且支持对于超链接的点击事件。
所以只要在添加下面一行代码,就可以使点击 UrlSpan 能够触发打开链接的功能。
tv.movementMethod = LinkMovementMethod.getInstance()
-
ImageSpan
除了上面的HTML可以定制我们TextView的样式外,还可以使用SpannableString来完成。
一般来说我们会使用setText为TextView设置Strign类型的内容,而setText(CharSequence text)中接收的是CharSequence,其接口如下。
了解到Spannable接口,其中定义了两个抽象类:
其中setSpan()方法包含如下参数:
参数 | 参数说明 |
what | span样式 |
start | 样式开始的索引 |
end | 样式结束的索引 |
flags | 样式作用的范围 |
而其中提到的flags通常指以下四种:
- Spanned.SPAN_EXCLUSIVE_EXCLUSIVE(前后都不包括);
- Spanned.SPAN_INCLUSIVE_EXCLUSIVE(前面包括,后面不包括);
- Spanned.SPAN_EXCLUSIVE_INCLUSIVE(前面不包括,后面包括);
- Spanned.SPAN_INCLUSIVE_INCLUSIVE(前后都包括)。
通常使用的是Spanned.SPAN_INCLUSIVE_EXCLUSIVE,这样我们就可以比较轻松地使用String的length()方法来得到end的位置。
而SpannableString和SpannableStringBuilder是其实现类,是可以直接赋值的。并且两者的setSpan()方法可以设置一些格式对象(例如字体大小、下划线、替换为图片等),这就可以实现富文本了。
图片插入的实现方式如下:
private fun imgSpan(content: String): SpannableString {
val str = " $content"
val spanString = SpannableString(str)
val d: Drawable = AppCompatResources.getDrawable(this, R.mipmap.ic_warning)
?: return spanString
val imgSize = resources.getDimension(R.dimen.img_size).toInt()
d.setBounds(0, 0, imgSize, imgSize)
val span = ImageSpan(d, ImageSpan.ALIGN_BOTTOM)
spanString.setSpan(span, 0, 1, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE)
return spanString
}
实现效果如图:
代码分析:
- 创建一个SpannableString对象,提前预留需要替换为图片的位置;
- 利用setSpan(Object what, int start, int end, int flags)方法,将SpannableString对象预留位置的内容替换为具体类型的Span;
- 利用TextView的setText(CharSequence text)方法将SpannableString对象进行展示。
- 遇到的问题:
- 其中ImageSpan默认对其方式有两种:ALIGN_BOTTOM及ALIGN_BASELINE,那该如何实现图片文字垂直居中?
解决方案:使用自定义的ImageSpan类,只需重写它的draw函数,代码如下:
class CenteredImageSpan(context: Context?, drawableRes: Int) : ImageSpan(context, drawableRes) {
override fun draw(
canvas: Canvas, text: CharSequence?,
start: Int, end: Int, x: Float,
top: Int, y: Int, bottom: Int, paint: Paint
) {
// image to draw
val b = drawable
// font metrics of text to be replaced
val fm: FontMetricsInt = paint.getFontMetricsInt()
val transY: Int = ((y + fm.descent + y + fm.ascent) / 2
- b.bounds.bottom / 2)
canvas.save()
canvas.translate(x, transY)
b.draw(canvas)
canvas.restore()
}
}
代码分析:
- 形参:
x,要绘制的image的左边框到textview左边框的距离。
y,要替换的文字的基线坐标,即基线到textview上边框的距离。
top,替换行的最顶部位置。
bottom,替换行的最底部位置。注意,textview中两行之间的行间距是属于上一行的,所以这里bottom是指行间隔的底部位置。
paint,画笔,包含了要绘制字体的度量信息。
- 实现:
getDrawable获取要绘制的image,getBounds是获取包裹image的矩形框尺寸;
y + fm.descent得到字体的descent线坐标;
y + fm.ascent得到字体的ascent线坐标;
两者相加除以2就是两条线中线的坐标;
b.getBounds().bottom是image的高度(试想把image放到原点),除以2即高度一半;
前面得到的中线坐标减image高度的一半就是image顶部要绘制的目标位置;
最后把目标坐标传递给canvas.translate函数。
扩展:
SpannableString可供我们使用的API较多,如下:
常用类 | 说明 |
BackgroundColorSpan | 背景色 |
ClickableSpan | 文本可点击,有点击事件 |
ForegroundColorSpan | 文本颜色(前景色) |
MaskFilterSpan | 修饰效果,如模糊(BlurMaskFilter)、浮雕(EmbossMaskFilter) |
MetricAffectingSpan | 父类,一般不用 |
RasterizerSpan | 光栅效果 |
StrikethroughSpan | 删除线(中划线) |
SuggestionSpan | 相当于占位符 |
UnderlineSpan | 下划线 |
AbsoluteSizeSpan | 绝对大小(文本字体) |
DynamicDrawableSpan | 设置图片,基于文本基线或底部对齐 |
ImageSpan | 图片 |
RelativeSizeSpan | 相对大小(文本字体) |
ReplacementSpan | 父类,一般不用 |
ScaleXSpan | 基于x轴缩放 |
StyleSpan | 字体样式:粗体、斜体等 |
SubscriptSpan | 下标(数学公式会用到) |
SuperscriptSpan | 上标(数学公式会用到) |
TextAppearanceSpan | 文本外貌(包括字体、大小、样式和颜色) |
TypefaceSpan | 文本字体 |
URLSpan | 文本超链接 |
这里给出一个较为简单的例子。
val span = SpannableString("红色百度斜体删除线蓝色下划线图片:.")
//1.设置背景色,setSpan时需要指定的flag,Spanned.SPAN_EXCLUSIVE_EXCLUSIVE(前后都不包括)
span.setSpan(ForegroundColorSpan(Color.RED), 0, 2, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//2.用超链接标记文本
span.setSpan(URLSpan("https://www.baidu.com"), 2, 4, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//3.用样式标记文本(斜体)
span.setSpan(StyleSpan(Typeface.BOLD_ITALIC), 4, 6, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//4.用删除线标记文本
span.setSpan(StrikethroughSpan(), 6, 9, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//5.用下划线标记文本
span.setSpan(UnderlineSpan(), 11, 14, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//6.用颜色标记
span.setSpan(ForegroundColorSpan(Color.BLUE), 9, 14, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE)
//7.//获取Drawable资源
val d = resources.getDrawable(R.mipmap.ic_warning)
d.setBounds(0, 0, d.intrinsicWidth, d.intrinsicHeight)
//8.创建ImageSpan,然后用ImageSpan来替换文本
val imgspan = ImageSpan(d, ImageSpan.ALIGN_BASELINE)
span.setSpan(imgspan, 17, 18, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
tv.text = span
//超链接的点击事件
tv.movementMethod = LinkMovementMethod.getInstance()
效果如图: