富文本相关的知识很多人在开发中用的并不多,但是遇到一些特殊问题时你会发现富文本还是很好用的 !
标如其名,其具体实现主要采用了富文本的相关知识!从而解决了同一个字符串内有多种样式,可以参考下方效果
部分效果
Tips:仅记录篇内已经实现的富文本效果
html 拼接
SpannableStringBuilder 拼接
进阶课堂
Tips
:通过 Kotlin扩展函数
快速实现 字体颜色不同、区分加粗效果、添加图片
等多样式富文本效果
基础认知
如果项目开发比较着急,也可以跳过基础部分直接看进阶课堂,可以直接用
Kotlin
的一些扩展函数
,实现更快一些,可以留出些时间做些别的
在 Andorid
中关于一些常见的富文本显示效果,通常会采用俩种方式
html
拼接 (适用于拼接文本中不同颜色的字体颜色、大小,掌握html
相关语法更具优势)SpannableStringBuilder
富文本拼接,Java
提供的原始方式,扩展方式较多
其中关于Java
提供的富文本方式,主要会用到俩个相关类,俩者主要区别在于SpannableStringBuilder
增加了类似于 StringBuilder
的 append
等方法,可以修改原来的字符串跟样式(个人见解)
- SpannableString
- SpannableStringBuilder
通过 SpannableStringBuilder
实现富文本效果主动性更强,可以在构造对象时直接声明需要设置的字符串,也可以动态在append
时添加已经Span
的文本
fun SpannableStringBuilder.appendColor(
@ColorInt color: Int,
text: CharSequence,
): SpannableStringBuilder = inSpans(ForegroundColorSpan(color)) { append(text) }
inSpans
源码
常用 API 解析
setSpan(Object what, int start, int end, int flags)
what
表示设置的格式是什么,可以是前景色、背景色也可以是可点击的文本等等start
表示需要设置格式的子字符串的起始下标end
同理表示终止下标flags
的格式可适用于各种角标位的取舍
Flag类型(四种属性)
Spannable.SPAN_EXCLUSIVE_EXCLUSIVE(前后都不包括)
即在指定范围的前面和后面插入新字符都不会应用新样式Spannable.SPAN_EXCLUSIVE_INCLUSIVE(前面不包括,后面包括)
即仅在范围字符的后面插入新字符时会应用新样式Spannable.SPAN_INCLUSIVE_EXCLUSIVE(前面包括,后面不包括)
Spannable.SPAN_INCLUSIVE_INCLUSIVE(前后都包括)
常用Span
格式
API | 含义 | 等同含义 |
---|---|---|
ForegroundColorSpan | 前景色 | setTextColor(字体颜色) |
BackgroundColorSpan | 背景色 | setBackgroudColor(背景颜色) |
RelativeSizeSpan | 字体大小 | setTextSize |
StrikethroughSpan | 删除线 | |
UnderlineSpan | 下划线 | |
SuperscriptSpan | 前景色 | |
ForegroundColorSpan | 上标 | |
SubscriptSpan | 下标 | |
StyleSpan | 字体风格 | |
ImageSpan | 文本中添加表情 | |
ClickableSpan | 设置可点击的文本 | |
URLSpan | 设置超链接 |
基础场景
此处仅做一些常用操作的使用说明,因为写的比较早,
共用
TextView textView = findViewById(R.id.content); //需要显示富文本样式的TextView控件
SpannableStringBuilder spannableBuilder = new SpannableStringBuilder("开始的开始,我们只是孩子!表情"); //需要实现富文本样式的字符串
//.....添加对应Span.....//
textView.setText(spannableBuilder); //记得将富文本后的SpannableStringBuilder设置给对应控件
字体颜色
//前景色
ForegroundColorSpan forColorSpan = new ForegroundColorSpan(Color.RED);
//设置前景色
spannableBuilder.setSpan(forColorSpan, 0, 1, Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
字体大小
//字体大小
RelativeSizeSpan testSizeSpan = new RelativeSizeSpan(1.0f);
//设置字体大小
spannableBuilder.setSpan(testSizeSpan, 0, spannableBuilder.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
添加图片
在富文本中图片的话,一定要给图片设置大小,负责显示不出来
//图片
Drawable drawable = getResources().getDrawable(R.mipmap.ic_launcher);
drawable.setBounds(0, 0, 42, 42);
ImageSpan imageSpan = new ImageSpan(drawable);
//设置图片
spannableBuilder.setSpan(imageSpan, 13, spannableBuilder.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
// 一般常见角标区间为的一般为 spannableBuilder.length()-1 至 spannableBuilder.length()
// spannableBuilder.setSpan(imageSpan, spannableBuilder.length()-1, spannableBuilder.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
设置点击效果
//点击
spannableBuilder.setSpan(new ClickableSpan() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this,"我想说,你可好?",Toast.LENGTH_SHORT).show();
}
},0,9,Spannable.SPAN_INCLUSIVE_INCLUSIVE);
textView.setMovementMethod(LinkMovementMethod.getInstance()); //注意添加此设置!否则点击事件无效!
实战场景
html 拼接
这种方式我以前在 Android进阶之路 - 常用小功能 中有记录过,为了方便特在此重新记录一下
//主要代码
Html.fromHtml("第一段文本信息" + "<font color=\"#757575\">" + "第二段文本信息(为灰色)" + "</font>")
//使用方式(如要设置颜色,可以如第二段一般进行颜色填充~)
mContext.setText(Html.fromHtml("希望" + "<font color=\"#757575\">" + "我们都不会老" + "</font>"));
//展示结果
// 希望 为 黑色字体
// 我们不会老 为 灰色字体
//上图效果
tvRisk.setText(Html.fromHtml("您的风险偏好是" + "<font color=\"#1760EA\">" + "C1稳健型" + "</font>"));
SpannableStringBuilder 拼接
package com.example.yongliu.spannablestringbuilder;
import android.graphics.Color;
import android.graphics.Typeface;
import android.graphics.drawable.Drawable;
import android.os.Bundle;
import android.support.v7.app.AppCompatActivity;
import android.text.Spannable;
import android.text.SpannableStringBuilder;
import android.text.Spanned;
import android.text.method.LinkMovementMethod;
import android.text.style.BackgroundColorSpan;
import android.text.style.ClickableSpan;
import android.text.style.ForegroundColorSpan;
import android.text.style.ImageSpan;
import android.text.style.RelativeSizeSpan;
import android.text.style.StrikethroughSpan;
import android.text.style.StyleSpan;
import android.text.style.SuperscriptSpan;
import android.text.style.URLSpan;
import android.view.View;
import android.widget.TextView;
import android.widget.Toast;
public class MainActivity extends AppCompatActivity {
private TextView mContent;
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
setContentView(R.layout.activity_main);
mContent = findViewById(R.id.content);
SpannableStringBuilder data = new SpannableStringBuilder("开始的开始,我们只是孩子!表情");
/* *
* 创建对应的Span样式
*/
//前景色
ForegroundColorSpan forColorSpan = new ForegroundColorSpan(Color.RED);
//背景色
BackgroundColorSpan backColorSpan = new BackgroundColorSpan(Color.BLUE);
//字体大小
RelativeSizeSpan textSizeSpan = new RelativeSizeSpan(1.0f);
//设置超链接
URLSpan urlSpan = new URLSpan("https://blog.csdn.net/qq_20451879/article/details/80094968");
//删除线
StrikethroughSpan strikethroughSpan = new StrikethroughSpan();
//上标
SuperscriptSpan superscriptSpan = new SuperscriptSpan();
//图片
Drawable drawable = getResources().getDrawable(R.mipmap.ic_launcher);
drawable.setBounds(0, 0, 42, 42);
ImageSpan imageSpan = new ImageSpan(drawable);
//设置字体Style
StyleSpan styleSpan = new StyleSpan(Typeface.BOLD);
/*
* 将上方创建好的Span样式,设置到对应的角标位
* start 起始角标值
* end 终点角标值
*/
//设置前景色
spannableBuilder.setSpan(forColorSpan, 0, 1, Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
//设置背景色
spannableBuilder.setSpan(backColorSpan, 1, 2, Spannable.SPAN_EXCLUSIVE_INCLUSIVE);
//设置字体大小
spannableBuilder.setSpan(textSizeSpan, 0, data.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
//设置删除线
spannableBuilder.setSpan(strikethroughSpan, 10, 12, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
//设置超链接
spannableBuilder.setSpan(urlSpan, 0, data.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
//设置上标
spannableBuilder.setSpan(superscriptSpan, 3, 4, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
//设置图片
spannableBuilder.setSpan(imageSpan, 13, data.length(), Spannable.SPAN_INCLUSIVE_INCLUSIVE);
//设置字体风格
spannableBuilder.setSpan(styleSpan, 5, 7, Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
//点击
spannableBuilder.setSpan(new ClickableSpan() {
@Override
public void onClick(View view) {
Toast.makeText(MainActivity.this,"我想说,你可好?",Toast.LENGTH_SHORT).show();
}
},0,9,Spannable.SPAN_INCLUSIVE_INCLUSIVE);
mContent.setText(spannableBuilder);
mContent.setMovementMethod(LinkMovementMethod.getInstance());
}
}
activity_main
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout
xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="match_parent"
tools:context="com.example.yongliu.spannablestringbuilder.MainActivity">
<TextView
android:layout_width="match_parent"
android:layout_height="45dp"
android:gravity="center"
android:id="@+id/content"/>
</LinearLayout>
项目实战
字体多色、加粗、尾部加图、点击事件
实现方式
val drawable = resources.getDrawable(R.drawable.icon_config_scheme_arrow_blue)
drawable.setBounds(0, 0, 12.dp, 12.dp);
val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_BASELINE)
val spannable = SpannableStringBuilder()
spannable.appendMore("#666666".toColorInt(), "根据你的风险偏好及期望的投资时长,通过资产配置测算,建议你")
spannable.mediumSmall {
color("#333333".toColorInt()) {
append("稳健类资产配置${"40%"},权益类资产配置${"60%"}。")
}
}
spannable.appendMore("#1760EA".toColorInt(), "了解更多>") {
//如果是在组件中的话,可以直接在这里写对应逻辑,如果是在一些容器内部就需要写接口回调了!
//callback?.invoke("")
}
spannable.setSpan(imageSpan, spannable.length-1, spannable.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
bind.tvAssetDesc.text = spannable
bind.tvAssetDesc.movementMethod = LinkMovementMethod.getInstance();
未读消息的小红点
TextView
尾部在特定时间加入小红点图片,此为我项目中一段代码,当做自我笔记,以防遗忘
SpannableString spannableString = new SpannableString(content);
//设置图片的方法
Drawable drawable = GarageApp.getAppContext().getResources().getDrawable(R.drawable.shape_msg_red_dot);
drawable.setBounds(0, 0, DeviceUtils.dp2px(GarageApp.getAppContext(), 8), DeviceUtils.dp2px(GarageApp.getAppContext(), 8));
ImageSpan imageSpan = new ImageSpan(drawable, ImageSpan.ALIGN_BASELINE);
spannableString.setSpan(imageSpan, content.length() - 1, content.length(), Spanned.SPAN_INCLUSIVE_EXCLUSIVE);
tvContent.setText(spannableString);
附带部分尺寸转换方法
/**
* dp 转化为 px
* @param context context
* @param dpValue dpValue
* @return int
*/
public static int dp2px(Context context, float dpValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (dpValue * scale + 0.5f);
}
/**
* px 转化为 dp
* @param context context
* @param pxValue pxValue
*/
public static int px2dp(Context context, float pxValue) {
final float scale = context.getResources().getDisplayMetrics().density;
return (int) (pxValue / scale + 0.5f);
}
public static int px2sp(Context context, float pxValue) {
final float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
return (int) (pxValue / fontScale + 0.5f);
}
public static int sp2px(Context context, float spValue) {
final float fontScale = context.getResources().getDisplayMetrics().scaledDensity;
return (int) (spValue * fontScale + 0.5f);
}
不同位置显示的字体大小有所不同
感觉当设置同等字体
Size
后,显示效果不同,主要是因为屏幕适配导致的,我们可以采用对应的dp、px转换工具
试一试
实现效果
起初我使用的就是以下 RelativeSizeSpan
声明的字体大小,但是实验效果不佳
val data = SpannableStringBuilder("您还未领取0折购基打折卡,无法享受\n0折购基的优惠,是否现在去领取?")
val startSize = RelativeSizeSpan(13.0f)
val endSize = RelativeSizeSpan(17.0f)
data.setSpan(startSize, 0, 5, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
data.setSpan(endSize, 5, data.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
content.text = data
后续我改用为 AbsoluteSizeSpan
声明的字体大小,效果不错
val data = SpannableStringBuilder("您还未领取0折购基打折卡,无法享受\n0折购基的优惠,是否现在去领取?")
data.setSpan(AbsoluteSizeSpan(13,true),0,4, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
data.setSpan(AbsoluteSizeSpan(17,true),5,9, Spannable.SPAN_INCLUSIVE_EXCLUSIVE)
data.setSpan(AbsoluteSizeSpan(13,true),9,data.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
content.text = data
常见问题
可能是常见使用场景,也可能常见的出错场景,希望可以帮到你我
设置字体Size和理想中有区别?
提示:我在项目中有一个需求是
不同区间,字体显示的大小有所区别
,在使用RelativeSizeSpan
方式设置后,效果不太理想,后续改为了AbsoluteSizeSpan
方式(具体实现在该篇的项目经验
)
val data = SpannableStringBuilder("少年可知,鲜衣怒马?")
val startSize = RelativeSizeSpan(13.0f)
val endSize = RelativeSizeSpan(17.0f)
data.setSpan(startSize, 0, 4, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
data.setSpan(endSize, 5, data.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE);
content.text = data
样式未生效?
设置文本时
不能使用后builder的toString()方法
,如果您这样做了,那么辛辛苦苦设置的样式可能就被覆盖了,并不会显示出来
- 首先检查是否设置对应的
Span
,区间是否为有效区间? - 设置
span
后,是否有调用toString()
方法?
点击事件未生效?
设置了点击事件却无效的时候, 查看是否有 setMovementMethod
,如没有的话,那么像下面一样进行设置
//mContent TextView控件
mContent.setMovementMethod(LinkMovementMethod.getInstance());
超链接要注意什么?
- 如果要使用超链接的设置,需要同时设置点击事件,不然无法触发!
- 如果超链接在点击事件内生效的话,那么会优先超链接,同时消耗此事件,其他操作点击操作将无法触发(个人Demo察觉)
- 超链接正常跳转之后你会发现你没有加入Intent的网络权限!神不神奇!~
进阶课堂
最近因为富本文效果,把整篇Blog重新修整了修整,关于顶部提到的效果已记录在项目经验中
场景践行
以下的实现均建立在 SpannableStringBuilder 扩展函数(加粗、渐变等)
扩展函数
变色
val spannable = SpannableStringBuilder()
spannable.appendMore("#666666".toColorInt(), "越来越无趣")
spannable.appendMore("#99999".toColorInt(), "为何")
contentView.text = spannable
加粗
textView.text = buildSpannedString { mediumSmall { append("能幼稚一生,何尝不是一种幸福") }
加粗、变色
contentView.text = buildSpannedString {
mediumSmall {
color("#333333".toColorInt()) {
append("为何")
}
color("#1760EA".toColorInt()) {
append("越来越无趣")
}
}
尾部加图标
val drawable = resources.getDrawable(R.drawable.icon_config_scheme_arrow_blue)
drawable.setBounds(0, 0, 12.dp, 12.dp);
val imageSpan = ImageSpan(drawable, ImageSpan.ALIGN_BASELINE)
val spannable = SpannableStringBuilder()
spannable.appendMore("#666666".toColorInt(), "描述文本,尾部可以加个字符用于替换图标")
spannable.setSpan(imageSpan, spannable.length-1, spannable.length, Spannable.SPAN_INCLUSIVE_INCLUSIVE)
contentView.text = spannable
12.dp
扩展函数
inline val Int.dp: Int
get() = (this * AppContext.resources.displayMetrics.density + 0.5f).toInt()
inline val Float.dp: Float
get() = (this * AppContext.resources.displayMetrics.density + 0.5f).toInt().toFloat()
渐变
private val textColor = intArrayOf("#6B4C16".toColorInt(), "#A17612".toColorInt())
textView.text = buildSpannedString {linearGradient(colors = textColor) { append("能幼稚一生,何尝不是一种幸福") }}
SpannableStringBuilder 扩展函数(加粗、渐变等)
关于字体渐变的需求,之前也曾记录过一篇 Android进阶之路 - TextView文本渐变 ,有兴趣的也可以去看看
SpannableStringBuilder
import android.text.SpannableStringBuilder
import android.text.style.AbsoluteSizeSpan
import android.text.style.ForegroundColorSpan
import android.text.style.RelativeSizeSpan
import android.text.style.StrikethroughSpan
import android.view.View
import androidx.annotation.ColorInt
import androidx.core.text.inSpans
import cn.com.huaan.fund.acts.core.android.text.spannable.ClickableSpan
import cn.com.huaan.fund.acts.core.android.text.spannable.LinearGradientFontSpan
import cn.com.huaan.fund.acts.core.android.text.spannable.MediumSmallSpan
inline fun SpannableStringBuilder.mediumSmall(
builderAction: SpannableStringBuilder.() -> Unit
) = inSpans(MediumSmallSpan(), builderAction = builderAction)
inline fun SpannableStringBuilder.linearGradient(
colors: IntArray,
orientation: Int = LinearGradientFontSpan.HORIZONTAL,
builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder {
return inSpans(LinearGradientFontSpan(colors, orientation), builderAction = builderAction)
}
inline fun SpannableStringBuilder.clickable(
listener: View.OnClickListener,
builderAction: SpannableStringBuilder.() -> Unit
): SpannableStringBuilder {
return inSpans(ClickableSpan(listener), builderAction = builderAction)
}
//
fun SpannableStringBuilder.appendClickable(
text: CharSequence,
listener: View.OnClickListener
): SpannableStringBuilder = inSpans(ClickableSpan(listener)) { append(text) }
fun SpannableStringBuilder.appendColor(
@ColorInt color: Int,
text: CharSequence,
): SpannableStringBuilder = inSpans(ForegroundColorSpan(color)) { append(text) }
fun SpannableStringBuilder.appendStrikeThrough(
text: CharSequence,
): SpannableStringBuilder = inSpans(StrikethroughSpan()) { append(text) }
fun SpannableStringBuilder.appendRelativeSize(
proportion: Float,
text: CharSequence,
): SpannableStringBuilder = inSpans(RelativeSizeSpan(proportion)) { append(text) }
fun SpannableStringBuilder.appendAbsoluteSize(
proportion: Int,
text: CharSequence,
): SpannableStringBuilder = inSpans(AbsoluteSizeSpan(proportion,true)) { append(text) }
//
fun SpannableStringBuilder.appendMore(
@ColorInt color: Int? = null,
text: CharSequence,
listener: View.OnClickListener? = null,
): SpannableStringBuilder {
val spans = mutableListOf<Any>()
color?.also { spans.add(ForegroundColorSpan(color)) }
listener?.also { spans.add(ClickableSpan(listener)) }
inSpans(*spans.toTypedArray()) { append(text) }
return this
}
fun SpannableStringBuilder.appendLabel(
@ColorInt color: Int? = null,
text: String,
listener: View.OnClickListener? = null,
): SpannableStringBuilder {
val splits = text.replace("</b>", "<b>").split("<b>")
splits.forEachIndexed { index, s ->
if (index % 2 == 1) {
appendMore(color = color, s, listener)
} else {
append(s)
}
}
return this
}
LinearGradientFontSpan
import android.graphics.Canvas
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Shader
import android.text.style.ReplacementSpan
import android.widget.LinearLayout
class LinearGradientFontSpan(val colors: IntArray, val orientation: Int = HORIZONTAL) : ReplacementSpan() {
companion object {
const val HORIZONTAL = LinearLayout.HORIZONTAL // 水平渐变方向
const val VERTICAL = LinearLayout.VERTICAL // 垂直渐变方向
}
private var mMeasureTextWidth = 0 // 测量的文本宽度
override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fontMetricsInt: Paint.FontMetricsInt?): Int {
mMeasureTextWidth = paint.measureText(text ?: "", start, end).toInt()
// 这段不可以去掉,字体高度没设置,会出现 draw 方法没有被调用的问题
// 详情请见:https://stackoverflow.com/questions/20069537/replacementspans-draw-method-isnt-called
val metrics = paint.fontMetrics
fontMetricsInt?.top = metrics.top.toInt()
fontMetricsInt?.ascent = metrics.ascent.toInt()
fontMetricsInt?.descent = metrics.descent.toInt()
fontMetricsInt?.bottom = metrics.bottom.toInt()
return mMeasureTextWidth
}
override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) {
if (text.isNullOrEmpty()) return
val linearGradient = if (orientation == VERTICAL) {
LinearGradient(0f, 0f, 0f, paint.descent() - paint.ascent(), colors, null, Shader.TileMode.REPEAT)
} else {
LinearGradient(x, 0f, x + mMeasureTextWidth, 0f, colors, null, Shader.TileMode.REPEAT)
}
val shader = paint.shader
val alpha = paint.alpha
//
paint.shader = linearGradient
paint.alpha = 255 // 如果是则设置不透明
//
canvas.drawText(text, start, end, x, y.toFloat(), paint)
//绘制完成之后将画笔的透明度还原回去
paint.shader = shader
paint.alpha = alpha
}
}
MediumSmallSpan
import android.graphics.Paint
import android.text.TextPaint
import android.text.style.MetricAffectingSpan
class MediumSmallSpan : MetricAffectingSpan() {
override fun updateDrawState(paint: TextPaint?) = apply(paint)
override fun updateMeasureState(paint: TextPaint) = apply(paint)
private fun apply(paint: Paint?) {
if (paint == null) return
paint.strokeWidth = 0.6f
paint.style = Paint.Style.FILL_AND_STROKE
}
}
ClickableSpan
import android.text.TextPaint
import android.view.View
import cn.com.huaan.fund.acts.core.android.listener.OnClickWrapListener
class ClickableSpan(val listener: View.OnClickListener) : android.text.style.ClickableSpan() {
override fun onClick(widget: View) {
OnClickWrapListener(listener).onClick(widget)
}
override fun updateDrawState(ds: TextPaint) {
ds.isUnderlineText = false
}
}
借鉴文章(早期)