图形修饰符

除了 Canvas 可组合项之外,Compose 还有几个实用的图形 Modifiers,可帮助绘制自定义内容。这些修饰符可以应用于任何可组合项,因而非常实用。

绘制修饰符

所有绘制命令均在 Compose 中使用绘制修饰符完成。Compose 中有三个主要的绘制修饰符:

基本绘制修饰符为 drawWithContent,您可以在其中确定可组合项的绘制顺序以及从修饰符内发出的绘制命令。drawBehind 是围绕 drawWithContent 的便利封装容器,其绘制顺序设为可组合项内容的后方。drawWithCache 会在其内部调用 onDrawBehind 或 onDrawWithContent,并提供一种机制来缓存在其中创建的对象。

Modifier.drawWithContent:选择绘制顺序

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

例如,如果您希望在内容之上渲染径向渐变,以在界面上创建手电筒锁孔效果,可以执行以下操作:

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 来打造手电筒类型界面体验。

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

Modifier.drawBehind lets you perform DrawScope operations behind the composable content that is drawn on screen. 如果看一下 Canvas 的实现,您可能会发现它只是一个围绕 Modifier.drawBehind 的便利封装容器。

如需在 Text 后面绘制圆角矩形,请执行以下操作:

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

这会产生以下结果:

使用 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())
                )
            }
        }
)

使用 drawWithCache 缓存 Brush 对象
图 3:使用 drawWithCache 缓存 Brush 对象

图形修饰符

Modifier.graphicsLayer:对可组合项应用转换

Modifier.graphicsLayer 是一个修饰符,用于将可组合项的内容绘制到绘图层。图层提供几项不同的功能,例如:

  • 隔离其绘制指令(类似于 RenderNode)。作为图层一部分捕获的绘制指令,可由渲染管道高效地重新发出,而无需重新执行应用代码。
  • 应用于图层中包含的所有绘制指令的转换。
  • 合成功能的光栅化。对图层进行光栅化时,系统会执行其绘制指令,并将输出捕获到屏幕外缓冲区。为后续帧合成此类缓冲区比执行单个指令更快,但在应用缩放或旋转等转换时,其行为类似于位图。

转换

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

Modifier.graphicsLayer 不会更改可组合项的测量尺寸或位置,因为它只会影响绘制阶段。这意味着,如果可组合项最终绘制到了其布局边界之外,则可能会与其他对象重叠。

使用此修饰符可应用以下转换:

缩放 - 增加大小

scaleX 和 scaleY 分别用于在水平和垂直方向上放大和缩小内容。值 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 更改 translationX 和 translationY,translationX 可向左或向右移动可组合项。translationY 可向上或向下移动可组合项。

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

图 5:使用 Modifier.graphicsLayer 将 translationX 和 translationY 应用于图片
旋转

将 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 在图片上设置 rotationX、rotationY 和 rotationZ
原点

您可以指定 transformOrigin,然后将其用作转换的发生点。到目前为止,所有示例都使用了位于 (0.5f, 0.5f) 的 TransformOrigin.Center。如果将原点指定为 (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 时裁剪内容的轮廓。在本例中,我们将两个框设为具有两种不同的裁剪:一个使用 graphicsLayer 裁剪变量,另一个使用便利的封装容器 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”的文本)的内容被裁剪为圆形:

裁剪已应用于框可组合项
图 8:裁剪已应用于框可组合项

如果随后将 translationY 应用于顶部的粉色圆形,就会发现可组合项的边界没有变化,但该圆形会绘制在底部圆形下方(以及其边界之外)。

通过 translationY 应用的裁剪,以及轮廓的红色边框
图 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))
    )
}

在 graphicsLayer 转换之上应用的裁剪
图 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
        }
)

应用了 alpha 值的图片
图 11:应用了 alpha 值的图片

合成策略

处理 alpha 值和透明度并不总是像更改单个 alpha 值那样简单。除了更改 alpha 值之外,还有 其他选项可以在 graphicsLayer 上设置 CompositingStrategy。CompositingStrategy 确定可组合项的内容如何与屏幕上已绘制的其他内容组合在一起。

这些不同策略包括:

自动(默认)

合成策略由其余的 graphicsLayer 参数确定。如果 alpha 小于 1.0f 或设置了 RenderEffect,该策略会将图层渲染到屏幕外缓冲区。当 alpha 小于 1f 时,系统会自动创建一个合成图层来渲染内容,然后使用相应的 alpha 值将此屏幕外缓冲区绘制到目标位置。无论 CompositingStrategy 如何设置,设置 RenderEffect 或滚动回弹始终会将内容渲染到屏幕外缓冲区。

屏幕外

可组合项的内容始终会先光栅化为屏幕外纹理或位图,然后再渲染到目标位置。这对于 应用 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,以及应用内的 BlendMode.Clear
图 12:显示圆形指示图的图片上的 Modifier.drawWithContent,以及应用内的 BlendMode.Clear 和 CompositingStrategy.Offscreen

如果您未使用 CompositingStrategy.Offscreen,则应用 BlendMode.Clear 的结果会清除目标中的所有像素,无论之前设置了什么内容,都会使窗口的渲染缓冲区(黑色)可见。许多涉及 alpha 的 BlendModes 在没有屏幕外缓冲区时都将无法正常工作。请注意红色圆形指示器周围的黑色圆环:

显示圆形指示图的图片上的 BlendMode.Clear,具有 BlendMode.Clear,未设置 CompositingStrategy
图 13:显示圆形指示图的图片上的 BlendMode.Clear,具有 BlendMode.Clear,未设置 CompositingStrategy

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

未设置 CompositingStrategy,并对具有半透明窗口背景的应用使用 BlendMode.Clear。粉色壁纸穿过红色状态圆形周围的区域显示。
图 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 与 CompositingStrategy.Offscreen - 屏幕外策略会裁剪至此区域,自动策略则不会
图 15:CompositingStrategy.Auto 与 CompositingStrategy.Offscreen - 屏幕外策略会裁剪至此区域, 自动策略则不会
ModulateAlpha

此 合成策略 会调整每条绘制 指令的 alpha 值,这些指令记录在 graphicsLayer 中。除非设置了 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)
    )
}

@Preview
@Composable
fun ModifierGraphicsLayerOutsets() {
    Column(
        modifier = Modifier
            .background(Color(0xFF0A0E14))
            .padding(48.dp),
        verticalArrangement = Arrangement.spacedBy(48.dp),
        horizontalAlignment = Alignment.CenterHorizontally
    ) {
        val pillShape = RoundedCornerShape(percent = 50)

        // Without outsets: alpha = 0.5f creates an offscreen buffer that clips the 24.dp glow
        Box(
            modifier = Modifier
                .size(
                    width = 140.dp,
                    height = 56.dp
                )
                .graphicsLayer(
                    alpha = 0.5f,
                    clip = false
                )
                .dropShadow(
                    shape = pillShape,
                    shadow = Shadow(radius = 24.dp, color = Color.Cyan)
                )
                .background(color = Color(0xFF141824), shape = pillShape),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "✨ Glow",
                color = Color.White,
                fontSize = 18.sp
            )
        }

        Box(
            modifier = Modifier
                .size(
                    width = 140.dp,
                    height = 56.dp
                )
                .graphicsLayer(
                    alpha = 0.5f,
                    outsets = LayerOutsets(24.dp),
                    clip = false
                )
                .dropShadow(
                    shape = pillShape,
                    shadow = Shadow(radius = 24.dp, color = Color.Cyan)
                )
                .background(color = Color(0xFF141824), shape = pillShape),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "✨ Glow",
                color = Color.White,
                fontSize = 18.sp
            )
        }
    }
}


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

ModulateAlpha 将设置的 alpha 值应用于各个绘制命令
图 16:ModulateAlpha 将设置的 alpha 值应用于各个绘制命令

图层外边距

当可组合项绘制到屏幕外缓冲区时(例如,当 alpha 小于 1.0f、设置了 RenderEffect 或使用了 CompositingStrategy.Offscreen 时),屏幕外纹理默认采用可组合项的测量布局边界。图层内延伸到可组合项布局边界之外的任何绘制或阴影(例如使用 Modifier.dropShadow 绘制的外发光)都会在屏幕外缓冲区的边界处隐式裁剪。

您可以将 LayerOutsets 传递给 Modifier.graphicsLayeroutsets 参数,而不是添加 Modifier.padding()(这会移动 相邻界面元素的布局测量)。这样可以扩展屏幕外缓冲区的每一侧的视觉边界,而不会改变可组合项的测量布局大小。请注意,LayerOutsets 不会扩展图层的裁剪形状或阴影边界,这些边界仍基于可组合项的原始测量大小(因此,请在 graphicsLayer 上保留 clip = false,以避免裁剪到原始边界):

Box(
    modifier = Modifier
        .size(
            width = 140.dp,
            height = 56.dp
        )
        .graphicsLayer(
            alpha = 0.5f,
            outsets = LayerOutsets(24.dp),
            clip = false
        )
        .dropShadow(
            shape = pillShape,
            shadow = Shadow(radius = 24.dp, color = Color.Cyan)
        )
        .background(color = Color(0xFF141824), shape = pillShape),
    contentAlignment = Alignment.Center
) {
    Text(
        text = "✨ Glow",
        color = Color.White,
        fontSize = 18.sp
    )
}

比较了不含 LayerOutsets(顶部,剪裁为矩形布局边界)与含 LayerOutsets(24.dp)(底部,未剪裁)的屏幕外图层外发光效果
图 17:不使用 LayerOutsets 的屏幕外图层外发光(顶部,裁剪到可组合项的矩形布局边界)与使用 LayerOutsets(24.dp) 的屏幕外图层外发光(底部,未裁剪)的对比

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

一个常见的用例是从可组合项创建 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()
)

在文本上应用自定义翻转修饰符
图 17:在文本上应用自定义翻转修饰符

其他资源

如需查看更多使用 graphicsLayer 和自定义绘制的示例,请查看以下资源: