图形修饰符

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

绘制修饰符

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

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

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,可以在界面上绘制的可组合项内容后面执行 DrawScope 操作。如果看一下 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 不会更改可组合项的测量尺寸或位置,因为它只会影响绘制阶段。这意味着,如果可组合项最终绘制到了其布局边界之外,则可能会与其他对象重叠。

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

缩放 - 增加大小

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:使用 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 上设置 CompositingStrategyCompositingStrategy 确定可组合项的内容如何与屏幕上已绘制的其他内容组合在一起。

这些不同策略包括:

自动(默认)

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

屏幕外

可组合项的内容始终会先光栅化为屏幕外纹理或位图,然后再渲染到目标位置。这对于应用 BlendMode 操作来遮盖内容,以及在渲染复杂的绘制指令集时提高性能非常有用。

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

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

合成策略会调整 graphicsLayer 中记录的每条绘制指令的 alpha 值。除非设置了 RenderEffect,否则此策略不会针对 1.0f 以下的 alpha 创建屏幕外缓冲区,以便可以更高效地进行 alpha 呈现。不过,此策略可以针对重叠内容提供不同的结果。对于事先知道内容不重叠的用例,与 alpha 值小于 1 的 CompositingStrategy.Auto 相比,此策略可以提供更好的性能。

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

@Preview
@Composable
fun CompositingStratgey_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 将设置的 alpha 值应用于各个绘制命令
图 16:ModulateAlpha 将设置的 alpha 值应用于各个绘制命令

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

一种常见的用例是从可组合项创建 Bitmap。如需将可组合项的内容复制到 Picture,请使用 drawIntoCanvas 方法:

Column(
    modifier = Modifier
        .padding(padding)
        .fillMaxSize()
        .drawWithCache {
            // Example that shows how to redirect rendering to an Android Picture and then
            // draw the picture into the original destination
            val width = this.size.width.toInt()
            val height = this.size.height.toInt()

            onDrawWithContent {
                val pictureCanvas =
                    androidx.compose.ui.graphics.Canvas(
                        picture.beginRecording(
                            width,
                            height
                        )
                    )
                // requires at least 1.6.0-alpha01+
                draw(this, this.layoutDirection, pictureCanvas, this.size) {
                    this@onDrawWithContent.drawContent()
                }
                picture.endRecording()

                drawIntoCanvas { canvas -> canvas.nativeCanvas.drawPicture(picture) }
            }
        }

) {
    ScreenContentToCapture()
}

上面的代码段会将可组合项的内容绘制到 Picture 对象中。然后,将 Picture 转换为 Bitmap

val bitmap = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
    Bitmap.createBitmap(picture)
} else {
    val bitmap = Bitmap.createBitmap(
        picture.width,
        picture.height,
        Bitmap.Config.ARGB_8888
    )
    val canvas = android.graphics.Canvas(bitmap)
    canvas.drawColor(android.graphics.Color.WHITE)
    canvas.drawPicture(picture)
    bitmap
}

然后将位图保存到磁盘并共享。如需了解详情,请参阅完整代码段

自定义绘制修饰符

如需创建自己的自定义修饰符,请实现 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 和自定义绘制的示例,请查看以下资源: