一、前言
开始前,建议大家可以去先看一下我们的这一篇文章==→Compose挑灯夜看 - 照亮手机屏幕里面的书本内容==,对阅读本篇文章有益。
我不知道有多少人用过“纯纯写作”,今天想起来,就以它为开头引子,纯纯写作里面有一个下面这样的功能,如下图:
当然这个功能只是我们今天这个文章里面提到的Compose的Text花样玩法
其中之一,且看我们下面一一道来。
二、Text组件介绍
耐心往下看,切记心浮气躁,我们先介绍一下Text组件,如果觉得简单,可以跳过目录二
,如果想看官方的Text文档,点击这里
// androidx.compose.material.Text
@Composable
fun Text(
// 要显示的文本内容
text: String,
// Modifier修饰符
modifier: Modifier = Modifier,
// 文本颜色
color: Color = Color.Unspecified,
......
// 在文本内容上面绘制的装饰(如,下划线)
textDecoration: TextDecoration? = null,
// 行高
lineHeight: TextUnit = TextUnit.Unspecified,
// 文本内容溢出处理方式:Clip、Ellipsis、Visible
overflow: TextOverflow = TextOverflow.Clip,
// 是否处理换行符
softWrap: Boolean = true,
// 计算新文本布局时执行的回调。
// [TextLayoutResult] 包含段落信息、大小
// 文本、基线等。回调可用于添加额外的装饰和
// 文本的功能。例如,围绕文本绘制选择。
onTextLayout: (TextLayoutResult) -> Unit = {},
// 文本的样式配置
style: TextStyle = LocalTextStyle.current
)
我们精简了Text
组件里面提供的参数,参数含义见上面的注释。
我们平常修改一下:“文字大小、字体颜色、字体、Modifier修饰符”,感觉就差不多了,但事情并不往往那么简单。
比如:我们这一篇文章中==→Compose挑灯夜看 - 照亮手机屏幕里面的书本内容==,还用到了TextStyle里面的brush的API。
看了Text
源码,它提供的方法我们知道:
显示文字的最基本方法是使用以 String 作为参数的 Text 可组合项。
同一 Text 可组合项中设置不同的样式,必须使用 AnnotatedString。
如果只是基于基础参数使用的话,很多功能,都会止步于此,如果要实现一些更复杂的效果话,这个时候就需要通过onTextLayout
的回调来定制了。
我们也可以通过TextMeasurer可以轻松的实现BasicText
一样的功能,我们不再需要nativeCanvas
辛苦的绘制Text
,这个在文章后面会有讲解。
// TextMeasurer简单示例,文章后面会有介绍,比如:目录五,会用到它。
val text = buildAnnotatedString { append("我们不会期待米粉的期待") }
val textMeasure = rememberTextMeasurer()
val textLayoutResult = textMeasure.measure(text = text, style = TextStyle(color = Color.Black, fontSize = 18.sp))
Box(modifier = Modifier.fillMaxSize().systemBarsPadding()) {
Canvas(modifier = Modifier.fillMaxWidth()) {
drawText(textLayoutResult = textLayoutResult)
}
}
我们看Text
组件的 onTextLayout
给我们回调了TextLayoutResult
,我们看看这个类里面给我们提供了什么:
// androidx.compose.ui.text.TextLayoutResult
class TextLayoutResult constructor(
// 保存文本布局计算参数集的数据类。
val layoutInput: TextLayoutInput,
// 文本布局计算返回的多段落object
val multiParagraph: MultiParagraph,
// 文本内容占的宽度和高度
val size: IntSize
) {
......
// 返回指定字符偏移的字符边界
fun getBoundingBox(offset: Int): Rect = ...
// 返回包含指定文本范围的路径
fun getPathForRange(start: Int, end: Int): Path = ...
// 返回指定行的顶部坐标
fun getLineTop(lineIndex: Int): Float = ...
// 返回指定行的左边水平x坐标
fun getLineLeft(lineIndex: Int): Float = ...
// 返回指定行的右边水平x坐标
fun getLineRight(lineIndex: Int): Float = ...
// 返回指定行的底部坐标
fun getLineBottom(lineIndex: Int): Float = ...
// 获取指定文本偏移的水平位置。返回与文本起始偏移量的相对距离
fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float = ...
......
}
TextLayoutResult
给我们提供太多有用的东西了,更多参数和方法
留给读者自己去阅读,不可能
一篇文章全部介绍完,篇幅有限,关键是我们这篇主讲的是“玩些新花样
”,我们下面开始玩些新花样
三、绘制自定义文本跨行
1、文本跨行背景绘制
回到文章开头的地方,我们提到了“纯纯写作
”这个引子,如何实现文本跨行背景绘制呢?
1、getBoundingBox方式
首先我们可能会想到,获取到单个文字的left.x
和top.y
,我们能从TextLayoutResult
哪个方法里面获取呢?
TextLayoutResult
里面有getBoundingBox
这个方法,可以获取“指定字符偏移的字符边界
”,我们这样是不是就可以获取对应的文字在哪个位置了,对不对?
由于需要在文本后面绘制背景色,那肯定需要Modifier的drawBehind
修饰符:
// 举个例子
var onDraw: DrawScope.() -> Unit by remember { mutableStateOf({}) }
Text(
text = text,
style = MaterialTheme.typography.body1.copy(lineHeight = 20.sp),
modifier = Modifier.drawBehind { onDraw() },
onTextLayout = { layoutResult ->
// 随便测试一段text里面的文本内容
val findIndex = text.indexOf("周")
onDraw = {
val boundsRect = layoutResult.getBoundingBox(findIndex)
drawRect(
brush = SolidColor(Color(0xFFFF6E00)),
topLeft = boundsRect.topLeft,
size = boundsRect.size
)
}
}
)
我们可以看到,取到了单个字符串所在的位置,并成功绘制了背景色,那么我们如果绘制跨行背景的话,是不是循环去获取getBoundingBox
对应的值呢?
// 注意:这段代码没有问题
Text(
...
onTextLayout = { layoutResult ->
// 随便测试一段text里面的文本内容
val findIndex = text.indexOf("周末七国分争,并入于秦。")
val endIndex = findIndex.plus("周末七国分争,并入于秦。".length)
onDraw = {
for (index in findIndex until endIndex) {
// 循环获取单个字符串的Rect边界
val boundsRect = layoutResult.getBoundingBox(index)
drawRect(
brush = SolidColor(Color(0xFF899BBE)),
topLeft = boundsRect.topLeft,
size = boundsRect.size
)
}
}
}
)
我们可以看到,这里没有成功跨行绘制,我们发现只要到了一行的最后一个字符串
,它的值就变成了下面这样:
Rect.fromLTRB(1008.0, 0.0, 0.0, 64.0)
我发现Issue Tracker里面也有人发过这个问题,谷歌修复的时间是“7月29号”,感兴趣的可以点击查看修改的内容
而我写这个示例的compose版本是1.2.1
,谷歌并没有把这个代码合并到1.2.1
里面,我在1.3.0-alpha03
里面找到了合并记录。
于是,上面的代码,只要升级到了compose 1.3.0-alpha03+ 的版本上,就可以正常绘制了:
2、getPathForRange方式
我们也可以通过drawPath来绘制跨行文本背景,我们在TextLayoutResult
里面发现getPathForRange
可以返回包含指定文本范围的路径
。
val findIndex = text.indexOf("....")
val path = layoutResult.getPathForRange(findIndex,"....".length)
onDraw = {
drawPath(
path = path,
brush = SolidColor(Color(0xFF899BBE))
)
}
同样可以实现上面的效果,由于这里不是一行一行的去绘制,所以这里不能给path添加圆角,如果在这里添加圆角会出现下面这样的效果:
如果它只有一行,那没有问题,直接path.addRoundRect
就行了。
这里再插一句,建议读者在drawPath里面添加一行下面这段,试试效果:
style = Stroke(width = 1.dp.toPx())
多行文本我们想实现跨多行文本圆角背景选中,如何实现呢?下面请看:扩展getBoundingBox实现
3、扩展getBoundingBox实现
先看个效果:
我们看上面这个效果,如果你看完上面的文章内容,看到这里,应该知道,我们这里需要拆解“行”,每行都需要单独绘制,遍历每一行,然后读取它们的边界,目前源码里面并没有
这个方法,那么我们就自己增加一个扩展。
我们再来回顾一下,TextLayoutResult
里面的方法:
// androidx.compose.ui.text.TextLayoutResult
class TextLayoutResult constructor(...) {
...
// 返回指定行的顶部坐标
fun getLineTop(lineIndex: Int): Float = ...
// 返回指定行的左边水平x坐标
fun getLineLeft(lineIndex: Int): Float = ...
// 返回指定行的右边水平x坐标
fun getLineRight(lineIndex: Int): Float = ...
// 返回指定行的底部坐标
fun getLineBottom(lineIndex: Int): Float = ...
// 获取指定文本偏移的水平位置。返回与文本起始偏移量的相对距离
fun getHorizontalPosition(offset: Int, usePrimaryDirection: Boolean): Float = ...
...
}
获取指定某段文本占的开始行
、结束行
:
val startIndex = text.indexOf("指定的文本内容")
val endIndex = start.plus("指定的文本内容".length)
// 开始的行
val startLine = getLineForOffset(startIndex)
// 结束的行
val endLine = getLineForOffset(endIndex)
知道从哪一行开始,哪一行结束,这个时候需要一个for循环了,那么循环每行,如何知道这一行的指定内容所在的坐标位置呢?
指定行的Top位置: TextLayoutResult#getLineTop
指定行的Bottom位置: TextLayoutResult#getLineBottom
我们需要注意,左侧和右侧的位置:
// com.melody.text.effect.components.TextLayoutExtension.kt
// 左侧:
if (indexLine == startLine) {
// 获取指定文本偏移的水平位置。返回与文本起始偏移量的相对距离
getHorizontalPosition(offset = start, usePrimaryDirection = true)
} else {
// 返回指定行的左边水平x坐标
getLineLeft(indexLine)
}
// 右侧:
if (indexLine == endLine) {
// 获取指定文本偏移的水平位置。返回与文本起始偏移量的相对距离
getHorizontalPosition(offset = end, usePrimaryDirection = true)
} else {
// 返回指定行的右边水平x坐标
getLineRight(indexLine)
}
如果是首行
或尾行
,需要通过TextLayoutResult#getHorizontalPosition
获取当前左侧
或者右侧
与文本起始偏移量的相对距离。
循环首行
到尾行
,记录下,所有的Rect
,接下来,我们只需要遍历这个列表,然后通过drawPath
去绘制即可。
val findIndex = text.indexOf("指定的某段文字内容")
// 注意:getBoundingBoxRectList方法里面我们区分了:“单行”和“多行”2个分支!!!
val rectList = layoutResult.getBoundingBoxRectList(findIndex,findIndex.plus("指定的某段文字内容".length))
onDraw = {
rectList.forEachIndexed { index, rect ->
// 清除路径中的所有直线和曲线,保留内部数据结构便于更快地重用
path.asAndroidPath().rewind()
// 具体值设置,可以在文章末尾查看,我们提供的源码地址。
// 我们可以在这里,增加边距,防止挨的太紧凑。
path.addRoundRect(RoundRect(....))
// 绘制背景
drawPath(
path = path,
brush = SolidColor(Color(0xFF276FFF).copy(alpha = 0.3F)),
style = Fill
)
// 绘制边框
drawPath(
path = path,
brush = SolidColor(Color(0xFF276FFF)),
style = Stroke(width = 1.sp.toPx())
)
}
}
2、内容下方添加波浪线动画
拆解任务:我们需要获取到指定内容,再给它绘制一个波浪线,最后再加上动画。
不知道怎么画波浪线,我们先画个直线
:
val rectList = layoutResult.getBoundingBoxRectList(...)
onDraw = {
rectList.forEach { rect->
val underline = rect.copy(top = rect.bottom - 2.sp.toPx())
drawRect(
color = Color.Blue,
topLeft = underline.topLeft,
size = underline.size,
)
}
}
波浪线
:需要用Path
来画,画出来需要它能动,就需要Animation
我们定义一个buildWaveLinePath
创建波浪线Path
:
val TWO_PI = 2 * Math.PI.toFloat()
private fun Path.buildWaveLinePath(bound: Rect, waveLength:Float, animProgress: Float): Path {
asAndroidPath().rewind()
//moveTo(bound.left, bound.height) // 不能放这里
var pointX = bound.left
while (pointX < bound.right) {
val offsetY = bound.bottom + sin(((x - bound.left) / waveLength) * TWO_PI + (TWO_PI * animProgress))
if(x == bound.left) {
moveTo(bound.left, offsetY)
}
lineTo(x, offsetY)
pointX += 1F
}
return this
}
如果要增加 波浪线
的 幅度 sin(x) * n
// 像这样:
sin(((x - bound.left) / waveLength) * TWO_PI + (TWO_PI * animProgress)) * 5
然后定义一个rememberInfiniteTransition()
无限运行的动画,去更新animProgress
,需要注意一点DrawScope#drawPath
绘制波浪线,一定要设置PathEffect
,否则你的波浪线会出现小山丘那种浪线:
val pathStyle = Stroke(
...
pathEffect = PathEffect.cornerPathEffect(radius = 9.dp.toPx())
)
drawPath(
path = path,
...
style = pathStyle
)
四、分离文字动画
我们上面一直在讲的都是绘制背景相关
,并没有提到对文字做什么动画之类的,这个目录,我们要对文字内容,开刀。
我们看一下要实现的效果(gif有点卡
):
这里我们不能用Modifier
的drawBehind
修饰符了,实验一,这里我们需要用drawWithCache
,我们再看一眼,drawBehind
:
// androidx.compose.ui.draw
fun onDrawBehind(block: DrawScope.() -> Unit): DrawResult = onDrawWithContent {
block()
drawContent()
}
block()
就是后面绘制的背景而已,drawContent()
是上层的内容,我们这里直接用drawWithCache
,但我们不调用drawContent()
,分离文字的时候就不会发生内容重叠绘制。
// 像这样的,不调用drawContent()
Modifier.drawWithCache {
onDrawWithContent {
onDraw()
}
}
同样的我们通过 onTextLayout
获取 TextLayoutResult
。
从文章开头看到这里的同学肯定知道了,我们这里需要用TextLayoutResult#getBoundingBox
获取每个内容的位置。
然后再给它通过drawText
画上去,看上去有点麻烦的样子,这分离后能和原来的一样吗?
注意:这个不是nativeCanvas#drawText
我们看一下DrawScope#drawText
需要我们传什么?
// 方法一
fun DrawScope.drawText(
textMeasurer: TextMeasurer,
text: String,
topLeft: Offset = Offset.Zero,
style: TextStyle = TextStyle.Default,
overflow: TextOverflow = TextOverflow.Clip,
softWrap: Boolean = true,
maxLines: Int = Int.MAX_VALUE,
size: IntSize = IntSize(
width = ceil(this.size.width).roundToInt(),
height = ceil(this.size.height).roundToInt()
)
)
// 方法二
fun DrawScope.drawText(
textLayoutResult: TextLayoutResult,
color: Color = Color.Unspecified,
topLeft: Offset = Offset.Zero,
alpha: Float = Float.NaN,
shadow: Shadow? = null,
textDecoration: TextDecoration? = null
)
我们可以看到,方法一 的第一个参数是TextMeasurer
,这个API
可实现任意文本布局计算,创建与 BasicText
相同的结果,
如果想修改 density
、layoutDirection
、fontFamilyResolver
,就需要把Compose版本升级到了1.3.0-beata01+
,然后可以使用TextMeasurer
去构造里面的自定义值:
val textMeasurer by remember { TextMeasurer(...) }
我们看一下TextMeasurer的部分源码解释:
// androidx.compose.ui.text
class TextMeasurer constructor(
// 用于加载到TextStyle 和 SpanStyles 中给出的字体
private val fallbackFontFamilyResolver: FontFamily.Resolver,
// 屏幕的密度
private val fallbackDensity: Density,
// 布局方向,当前是LTR还是RTL,阿拉伯这些国家用的就是RTL,从右到左显示
private val fallbackLayoutDirection: LayoutDirection,
// TextMeasurer 内部缓存的容量
private val cacheSize: Int = DefaultCacheSize
) {
...
// [TextLayoutResult] 包含段落信息、大小
// 文本、基线等。可用于添加额外的装饰和
// 文本的功能。例如,围绕文本绘制选择等。
fun measure(...): TextLayoutResult { ... }
...
}
回到上面,我们看DrawScope#drawText
的方法二,需要传TextLayoutResult
,这里就需要我们使用TextMeasurer#measure
我们实验一的效果,只需要DrawScope#drawText
的方法一
我们挨个取字符串,通过TextLayoutResult#getBoundingBox
获取对应的字符串的位置
for (index in text.indices){
val rect = it.getBoundingBox(index)
drawText(
textMeasurer = ...,
text = text[index].toString(),
topLeft = rect.topLeft,
)
}
如何让它动起来呢?我们需要用到DrawScope#withTransform
withTransform({
rotate(
degrees = ...,
pivot = rect.center
)
})
这样是不是就可以动起来了(GIF录屏卡
):
那么如何增加整个文字颜色渐变呢?如果你看过Compose挑灯夜看 - 照亮手机屏幕里面的书本内容这一篇文章就知道怎么做了,关键代码如下:
val brush = Brush.horizontalGradient(
listOf(
Color(0xFF22B6FF),
Color(0xFFB732FF),
Color(0xFFFF1D37)
)
)
// graphicsLayer(alpha = 0.99F)是合成的关键,为什么小于1F,读者可以打开
// graphicsLayer里面的alpha注释:小于1.0F的alpha值会将其内容隐式裁剪到其边界
// 如果太小就会出现太透明,所以我们这里用0.99F就行了
Modifier.graphicsLayer(alpha = 0.99F)
.drawWithCache {
onDrawWithContent {
onDraw()
drawRect(brush, blendMode = BlendMode.SrcAtop)
}
}
到这里,我觉得还可以再修改一下,我们是否可以通过其他方式去,单个文字去绘制做动画呢?
我们可以通过clipRect
设置显示区域:
val boundingBoxList = textLayoutResult.layoutInput.text.indices.map {
textLayoutResult.getBoundingBox(it)
}
for (index in textLayoutResult.layoutInput.text.indices) {
val box = boundingBoxList[index]
withTransform({
rotate(...)
// 设置显示区域
clipRect(box.left, box.top, box.right, box.bottom)
}) {
drawText(textLayoutResult)
}
}
我们一样可以实现上面的效果。
延伸:是否可以配合PathMeasure
来做更多有意思的效果呢?