一、前言

开始前,建议大家可以去先看一下我们的这一篇文章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、Visibleoverflow: 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,// 文本布局计算返回的多段落objectval 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.xtop.y,我们能从TextLayoutResult哪个方法里面获取呢?

TextLayoutResult里面有getBoundingBox这个方法,可以获取“指定字符偏移的字符边界”,我们这样是不是就可以获取对应的文字在哪个位置了,对不对?

由于需要在文本后面绘制背景色,那肯定需要ModifierdrawBehind修饰符:

// 举个例子
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.leftwhile (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有点卡):

这里我们不能ModifierdrawBehind修饰符了,实验一,这里我们需要用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 相同的结果,

如果想修改 densitylayoutDirectionfontFamilyResolver,就需要把Compose版本升级到了1.3.0-beata01+,然后可以使用TextMeasurer去构造里面的自定义值:

val textMeasurer by remember { TextMeasurer(...) }

我们看一下TextMeasurer的部分源码解释:

// androidx.compose.ui.textclass 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来做更多有意思的效果呢?

Compose把Text组件玩出新高度相关推荐

  1. matlab从flove,Matlab玩出新高度,变身表白女友神器_善良995的博客-CSDN博客

    原文作者:善良995 原文标题:Matlab玩出新高度,变身表白女友神器 发布时间:2021-03-19 13:36:02 Matlab还可以这样玩儿?每逢节日愁哭程序员,不知道该送什么给女朋友,在这 ...

  2. 百度微笑起航将人脸识别玩出新高度

    2017年1月14日,今年春运序幕缓缓拉开之时,乘坐国航CA1415.CA1416两架航班的乘客发现了一些特别的变化,这两架名为"微笑中国号"的航班上,增加了15台人工智能互动装置 ...

  3. 人人可用的在线抠图,还是AI自动化的那种!北大校友的算法被玩出新高度

    杨净 发自 凹非寺  量子位 报道 | 公众号 QbitAI 现在人人可试可玩的图像分割来了. 在线API,只需输入图片网址,即可自动删除目标背景. 就拿今天凌晨刚夺得欧冠冠军的拜仁来试试手- 然后就 ...

  4. opencv 图像 抠图 算法_人人可用的在线抠图,AI自动化的那种!北大校友算法玩出新高度...

    杨净 发自 凹非寺 量子位 报道 | 公众号 QbitAI 现在人人可试可玩的图像分割来了. 在线API,只需输入图片网址,即可自动删除目标背景. 就拿今天凌晨刚夺得欧冠冠军的拜仁来试试手- 然后就变 ...

  5. 北大校友的算法被玩出新高度,AI自动化在线抠图

    点上方蓝字计算机视觉联盟获取更多干货 在右上方 ··· 设为星标 ★,与你不见不散 仅作学术分享,不代表本公众号立场,侵权联系删除 转载于:量子位 报道 | 公众号 QbitAI AI博士笔记系列推荐 ...

  6. java代码自动抠图_人人可用的在线抠图,AI自动化的那种!北大校友算法玩出新高度...

    本文经AI新媒体量子位(公众号ID:QbitAI)授权转载,转载请联系出处. 现在人人可试可玩的图像分割来了. 在线API,只需输入图片网址,即可自动删除目标背景. 就拿今天凌晨刚夺得欧冠冠军的拜仁来 ...

  7. 5620亿参数,最大多模态模型控制机器人,谷歌把具身智能玩出新高度

    关注并星标 从此不迷路 计算机视觉研究院 公众号ID|ComputerVisionGzq 学习群|扫码在主页获取加入方式 计算机视觉研究院专栏 作者:Edison_G 机器人越来越像人了!谷歌等的这项 ...

  8. Matlab玩出新高度,变身表白女孩神器

    Matlab还可以这样玩儿?每逢节日愁哭程序员,不知道该送什么给喜欢的女孩子,在这里教你用Matlab玩儿出属于程序员的浪漫,送给她一整天的惊喜^^ 一.效果图 二.完整模板代码 三.教你如何个性化定 ...

  9. nginx 转发_除了转发和负载均衡,nginx又一次让他玩出新高度

    点击上方"Java学习之道",选择"关注"公众号 每天10:24,干货准时送达! 来源:https://dwz.cn/JY7SVlZf Nginx应该是现在最火 ...

最新文章

  1. 关于FluentNhibernate数据库连接配置,请教
  2. iPIN CEO 杨洋:AI 还未被大规模用在工作中,缺的是认知智能
  3. Py之albumentations:albumentations库函数的简介、安装、使用方法之详细攻略
  4. Android开发工程师面试指南
  5. 校园录html源码,校园录播系统设计方案(20页)-原创力文档
  6. (IOS)BaiduFM 程序分析
  7. idea报错 IDEA:clear read-only status
  8. 【Matlab学习笔记】【图像滤波去噪】以-4,-8为中心的拉普拉斯滤波器
  9. 2015暑假多校联合---Friends(dfs枚举)
  10. PTB IP——支持电信配置文件的精确同步:5G
  11. 考研807程序设计C语言教程,中央财经大学
  12. HTML+CSS+VUE 简易的便签
  13. 5G时代App和小程序是否会逐渐消失?
  14. 基于51单片机简易数字示波器Proteus仿真
  15. 想做倒卖生意,现在有两万启动资金,倒卖什么比较好?
  16. 中望3D Overdrive内核技术之“容差建模”
  17. (2018 -NIPS)SimplE embedding for link prediction in knowledge
  18. 无法嵌入互操作类型“stdole.StdFontClass”的解决方法
  19. 计算机组成原理题目透析(1)
  20. 实现智能读报(逐字朗读+自动滚屏)

热门文章

  1. 【功能测试】part2
  2. python斗地主出牌算法_斗地主之用蚁群算法整理牌型:如何进行牌力估计
  3. JL-03-Q9 自动气象站 常见气象9参数 空气温湿度 风速风向 雨量光照 大气压力 土壤温湿度
  4. GTD任务清单及项目管理器2Do for Mac
  5. SXSSFWorkbook 表格内换行
  6. 月薪50K的测试工程师,要求原来是这样!
  7. EasyExcel锁定指定单元格 禁止表格复制
  8. 解决邮件附件乱码问题
  9. 解决 mac 蓝牙鼠标、键盘经常总是 断开连接的问题
  10. 小米温湿度计接入金桔通用蓝牙网关