Compose把Text组件玩出新高度

Compose - Text 详解 由于本文罗列的知识点是根据我自身总结出来的,并且由于本人水平有限,无法全部提及,欢迎大神们能补充~将来我会对上面的知识点一个一个深入学习,也希望有童鞋跟我一起学习,一起进阶。提升架构认知不是一蹴而就的,它离不开刻意学习和思考。**这里,笔者分享一份从架构哲学的层面来剖析的视频及资料分享给大家,**梳理了多年的架构经验,筹备近1个月最新录制的,相信这份视频能给你带来不一样的启发、收获。,第一时间获取最新知识点Android架构师之路很漫长,一起共勉吧! 阅读详情

一、前言

开始前,建议大家可以去先看一下我们的这一篇文章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来做更多有意思的效果呢?😍当然可以啦,篇幅有限,有兴趣的可以去尝试一下。

五、展开/收起更多文本

刚接触的同学,可能不知道怎么去实现这种效果,或者说实现完,发现总达不到就是那种,能拿的出手的那种效果。

这种设计,移动到都会有的,所以我们这里拿出来,并讲解其中的思路,源码会在文章末尾提供出来,先看一下,最终的效果:

我们可以看到其中的细节,触摸到“展开”和“收起”文本内容的时候,文字背景色会有选中的效果,同时我们可以看到“省略号”是和正文在一起的,同时,我们也处理了:内容换行符+展开内容的功能。

大家可以先不急着往下看,可以自己去尝试写一下,写不出来,没有思路的话,再来往下看。

短短的这些描述,我们能捕捉到下面几点:

  1. 手势点击+触摸。
  2. 展开/收起,选中高亮。
  3. 展开/收起,正文内容展开有动画过渡。
  4. 按钮位置偏移量计算。
  5. 如何处理“展开”,“收起”按钮的摆放。

一、手势点击和触摸:

我们使用手势事件作用域 awaitPointerEventScope来处理DOWN/MOVE/UP事件回调。

internal suspend fun PointerInputScope.detectTouchGestures(
    onDown: ((Offset) -> Unit),
    onMove: ((Offset) -> Unit),
    onUp: (Offset) -> Unit
): Unit = coroutineScope {
    launch {
        forEachGesture {
            awaitPointerEventScope {
                val down = awaitFirstDown().also { it.consume() }
                // 这里处理:ACTION_DOWN
                ....
                while (true) {
                    val event: PointerEvent = awaitPointerEvent()
                    if (event.changes.fastAny { it.pressed }) {
                        ....
                        // 这里处理:ACTION_MOVE
                    } else {
                        // 这里处理:ACTION_UP
                        break
                    }
                }
            }
        }
    }
}

二、文字背景内容选中高亮:

我们通过上面的detectTouchGestures回调的Offset,得到当前手势触摸的位置,这个Offset是通过PointerInputChange获取到的。

我们知道触摸的位置,还不够,我们还要知道“展开”和“收起”按钮,当前的位置,如果知道呢,别急,我们在后面再说,这里先跳过。

高亮的话,就是需要,触摸的位置和按钮的位置进行对比,我们可以稍微增大,按钮的可触摸点击的范围,可以提供如下方法来判断是否在按钮上触摸移动:

private fun isMoveSeeMore(touchOffset: Offset, textLayoutSize: IntSize, btnOffset: Offset): Boolean {
    val touchSizeWidth = textLayoutSize.width
    val touchSizeHeight = textLayoutSize.height
    return touchOffset.x >= btnOffset.x - touchSizeWidth
            && touchOffset.x <= btnOffset.x + touchSizeWidth
            && touchOffset.y >= btnOffset.y - touchSizeHeight
            && touchOffset.y <= btnOffset.y + touchSizeHeight
}

如果为true,就表示当前是在“展开”和“收起”按钮上触摸,可以修改背景,添加高亮背景。

三、正文内容展开过渡动画:

我们这里,又需要用到Animatable来触发动画更新了,我们通过Animatable#animateTo更新。

正文内容展开过渡,我们这里就需要用到clipRect裁剪内容到可见范围内,同时需要更新当前组件的高度Modifier.height,保证组件空间正常缩放,2者缺一不可。

clipRect示例如下:

withTransform({
    clipRect(
        left = ...,
        top = ...,
        ....
    )
}) {
   drawText(textLayoutResult)
}

展开内容的高度示例:

val expandHeight by remember {
    derivedStateOf {
        if (!isInitRender && animaTable.value > 0) {
            animaTable.value + if (expanded) seeMoreTextLayoutResult.size.height else 0
        } else {
            collapseMinHeight
        }
    }
}

// 高度随着我们动画,而更新
Modifier.height(expandHeight)

四、按钮偏移位置:

怎么计算偏移位置呢?如果你从文章上面看到这里,我想你应该知道,怎么计算,估计这里会有读者说作者这人二臂吧,我怎么会知道呢?🤣

正文默认折叠的内容,假如只显示“3行”,那么我们就需要获取第3行内容的top和bottom位置。

假如是第3行,我们先获取“省略号”的位置,我们可以通过textLayoutResult.getLineRight(2)获取右侧的位置,这个时候还不能直接使用,我们这里还需要处理:

  1. textLayoutResult.getLineRight(2)是否小于或者大于,正文内容的宽度 - (省略号+展开/收起的内容宽度和)。
  2. 正文内容渲染一半有换行符的情况。

拿到上面,省略号的位置之后,我们就可以获取“展开”/“收起”的位置了:

seeMoreOffset = seeMoreOffset.copy(
    x = if (expanded) 0F else ellipsisLineLeftX + ellipsisTextLayoutResult.size.width,
    y = (expandHeight - seeMoreTextLayoutResult.size.height)
)

省略号,“展开”/“收起”的文字的宽度和高度,我们这里需要通过TextMeasurer的measure去测量,拿到TextLayoutResult就可以获取文本的宽度和高度了。

// 查看更多
val seeMoreTextLayoutResult = seeMoreTextMeasure.measure(
    text = readOnlyMeasureText,
    style = textStyle
)

// 测量省略号,返回TextLayoutResult
val ellipsisTextLayoutResult = seeMoreTextMeasure.measure(
    text = ellipsisText,
    style = textStyle
)

五、如何处理“展开”,“收起”按钮的摆放

我们上面提到了,需要通过clipRect设置可见区域,我们这里需要先裁剪到省略号那一行的上一行。

我们需要,把内容拆分一下,先绘制不含省略号的那一行,再绘制含省略号的那一行。


withTransform({
    clipRect(...)
}) {
    // 绘制正文,不包含最后一行文本内容
    drawText(textLayoutResult)
}
withTransform({
    clipRect(...)
}) {
    // 绘制正文,最后一行内容,不包含:省略号
    drawText(textLayoutResult)
}

这个时候,我们需要进行“省略号”和“展开,收起”按钮进行绘制了,第四点,我们提到了按钮偏移位置,然后我们只需要通过drawText,然后传入topLeft进行摆放即可。

注意:这里我们需要判断,展开的最大高度和默认折叠的高度对比,保证默认折叠的高度>=展开最大高度的时候,不显示“省略号”和“展开,收起”按钮。

if (!expanded) {
    // 绘制省略号
    // 默认收起状态,才显示... 省略号,省略号和正文一个颜色,一个字体大小,和:展开、收起样式不同
    drawText(
        textLayoutResult = ellipsisTextLayoutResult,
        topLeft = Offset(...)
    )
}
// 绘制:”展开“ 或者 ”收起“
drawText(
    textMeasurer = seeMoreTextMeasure,
    text = if (expanded) collapseStateText else expandStateText,
    style = ..., // 文字大小,颜色,单独设置
    topLeft = seeMoreOffset
)

六、全文涉及的示例源码

本文所介绍的内容全量源码在这里:ComposeTextEffect

看完记得 😘😘 点赞❤️ + 评论❤️ + 关注❤️

Compose - Text 详解,2024年Android社招面试题精选 ClipEllipsisVisible关于最后一个Visible在中可以找到示例去演示器效果。笔者这边简化了一下。示例如下。 阅读详情

相关推荐

Compose Text字体大小自适应终极指南:从原理到避坑

本文深入解析了Jetpack Compose中Text组件字体大小自适应的核心原理与实现方案。针对文本溢出问题,详细介绍了如何利用onTextLayout回调、构建健壮的缩放算法(如AutoSize策略)并规避无限重组等常见陷阱,帮助开发者打造适配多语言与多屏幕尺寸的优雅UI。

silver的专栏 587

compose Text简单使用

Text 任何前端框架对文字的处理都是最重要的,Compose中文字的展示和文字的编辑分别使用的Text控件和TextField控件实现。 Text使用 简单使用 代码 @Composable fun SimpleText() { Text("Hello World") } 效果 修改文字样式 代码 Text( text = "Hello $name!", color = Color.Green,//文字颜色 fontSize = 40.

ymeddmn的博客 2131

Compose Text 属性与 Modifier 的边界:彻底理清文字排版与组件布局

在 Jetpack Compose 中,Text 组件与 Modifier 体系的关系常令开发者困惑:改字号、调间距究竟该用参数还是修饰符?从概念上看,Text 属性负责文本排版引擎的内在规则,而 Modifier 是通用布局与交互管道,两者作用域截然不同。理解这一底层原理是高效构建 UI 的关键。实际开发中,合理区分二者能显著提升界面稳定性与可维护性,尤其在列表项、标签按钮、无障碍适配等场景中,错误的混用会导致文字裁切、点击区域错位或语义异常。本文梳理了高频踩坑场景与排查方法,帮助开发者建立清晰的决策思路

weixin_42126677的博客 186

Jetpack Compose Text 组件

样式统一:以为基准,通过style统一管理,再少量覆盖参数。性能优先:减少样式变体与复杂富文本的频繁构造;长文本配合。+ RTL 兼容 + 字体 fallback。布局稳健:让父容器负责对齐(垂直居中/基线对齐),Text 专注样式与内容。可测试可调试:利用、Layout Inspector、回归检查,保证升级稳定。

giaoly的博客 1631

Jetpack Compose基础组件与声明式UI开发指南

声明式UI是现代移动开发的核心范式,通过描述UI的最终状态而非逐步操作DOM/View树,大幅提升了开发效率。Jetpack Compose作为Android官方推出的声明式UI框架,采用Kotlin DSL实现组件化开发,其核心原理是基于状态驱动的自动重组机制。这种架构特别适合需要频繁更新UI的交互场景,如实时数据展示、表单验证等。通过基础组件如Text、Button和Column/Row布局的组合,开发者可以快速构建响应式界面。结合Material Design主题系统和状态管理,Compose显著提升

weixin_29973493的博客 195

从入门到进阶:Jetpack Compose 之 Text文本组件深度详解(Material 3 版)

Jetpack Compose中的Text组件是展示文字的核心控件,相当于传统Android的TextView。文章详细介绍了Text组件的7种主要用法:1)基础样式设置(颜色、字号、字重等);2)段落排版控制(对齐、行高、溢出处理);3)使用AnnotatedString实现多样式文本;4)通过SelectionContainer支持文本选择;5)加载自定义字体;6)与Material主题集成实现样式统一;7)添加点击事件交互。开发者应优先使用主题定义的排版样式,在需要复杂样式或交互时选择合适的高级用法。

郭宝的博客 174

我用Ai学Android Jetpack Compose之Text

当然可以!Text是 Jetpack Compose 中最常用的 Composable 函数之一,用于显示文本内容。它提供了丰富的配置选项来定制文本的外观和行为。下面将详细介绍Text的基本用法、常见属性以及一些高级特性。Text是 Jetpack Compose 中不可或缺的一部分,掌握了它的基本用法和高级特性后,你可以轻松地构建出美观且功能丰富的文本UI。随着你对Compose框架理解的加深,你会发现更多有趣的应用场景和技术细节。希望这些信息对你有所帮助!

学无止境,止于至善 1383

谷歌Gemini模型免费API调用全攻略:从注册到实战(附代码示例)

本文详细介绍了如何免费调用谷歌Gemini模型的API,从注册获取API密钥到实战应用的全过程。内容涵盖环境准备、基础API调用、多模态处理、高级功能及生产环境实践,帮助开发者高效利用Gemini模型进行代码生成和复杂任务处理。附有代码示例,适合AI开发者和技术爱好者。

weixin_29212429的博客 1601

【Compose multiplatform教程07】多平台常用组件和重要组件目录

alignment。

b275518834的专栏 994

vivado生成及调用自定义IP的方法

开发环境:vivado 2018.2 1.将整个工程封装为自定义IP 将整个工程封装为自定义IP主要有以下步骤: 步骤1.选中工程中的top模块。点击Tools ------> Create and Package New IP,如图1所示 图1 步骤2.弹出界面后,点击next至图2界面,选择Package your current project,然后继续点击next 图2 步骤3.选择生成的IP路径,如图3所示。这个路径就是后续添加该IP的路径 图3 步骤4.继续点击next,然后选择fi

进击的阿日比 4730

Mac上Office合法安装与激活全指南:2021/2024/M365区别解析

Office是微软办公套件的核心产品,其在macOS平台的部署逻辑与Windows截然不同——不依赖传统密钥激活,而是基于Microsoft账户与设备标识的云端绑定机制。理解这一原理,是解决‘无法验证开发者’‘打不开’‘功能受限’等常见问题的前提。Office 2021代表永久许可模型,Office 2024 LTSC面向企业离线场景,Microsoft 365则是持续订阅服务,三者在授权验证方式、更新策略与系统兼容性上存在本质差异。实际应用中,需结合macOS版本、芯片架构(Intel/Apple Sil

weixin_33491377的博客 1249

Android到Jetpack Compose迁移实践指南-Text、Image、Layout篇

本文总结了从Android传统视图系统迁移到Jetpack Compose的实践经验,重点介绍了布局优化和组件使用。在布局迁移方面,展示了如何将ConstraintLayout转换为更简洁的Column布局,并详细对比了图片和文本组件的特性。特别是提供了Android XML属性与Compose修饰符的对照表,包括ImageView的scaleType与ContentScale的对应关系,以及宽高属性的转换方法。通过实例代码展示了两种技术栈在实现相同功能时的差异,为开发者提供了实用的迁移指南。

小一的专栏 790

详解Σ-Δ型ADC拓扑结构的基本原理

现在,总环路滤波器的H(f)具有更大的滚降,Mod 2型的噪声传递函数具有40 dB/十倍频程的上升速率。同样,当正弦波输入为负满量程时,+1和–1之间的转换会减少,输出以–1为主。噪声传递函数是一个高通滤波器函数,提供噪声整形,在DC附近的较低频率,对量化噪声有很强的抑制。H(f)是环路滤波器的函数,定义噪声和信号的传递函数。Σ-Δ型ADC的噪底由热噪声决定,而不受量化噪声的限制。量化噪声为量化器的输入与输出之差。图5. Mod 1 Σ-Δ环路的线性模型(b),包括方程、滤波器、信号和噪声传递函数图。

qq_43416206的博客 1810

Android Jetpack Compose 的所有核心组件(大全)

摘要:Android Jetpack Compose核心组件体系分为7大类:1.基础UI组件(Text/Image等)替代传统View控件;2.布局组件(Column/LazyColumn等)构建界面结构,其中懒加载布局优化列表性能;3.交互组件(按钮/输入框/选择器等)处理用户操作;4.容器组件(卡片/弹窗等)增强样式功能;5.导航组件实现页面跳转;6.动画组件提升交互体验;7.辅助工具(状态管理/无障碍等)。开发者应优先掌握基础组件,结合Lazy系列优化列表性能,并善用ConstraintLayout处

fuge2008的博客 878

H.264 入门篇 - 09 (帧间预测 - 参考帧列表)

短期和长期的关系和区别:在一个解码器中的 DPB(参考帧缓存)大小总是有限的,所以引入一个问题,什么样的参考帧会被保存到 DPB 中,什么样的参考帧会被移除出去,因此将分为长期和短期参考帧,所谓短期参考帧是这帧不会长期驻留在DPB中,如果参考帧缓存已满或者有其他特定的条件,那么短期参考帧就会被从DPB中移除;与之相对的长期参考帧,只要没有遇到某个指定的条件,遇到某个IDR帧, 遇到了清空整个DPB的指令等等,那么长期参考帧会长期在DPB中,不会因为有新的参考帧而被冲刷掉;(P帧是按照编码顺序)

StephenZhou 5103

Vue3 + OpenLayers 开发指南(一) - 环境搭建与基础配置

本文详细介绍了如何从零开始搭建Vue3与OpenLayers的集成开发环境。通过项目初始化、核心依赖安装、目录结构规划及基础配置,手把手引导开发者创建一个结构清晰、可维护的现代化WebGIS应用骨架,为后续添加地图图层和交互功能奠定坚实基础。

weixin_28049429的博客 339

Jetpack系列之Compose Text

Jetpack Compose是Android的现代UI工具包,采用声明式Kotlin DSL替代传统XML布局。其核心特性包括:1)状态驱动UI自动更新,通过remember缓存状态;2)代码简洁,减少findViewById等冗余操作;3)组件高度可组合复用;4)支持即时预览。文章详细展示了Compose主题系统实现,包括颜色方案(深浅模式)、字体定义和状态化文本组件封装,其中AppText支持5种状态(Normal/Disabled/Error/Warning/Success)并自动适配样式。通过Ma

JAVE_LOVER 466

单周期CPU设计【Verilog】

第一章 单周期CPU的设计原理 为实现单周期CPU,本文首先研究了单周期CPU的相关理论和工作原理。本章首先简要介绍单周期CPU的基本概念,然后对CPU的工作原理进行简要分析,以便为单周期CPU的设计和开发提供理论基础。 1.1 单周期CPU概述 中央处理器,即CPU,作为计算机系统的运算和控制核心,是信息处理、程序运行的最终执行单元。在CPU内部,电平从低到高变化的瞬间称为时钟上升沿,两个相邻时钟上升沿之间的时间间隔称为一个时钟周期。单周期CPU指的是一条指令的执行在一个时钟周期内完成,然后开始下一

醉酒、冰城外 2万+

Compose 中 Text 控件对齐方式深度总结

本文总结了Compose中Text控件的对齐方式:1)内部对齐通过textAlign、lineHeight和wrapContentHeight控制文字在文本框内的位置;2)外部对齐由父容器(Box/Column/Row)的特性决定。核心区别在于点击热区和基线对齐的差异,Box(contentAlignment=Alignment.Center)是最推荐方案,提供最大点击面积且代码清晰。文章还对比了不同场景下的推荐写法和视觉效果,并附有代码示例说明。

琦琦的博客 397

Mac版 Android Studio自带模拟器无法联网解决方案

前言 作为一枚安卓开发者,免不了使用模拟器来开发调试(毕竟它比手机方便太多)。但是因为适配需要,我们会更多的使用真机开发调试,那么当模拟器遇到这样那样的问题,我们往往会无从下手,其中最烦的就是网络问题。 我最近图方便,又重新用起了模拟器,但是发现联网失败,用模拟器内置的浏览器也打不开网页,于是百度上一通找,毫无效果,各种说用cmd命令行设置模拟器dns的都没用,显示没有什么dns文件啥的,后来...

陈万洲的专栏 1万+

WINCC7.3破解文件

WINCC7.3硬件够破解文件,有需要的下载!WINCC7.3硬件够破解文件,有需要的下载!

Compose Text文字垂直居中

Compose Text文字垂直居中

u010106754的博客 2724

假人有限元模型

假人模型,车辆碰撞假人有限元模型,适合做乘员安全的研究者利用

Android全新UI框架之常用ComposeUI组件

在Compose中,每个组件都是一个带有@Composable注解的函数,被称为Composable。Compose已经预置了很多基于MD设计规范的Composable组件。在布局方面,Compose提供了Column、Row、Box三种布局组件(感觉跟flutter差不多),类似于传统视图开发中的LinearLayout(Vertical)、LinearLayout(Horizontal)、RelateiveLayout,可以满足各类产品的常见布局需求。

qq_36828822的博客 4122

C# WinForm 基于DirectShow.dll编写的程序,可边预览边保存视频,音频和图片

基于微软的DirectShow.dll的二次开发1.自动识别连接的摄像头跟麦克风且可以对其进行选择(默认选中第一个)2.可录制wmv和avi两种格式的视频并且都带有音频,avi可以录制的分辨率和帧数进行选择,wmv默认进行了淸晰度优化如不喜欢可去除代码中有相关提示3.可在预览点开之后进行拍照,也可边录制视频边拍照4.添加了视频和音频的压缩功能(酌情使用)这次二次开发发现C#对DirectShow的相关信息太少,这次做完也不敢藏私共享出来,还望大家指正

Compose 核心组件学习指南

Compose 核心组件指南 📐 Modifier - UI修饰工具链 控制大小、边距、背景、边框等样式 支持链式组合:fillMaxWidth().padding(16.dp).background(Color.LightGray) 🎛️ 布局容器 Box:堆叠布局(图片+文字) Column/Row:线性排列 LazyColumn:高性能列表(带滚动记忆) 📝 基础组件 Text:支持字体样式、对齐、溢出处理 Image/Icon:图片显示与图标 Button:可自定义颜色/圆角的交互控件 💡

琦琦的博客 465

掌握Jetpack Compose UI设计:Why-Not-Compose核心组件详解与示例

Why-Not-Compose是一个基于Jetpack Compose的UI组件库,它包含了丰富的动画、组合和界面设计示例,堪称Jetpack Compose的实用指南。本文将详细介绍该项目的核心组件,帮助开发者快速上手Jetpack Compose UI设计。 ## 项目简介 Why-Not-Compose项目是一个Jetpack Compose的实践集合,涵盖了各种动画效果、UI组件和界面

gitblog_00693的博客 304

Android | Compose 初上手

简介 Jetpack Compose 是用于构建原生 Andorid 界面的新工具包,Compose 使用了更少的代码,强大的工具和直观的 Kotlin Api 简化并且加快了 Android 上界面的开发。 在 Compose 中,在构建界面的时候,无需在像之前那么构建 XML 布局,只需要调用 Jetpack Compose 函数来声明你想要的的元素,Compose 编译器就会自动帮你完成后面的工作。 在开始使用 Compose 之前,你需要重新搭建环境,可参考**官方文档** 注解 @Compos

345丶 1863
上一篇: Compose挑灯夜看 - 照亮手机屏幕里面的书本内容
下一篇: OpenHarmony 物联网设备开发环境搭建
Halifax ‎
博客等级 码龄14年 4272粉丝 43原创
评论
成就一亿技术人!
拼手气红包6.0元
还能输入1000个字符
 
 条评论被折叠 查看
添加红包

请填写红包祝福语或标题

个

红包个数最小为10个

元

红包金额最低5元

当前余额3.43元 前往充值 >
需支付:10.00元
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

Halifax ‎

交个朋友

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付元
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值