图形修饰符

除了 Canvas 可组合项之外,Compose 还提供了一些有用的图形 Modifiers,可帮助绘制自定义内容。这些修饰符很有用,因为它们可以应用于任何可组合项。

绘图修饰符

Compose 中的所有绘图命令都使用绘图修饰符完成。Compose 中有三个主要的绘图修饰符

绘图的基本修饰符是 drawWithContent,您可以在其中确定可组合项的绘制顺序以及在修饰符内发出的绘图命令。drawBehinddrawWithContent 的一个便捷包装器,其绘制顺序设置为可组合项内容的后面。drawWithCache 在其内部调用 onDrawBehindonDrawWithContent,并提供了一种缓存其中创建的对象的机制。

Modifier.drawWithContent:选择绘制顺序

Modifier.drawWithContent 允许您在可组合项内容之前或之后执行 DrawScope 操作。请务必调用 drawContent 以呈现可组合项的实际内容。使用此修饰符,您可以决定操作顺序,如果希望在自定义绘图操作之前或之后绘制内容。

例如,如果您希望在内容上渲染径向渐变以在 UI 上创建手电筒钥匙孔效果,您可以执行以下操作

var pointerOffset by remember {
    mutableStateOf(Offset(0f, 0f))
}
Column(
    modifier = Modifier
        .fillMaxSize()
        .pointerInput("dragging") {
            detectDragGestures { change, dragAmount ->
                pointerOffset += dragAmount
            }
        }
        .onSizeChanged {
            pointerOffset = Offset(it.width / 2f, it.height / 2f)
        }
        .drawWithContent {
            drawContent()
            // draws a fully black area with a small keyhole at pointerOffset that’ll show part of the UI.
            drawRect(
                Brush.radialGradient(
                    listOf(Color.Transparent, Color.Black),
                    center = pointerOffset,
                    radius = 100.dp.toPx(),
                )
            )
        }
) {
    // Your composables here
}

图 1:在可组合项顶部使用 Modifier.drawWithContent 创建手电筒类型 UI 体验。

Modifier.drawBehind:在可组合项后面绘制

Modifier.drawBehind 允许您在屏幕上绘制的可组合项内容后面执行 DrawScope 操作。如果您查看 Canvas 的实现,您可能会注意到它只是 Modifier.drawBehind 的一个便捷包装器。

Text 后面绘制圆角矩形

Text(
    "Hello Compose!",
    modifier = Modifier
        .drawBehind {
            drawRoundRect(
                Color(0xFFBBAAEE),
                cornerRadius = CornerRadius(10.dp.toPx())
            )
        }
        .padding(4.dp)
)

产生以下结果

Text and a background drawn using Modifier.drawBehind
图 2:使用 Modifier.drawBehind 绘制的文本和背景

Modifier.drawWithCache:绘制和缓存绘制对象

Modifier.drawWithCache 保持其内部创建的对象处于缓存状态。只要绘图区域的大小相同,或者读取的任何状态对象未更改,这些对象就会被缓存。此修饰符对于提高绘图调用的性能很有用,因为它避免了重新分配在绘制时创建的对象(例如:Brush、Shader、Path 等)。

或者,您也可以使用 remember 在修饰符外部缓存对象。但是,这并非始终可能,因为您并非始终可以访问组合。如果对象仅用于绘图,则使用 drawWithCache 可能会提高性能。

例如,如果您创建了一个 Brush 以在 Text 后面绘制渐变,则使用 drawWithCache 会缓存 Brush 对象,直到绘图区域的大小发生变化

Text(
    "Hello Compose!",
    modifier = Modifier
        .drawWithCache {
            val brush = Brush.linearGradient(
                listOf(
                    Color(0xFF9E82F0),
                    Color(0xFF42A5F5)
                )
            )
            onDrawBehind {
                drawRoundRect(
                    brush,
                    cornerRadius = CornerRadius(10.dp.toPx())
                )
            }
        }
)

Caching the Brush object with drawWithCache
图 3:使用 drawWithCache 缓存 Brush 对象

图形修饰符

Modifier.graphicsLayer:将变换应用于可组合项

Modifier.graphicsLayer 是一个修饰符,它使可组合项的内容绘制到一个绘制图层中。图层提供了一些不同的功能,例如

  • 其绘图指令的隔离(类似于 RenderNode)。作为图层一部分捕获的绘图指令可以通过渲染管道有效地重新发出,而无需重新执行应用程序代码。
  • 应用于图层中包含的所有绘图指令的变换。
  • 用于组合功能的光栅化。当图层被光栅化时,其绘图指令会被执行,并且输出会被捕获到一个离屏缓冲区中。合成后续帧的此类缓冲区比执行各个指令更快,但当应用缩放或旋转等变换时,它将表现为位图。

变换

Modifier.graphicsLayer 为其绘图指令提供隔离;例如,可以使用 Modifier.graphicsLayer 应用各种变换。这些可以进行动画处理或修改,而无需重新执行绘图 lambda。

Modifier.graphicsLayer 不会更改可组合项的测量大小或放置,因为它仅影响绘制阶段。这意味着如果可组合项最终绘制在其布局边界之外,则它可能会与其他可组合项重叠。

可以使用此修饰符应用以下变换

缩放 - 增大尺寸

scaleXscaleY 分别沿水平或垂直方向放大或缩小内容。1.0f 的值表示缩放不变,0.5f 的值表示维度的一半。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.scaleX = 1.2f
            this.scaleY = 0.8f
        }
)

图 4:将 scaleX 和 scaleY 应用于 Image 可组合项
平移

可以使用 graphicsLayer 更改 translationXtranslationYtranslationX 将可组合项向左或向右移动。translationY 将可组合项向上或向下移动。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.translationX = 100.dp.toPx()
            this.translationY = 10.dp.toPx()
        }
)

图 5:将 translationX 和 translationY 应用于带有 Modifier.graphicsLayer 的 Image
旋转

设置 rotationX 以水平旋转,rotationY 以垂直旋转,rotationZ 以 Z 轴旋转(标准旋转)。此值以度数指定(0-360)。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.rotationX = 90f
            this.rotationY = 275f
            this.rotationZ = 180f
        }
)

图 6:通过 Modifier.graphicsLayer 在 Image 上设置 rotationX、rotationY 和 rotationZ
原点

可以指定 transformOrigin。然后将其用作变换发生的点。到目前为止,所有示例都使用了 TransformOrigin.Center,它位于 (0.5f, 0.5f)。如果您将原点指定为 (0f, 0f),则变换将从可组合项的左上角开始。

如果您使用 rotationZ 变换更改原点,您可以看到该项目围绕可组合项的左上角旋转

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "Sunset",
    modifier = Modifier
        .graphicsLayer {
            this.transformOrigin = TransformOrigin(0f, 0f)
            this.rotationX = 90f
            this.rotationY = 275f
            this.rotationZ = 180f
        }
)

图 7:应用于 TransformOrigin 设置为 0f、0f 的旋转

裁剪和形状

clip = true 时,Shape 指定内容裁剪到的轮廓。在此示例中,我们设置了两个框以具有两个不同的裁剪 - 一个使用 graphicsLayer clip 变量,另一个使用便捷包装器 Modifier.clip

Column(modifier = Modifier.padding(16.dp)) {
    Box(
        modifier = Modifier
            .size(200.dp)
            .graphicsLayer {
                clip = true
                shape = CircleShape
            }
            .background(Color(0xFFF06292))
    ) {
        Text(
            "Hello Compose",
            style = TextStyle(color = Color.Black, fontSize = 46.sp),
            modifier = Modifier.align(Alignment.Center)
        )
    }
    Box(
        modifier = Modifier
            .size(200.dp)
            .clip(CircleShape)
            .background(Color(0xFF4DB6AC))
    )
}

第一个框(显示“Hello Compose”的文本)的内容被裁剪为圆形

Clip applied to Box composable
图 8:应用于 Box 可组合项的裁剪

然后,如果您将 translationY 应用于顶部粉红色圆圈,您会看到可组合项的边界仍然相同,但圆圈绘制在底部圆圈下方(并在其边界之外)。

Clip applied with translationY, and red border for outline
图 9:应用于 translationY 的裁剪,以及用于轮廓的红色边框

要将可组合项裁剪到其绘制的区域,您可以在修饰符链的开头添加另一个 Modifier.clip(RectangleShape)。然后,内容保留在原始边界内。

Column(modifier = Modifier.padding(16.dp)) {
    Box(
        modifier = Modifier
            .clip(RectangleShape)
            .size(200.dp)
            .border(2.dp, Color.Black)
            .graphicsLayer {
                clip = true
                shape = CircleShape
                translationY = 50.dp.toPx()
            }
            .background(Color(0xFFF06292))
    ) {
        Text(
            "Hello Compose",
            style = TextStyle(color = Color.Black, fontSize = 46.sp),
            modifier = Modifier.align(Alignment.Center)
        )
    }

    Box(
        modifier = Modifier
            .size(200.dp)
            .clip(RoundedCornerShape(500.dp))
            .background(Color(0xFF4DB6AC))
    )
}

Clip applied on top of graphicsLayer transformation
图 10:在 graphicsLayer 变换之上应用裁剪

Alpha

Modifier.graphicsLayer 可用于为整个图层设置 alpha(不透明度)。1.0f 表示完全不透明,0.0f 表示不可见。

Image(
    painter = painterResource(id = R.drawable.sunset),
    contentDescription = "clock",
    modifier = Modifier
        .graphicsLayer {
            this.alpha = 0.5f
        }
)

Image with alpha applied
图 11:应用了 alpha 的 Image

合成策略

使用 alpha 和透明度可能并不像更改单个 alpha 值那样简单。除了更改 alpha 之外,还可以选择在 graphicsLayer 上设置 CompositingStrategyCompositingStrategy 确定可组合项的内容如何与已绘制在屏幕上的其他内容合成(组合)。

不同的策略是

自动(默认)

合成的策略由 graphicsLayer 的其他参数决定。如果 alpha 小于 1.0f 或设置了 RenderEffect,它会将图层渲染到离屏缓冲区中。每当 alpha 小于 1f 时,都会自动创建一个合成图层来渲染内容,然后将这个离屏缓冲区以相应的 alpha 值绘制到目标位置。设置 RenderEffect 或 overscroll 将始终将内容渲染到离屏缓冲区中,而不管设置的 CompositingStrategy 是什么。

离屏

可组合项的内容在渲染到目标位置之前始终光栅化到离屏纹理或位图。这对于将 BlendMode 操作应用于遮罩内容以及在渲染复杂的绘图指令集时提高性能很有用。

使用 CompositingStrategy.Offscreen 的一个示例是使用 BlendModes。查看下面的示例,假设您想通过发出使用 BlendMode.Clear 的绘制命令来移除 Image 可组合项的某些部分。如果您未将 compositingStrategy 设置为 CompositingStrategy.Offscreen,则 BlendMode 会与其下方的所有内容交互。

Image(
    painter = painterResource(id = R.drawable.dog),
    contentDescription = "Dog",
    contentScale = ContentScale.Crop,
    modifier = Modifier
        .size(120.dp)
        .aspectRatio(1f)
        .background(
            Brush.linearGradient(
                listOf(
                    Color(0xFFC5E1A5),
                    Color(0xFF80DEEA)
                )
            )
        )
        .padding(8.dp)
        .graphicsLayer {
            compositingStrategy = CompositingStrategy.Offscreen
        }
        .drawWithCache {
            val path = Path()
            path.addOval(
                Rect(
                    topLeft = Offset.Zero,
                    bottomRight = Offset(size.width, size.height)
                )
            )
            onDrawWithContent {
                clipPath(path) {
                    // this draws the actual image - if you don't call drawContent, it wont
                    // render anything
                    this@onDrawWithContent.drawContent()
                }
                val dotSize = size.width / 8f
                // Clip a white border for the content
                drawCircle(
                    Color.Black,
                    radius = dotSize,
                    center = Offset(
                        x = size.width - dotSize,
                        y = size.height - dotSize
                    ),
                    blendMode = BlendMode.Clear
                )
                // draw the red circle indication
                drawCircle(
                    Color(0xFFEF5350), radius = dotSize * 0.8f,
                    center = Offset(
                        x = size.width - dotSize,
                        y = size.height - dotSize
                    )
                )
            }
        }
)

通过将 CompositingStrategy 设置为 Offscreen,它会创建一个离屏纹理来执行命令(仅将 BlendMode 应用于此可组合项的内容)。然后将其渲染到屏幕上已渲染内容的顶部,而不影响已绘制的内容。

Modifier.drawWithContent on an Image showing a circle indication, with the BlendMode.Clear inside app
图 12:在显示圆形指示符的 Image 上使用 Modifier.drawWithContent,并在应用内使用 BlendMode.Clear 和 CompositingStrategy.Offscreen

如果您没有使用 CompositingStrategy.Offscreen,则应用 BlendMode.Clear 的结果将清除目标中的所有像素,而不管之前设置了什么内容——使窗口的渲染缓冲区(黑色)可见。许多涉及 alpha 的 BlendModes 在没有离屏缓冲区的情况下将无法按预期工作。请注意红色圆形指示符周围的黑环

Modifier.drawWithContent on an Image showing a circle indication, with the BlendMode.Clear and no CompositingStrategy set
图 13:在显示圆形指示符的 Image 上使用 Modifier.drawWithContent,并使用 BlendMode.Clear 且未设置 CompositingStrategy

为了进一步理解这一点:如果应用具有半透明的窗口背景,并且您没有使用 CompositingStrategy.Offscreen,则 BlendMode 将与整个应用交互。它将清除所有像素以显示下方应用或壁纸,如本例所示

No CompositingStrategy set and using BlendMode.Clear with an app that has a translucent window background. The pink wallpaper is shown through the area around the red status circle.
图 14:未设置 CompositingStrategy 并在具有半透明窗口背景的应用中使用 BlendMode.Clear。请注意,红色状态圆圈周围的区域如何显示粉红色的壁纸。

值得注意的是,当使用 CompositingStrategy.Offscreen 时,会创建一个与绘图区域大小相同的离屏纹理并将其渲染回屏幕。使用此策略执行的任何绘图命令默认情况下都将裁剪到此区域。下面的代码片段说明了切换到使用离屏纹理时的差异

@Composable
fun CompositingStrategyExamples() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .wrapContentSize(Alignment.Center)
    ) {
        // Does not clip content even with a graphics layer usage here. By default, graphicsLayer
        // does not allocate + rasterize content into a separate layer but instead is used
        // for isolation. That is draw invalidations made outside of this graphicsLayer will not
        // re-record the drawing instructions in this composable as they have not changed
        Canvas(
            modifier = Modifier
                .graphicsLayer()
                .size(100.dp) // Note size of 100 dp here
                .border(2.dp, color = Color.Blue)
        ) {
            // ... and drawing a size of 200 dp here outside the bounds
            drawRect(color = Color.Magenta, size = Size(200.dp.toPx(), 200.dp.toPx()))
        }

        Spacer(modifier = Modifier.size(300.dp))

        /* Clips content as alpha usage here creates an offscreen buffer to rasterize content
        into first then draws to the original destination */
        Canvas(
            modifier = Modifier
                // force to an offscreen buffer
                .graphicsLayer(compositingStrategy = CompositingStrategy.Offscreen)
                .size(100.dp) // Note size of 100 dp here
                .border(2.dp, color = Color.Blue)
        ) {
            /* ... and drawing a size of 200 dp. However, because of the CompositingStrategy.Offscreen usage above, the
            content gets clipped */
            drawRect(color = Color.Red, size = Size(200.dp.toPx(), 200.dp.toPx()))
        }
    }
}

CompositingStrategy.Auto vs CompositingStrategy.Offscreen - offscreen clips to the region, where auto doesn’t
图 15:CompositingStrategy.Auto 与 CompositingStrategy.Offscreen 的对比 - 离屏裁剪到区域,而自动裁剪则不裁剪
调制 Alpha

合成策略 会调制 graphicsLayer 中记录的每个绘图指令的 alpha 值。除非设置了 RenderEffect,否则它不会为低于 1.0f 的 alpha 值创建离屏缓冲区,因此对于 alpha 渲染来说,它可以更高效。但是,它可能会为重叠内容提供不同的结果。对于预先知道内容不会重叠的用例,这可以提供比 alpha 值小于 1 的 CompositingStrategy.Auto 更好的性能。

下面是不同合成策略的另一个示例 - 将不同的 alpha 值应用于可组合项的不同部分,并应用 Modulate 策略

@Preview
@Composable
fun CompositingStrategy_ModulateAlpha() {
    Column(
        modifier = Modifier
            .fillMaxSize()
            .padding(32.dp)
    ) {
        // Base drawing, no alpha applied
        Canvas(
            modifier = Modifier.size(200.dp)
        ) {
            drawSquares()
        }

        Spacer(modifier = Modifier.size(36.dp))

        // Alpha 0.5f applied to whole composable
        Canvas(
            modifier = Modifier
                .size(200.dp)
                .graphicsLayer {
                    alpha = 0.5f
                }
        ) {
            drawSquares()
        }
        Spacer(modifier = Modifier.size(36.dp))

        // 0.75f alpha applied to each draw call when using ModulateAlpha
        Canvas(
            modifier = Modifier
                .size(200.dp)
                .graphicsLayer {
                    compositingStrategy = CompositingStrategy.ModulateAlpha
                    alpha = 0.75f
                }
        ) {
            drawSquares()
        }
    }
}

private fun DrawScope.drawSquares() {

    val size = Size(100.dp.toPx(), 100.dp.toPx())
    drawRect(color = Red, size = size)
    drawRect(
        color = Purple, size = size,
        topLeft = Offset(size.width / 4f, size.height / 4f)
    )
    drawRect(
        color = Yellow, size = size,
        topLeft = Offset(size.width / 4f * 2f, size.height / 4f * 2f)
    )
}

val Purple = Color(0xFF7E57C2)
val Yellow = Color(0xFFFFCA28)
val Red = Color(0xFFEF5350)

ModulateAlpha applies the alpha set to each individual draw command
图 16:ModulateAlpha 将设置的 alpha 值应用于每个单独的绘制命令

将可组合项的内容写入位图

一个常见的用例是从可组合项创建 Bitmap。要将可组合项的内容复制到 Bitmap,请使用 rememberGraphicsLayer() 创建一个 GraphicsLayer

使用 drawWithContent()graphicsLayer.record{} 将绘图命令重定向到新图层。然后使用 drawLayer 在可见画布中绘制图层

val coroutineScope = rememberCoroutineScope()
val graphicsLayer = rememberGraphicsLayer()
Box(
    modifier = Modifier
        .drawWithContent {
            // call record to capture the content in the graphics layer
            graphicsLayer.record {
                // draw the contents of the composable into the graphics layer
                this@drawWithContent.drawContent()
            }
            // draw the graphics layer on the visible canvas
            drawLayer(graphicsLayer)
        }
        .clickable {
            coroutineScope.launch {
                val bitmap = graphicsLayer.toImageBitmap()
                // do something with the newly acquired bitmap
            }
        }
        .background(Color.White)
) {
    Text("Hello Android", fontSize = 26.sp)
}

您可以将位图保存到磁盘并共享它。有关更多详细信息,请参阅 完整的示例代码片段。在尝试保存到磁盘之前,请务必检查设备上的权限。

自定义绘图修饰符

要创建自己的自定义修饰符,请实现 DrawModifier 接口。这使您可以访问 ContentDrawScope,它与使用 Modifier.drawWithContent() 时公开的内容相同。然后,您可以将常见的绘图操作提取到自定义绘图修饰符中以清理代码并提供方便的包装器;例如,Modifier.background() 是一个方便的 DrawModifier

例如,如果您想实现一个垂直翻转内容的 Modifier,您可以按如下方式创建一个:

class FlippedModifier : DrawModifier {
    override fun ContentDrawScope.draw() {
        scale(1f, -1f) {
            this@draw.drawContent()
        }
    }
}

fun Modifier.flipped() = this.then(FlippedModifier())

然后使用应用于 Text 的此翻转修饰符

Text(
    "Hello Compose!",
    modifier = Modifier
        .flipped()
)

Custom Flipped Modifier on Text
图 17:Text 上的自定义翻转修饰符

其他资源

有关使用 graphicsLayer 和自定义绘图的更多示例,请查看以下资源