Modificadores gráficos

Además del elemento componible Canvas, Compose tiene varios gráficos útiles Modifiers que ayudan a dibujar contenido personalizado. Estos modificadores son útiles porque se pueden aplicar a cualquier elemento componible.

Modificadores de dibujo

Todos los comandos de dibujo se realizan con un modificador de dibujo en Compose. Existen tres modificadores de dibujos principales en Compose:

El modificador base para dibujar es drawWithContent, en el que puedes decidir el orden de dibujo del elemento componible y los comandos de dibujo que se emiten dentro del modificador. drawBehind es un wrapper práctico alrededor de drawWithContent que tiene el orden de dibujo configurado detrás del contenido del elemento componible. drawWithCache llama a onDrawBehind o onDrawWithContent dentro suyo. Además, proporciona un mecanismo para almacenar en caché los objetos creados en ellos.

Modifier.drawWithContent: Elige el orden de dibujo

Modifier.drawWithContent te permite ejecutar operaciones DrawScope antes o después del contenido del elemento componible. Asegúrate de llamar a drawContent para renderizar el contenido real del elemento componible. Con este modificador, puedes decidir el orden de las operaciones si deseas que el contenido se dibuje antes o después de tus operaciones de dibujo personalizadas.

Por ejemplo, si deseas renderizar un gradiente radial sobre tu contenido para crear un efecto de linterna en la IU, puedes hacer lo siguiente:

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
}

Figura 1: Uso de Modifier.drawWithContent sobre un elemento componible para crear una experiencia de IU de tipo linterna

Modifier.drawBehind: Dibujo detrás de un elemento componible

Modifier.drawBehind permite realizar operaciones de DrawScope detrás del contenido componible que se dibuja en pantalla. Si observas la implementación de Canvas, quizás notes que se trata de un wrapper práctico alrededor de Modifier.drawBehind.

Para dibujar un rectángulo redondeado detrás de Text, haz lo siguiente:

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

Esto produce el siguiente resultado:

Texto y fondo dibujados con Modifier.drawBehind
Figura 2: Texto y fondo dibujados con Modifier.drawBehind

Modifier.drawWithCache: Cómo dibujar y almacenar en caché objetos de dibujo

Modifier.drawWithCache mantiene almacenados en caché los objetos que se crean en su interior. Los objetos se almacenan en caché siempre y cuando el tamaño del área de trazado sea igual o no se hayan modificado los objetos de estado leídos. Este modificador es útil para mejorar el rendimiento de las llamadas de dibujo, ya que evita la necesidad de reasignar objetos (como Brush, Shader, Path, etc.) que se crean con el dibujo.

Como alternativa, también puedes almacenar objetos en caché mediante remember, fuera del modificador. Sin embargo, esto no siempre es posible, ya que no siempre tienes acceso a la composición. Puede ser más eficaz usar drawWithCache si los objetos solo se usan para dibujar.

Por ejemplo, si creas un Brush para dibujar un gradiente detrás de un Text, con drawWithCache se almacena en caché el objeto Brush hasta que cambie el tamaño del área de trazado:

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

Cómo almacenar en caché el objeto Brush con drawWithCache
Figura 3: Cómo almacenar en caché el objeto Brush con drawWithCache

Modificadores gráficos

Modifier.graphicsLayer: Aplica transformaciones a elementos componibles

Modifier.graphicsLayer es un modificador que convierte el contenido del elemento componible en una capa de dibujo. Una capa proporciona algunas funciones diferentes, como las siguientes:

  • Aislamiento para las instrucciones de dibujo (similar a RenderNode): La canalización de renderización puede volver a emitir las instrucciones de dibujo capturadas como parte de una capa de manera eficiente sin volver a ejecutar el código de la aplicación.
  • Transformaciones que se aplican a todas las instrucciones de dibujo incluidas en una capa.
  • Rasterización para capacidades de composición: Cuando una capa se rasteriza, se ejecutan sus instrucciones de dibujo y el resultado se captura en un búfer fuera de pantalla. La composición de un búfer para marcos posteriores es más rápida que ejecutar las instrucciones individuales, pero se comportará como un mapa de bits cuando se apliquen transformaciones como el escalamiento o la rotación.

Transformaciones

Modifier.graphicsLayer proporciona aislamiento para sus instrucciones de dibujo. Por ejemplo, se pueden aplicar varias transformaciones con Modifier.graphicsLayer. Estas se pueden animar o modificar sin necesidad de volver a ejecutar la expresión lambda del dibujo.

Modifier.graphicsLayer no cambia el tamaño ni la posición de medición del elemento componible, ya que solo afecta la fase de dibujo. Eso significa que el elemento componible puede superponerse a otros si dibuja fuera de los límites de su diseño.

Las siguientes transformaciones se pueden aplicar con este modificador:

Aumento del tamaño con Scale

scaleX y scaleY aumentan o reducen el contenido en la dirección horizontal o vertical, respectivamente. Un valor de 1.0f indica que no hay cambios en la escala; un valor de 0.5f representa la mitad de la dimensión.

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

Figura 4: Uso de scaleX y scaleY a un elemento componible Image
Elementos translation

translationX y translationY se pueden cambiar con graphicsLayer, mientras que translationX mueve el elemento componible hacia la izquierda o la derecha. translationY mueve el elemento componible hacia arriba o abajo.

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

Figura 5: Uso de translationX y translationY a Image con Modifier.graphicsLayer
Rotación

Configura rotationX para rotar de forma horizontal, rotationY para rotar de forma vertical y rotationZ para rotar en el eje Z (rotación estándar). Este valor se especifica en grados (0-360).

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

Figura 6: Valores de rotationX, rotationY y rotationZ establecidos en Image con Modifier.graphicsLayer
Origen

Se puede especificar un transformOrigin. Luego, se usa como el punto desde el que se realizan las transformaciones. Todos los ejemplos hasta ahora utilizaron TransformOrigin.Center, que está en (0.5f, 0.5f). Si especificas el origen en (0f, 0f), las transformaciones comienzan desde la esquina superior izquierda del elemento componible.

Si cambias el origen con una transformación rotationZ, podrás ver que el elemento rota alrededor de la parte superior izquierda del elemento componible:

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
        }
)

Figura 7: La rotación se aplica con TransformOrigin configurado en 0f, 0f

Recorte y forma

La forma es el contorno al que se recorta el contenido cuando clip = true. En este ejemplo, configuramos dos cuadros para tener dos recortes diferentes: uno con la variable de recorte graphicsLayer y el otro con el wrapper práctico 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))
    )
}

El contenido del primer cuadro (el texto que dice "Hello Compose") se recorta con la forma de un círculo:

Recorte aplicado a un elemento componible Box
Figura 8: Un recorte aplicado a un elemento componible Box

Si luego aplicas translationY al círculo rosa superior, verás que los márgenes del elemento componible siguen siendo los mismos, pero el círculo se dibuja debajo del círculo inferior (y fuera de sus límites).

Recorte aplicado con translationY y margen rojo para el contorno
Figura 9: Recorte aplicado con translationY y margen rojo para el contorno

Para recortar el elemento componible conforme a la región en la que se dibuja, puedes agregar otro Modifier.clip(RectangleShape) al comienzo de la cadena de modificadores. El contenido permanece dentro de los márgenes originales.

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))
    )
}

Recorte aplicado sobre la transformación de graphicsLayer
Figura 10: Recorte aplicado sobre la transformación de graphicsLayer

Alfa

Se puede usar Modifier.graphicsLayer para establecer un valor alpha (opacidad) para toda la capa. 1.0f es completamente opaco y 0.0f es invisible.

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

Uso de alfa en la imagen
Figura 11: Uso de alfa en una imagen

Estrategia de composición

Trabajar con alfa y transparencia podría no ser tan simple como cambiar un solo valor alfa. Además de cambiar un valor alfa, también existe la opción de establecer una CompositingStrategy en una graphicsLayer. Una CompositingStrategy determina cómo está compuesto el contenido del elemento componible (cómo está unido) con el resto del contenido que ya se dibujó en la pantalla.

Las diferentes estrategias son las siguientes:

Automática (configuración predeterminada)

La estrategia de composición se determina mediante el resto de los parámetros de graphicsLayer. Renderiza la capa en un búfer fuera de pantalla si el valor alfa es menor que 1.0f o si se configura un RenderEffect. Cuando el valor alfa es menor que 1f, se crea automáticamente una capa compuesta para renderizar el contenido y, luego, se dibuja este búfer fuera de pantalla en el destino con el valor alfa correspondiente. Si se establece un RenderEffect o un sobredesplazamiento, siempre se renderiza el contenido en un búfer fuera de pantalla, sin importar la configuración de CompositingStrategy.

Fuera de pantalla

El contenido del elemento componible siempre se rasteriza en una textura o un mapa de bits fuera de la pantalla antes de renderizarlos en el destino. Esto es útil para aplicar operaciones BlendMode a fin de enmascarar contenido y mejorar el rendimiento cuando se renderizan conjuntos complejos de instrucciones de dibujo.

Un ejemplo del uso de CompositingStrategy.Offscreen es con BlendModes. En el siguiente ejemplo, supongamos que deseas quitar partes de un elemento componible Image con un comando de dibujo que usa BlendMode.Clear. Si no configuras compositingStrategy como CompositingStrategy.Offscreen, BlendMode interactúa con todo el contenido debajo de él.

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
                    )
                )
            }
        }
)

Cuando se establece CompositingStrategy en Offscreen, se crea una textura fuera de pantalla para ejecutar los comandos (se aplica BlendMode solo al contenido de este elemento componible). Luego, lo renderiza sobre lo que ya se renderizó en la pantalla, sin afectar el contenido ya dibujado.

Modifier.drawWithContent en una imagen que muestra un indicador de círculo con BlendMode.Clear dentro de la app
Figura 12: Modifier.drawWithContent en una imagen que muestra un indicador de círculo con BlendMode.Clear y CompositingStrategy.Offscreen dentro de la app

Si no usaste CompositingStrategy.Offscreen, como resultado de aplicar BlendMode.Clear se borrarán todos los píxeles del destino, independientemente de lo que se haya establecido, lo que dejará visible el búfer de renderización de la ventana (negro). Muchos de los BlendModes que usan alfa no funcionarán como se espera sin un búfer fuera de la pantalla. Observa el anillo negro alrededor del indicador de círculo rojo:

Modifier.drawWithContent en una imagen que muestra un indicador de círculo con BlendMode.Clear, pero sin CompositingStrategy configurado
Figura 13: Modifier.drawWithContent en una imagen que muestra un indicador de círculo con BlendMode.Clear, pero sin CompositingStrategy configurado

Para facilitar la comprensión: Si la app tiene un fondo de ventana translúcida y no usas CompositingStrategy.Offscreen, entonces BlendMode interactuará con toda la app. Se borrarán todos los píxeles para mostrar la app o el fondo de pantalla debajo, como se ve en este ejemplo:

No se configuró CompositingStrategy y se usó BlendMode.Clear con una app que tiene un fondo de ventana translúcido El fondo de pantalla rosa se puede ver en el área alrededor del círculo de estado rojo.
Figura 14: No se configuró CompositingStrategy y se usó BlendMode.Clear con una app que tiene un fondo de ventana translúcido. Observa cómo el fondo de pantalla rosa se puede ver en el área alrededor del círculo de estado rojo.

Ten en cuenta que, cuando usas CompositingStrategy.Offscreen, se crea una textura fuera de pantalla del tamaño del área de trazado y se renderiza en la pantalla. De forma predeterminada, los comandos de dibujo que se realizan con esta estrategia, se ajustan a esta región al recortarse. En el siguiente fragmento de código, se muestran las diferencias al usar texturas fuera de pantalla:

@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 versus CompositingStrategy.Offscreen: recortes fuera de la pantalla que se ajustan a la región, a diferencia de la función automática
Figura 15: CompositingStrategy.Auto frente a CompositingStrategy.Offscreen: recortes fuera de la pantalla que se ajustan a la región, a diferencia de la función automática
ModulateAlpha

Esta estrategia de composición modula el valor alfa de cada una de las instrucciones de dibujo registradas en graphicsLayer. No creará un búfer fuera de pantalla para un valor alfa inferior a 1.0f, a menos que se establezca un RenderEffect, por lo que puede ser más eficiente para la renderización de alfa. Sin embargo, puede proporcionar resultados diferentes para el contenido superpuesto. Para los casos de uso en los que se sabe de antemano que el contenido no se superpone, esto puede proporcionar un mejor rendimiento que CompositingStrategy.Auto con valores alfa menores que 1.

A continuación, se incluye otro ejemplo de estrategias de composición; la aplicación de valores alfa distintos a partes diferentes de los elementos componibles, y la aplicación de una estrategia 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 aplica el conjunto alfa a cada comando de dibujo individual
Figura 16: ModulateAlpha aplica el conjunto alfa a cada comando de dibujo individual

Cómo escribir el contenido de un elemento componible en un mapa de bits

Un caso de uso común es crear un Bitmap a partir de un elemento componible. Para copiar el contenido de tu elemento componible a un Bitmap, crea un GraphicsLayer con rememberGraphicsLayer().

Redirecciona los comandos de dibujo a la nueva capa con drawWithContent() y graphicsLayer.record{}. Luego, dibuja la capa en el lienzo visible con 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)
}

Puedes guardar el mapa de bits en el disco y compartirlo. Para obtener más detalles, consulta el fragmento de ejemplo completo. Asegúrate de verificar los permisos del dispositivo antes de intentar guardar en el disco.

Modificador de dibujo personalizado

Para crear tu propio modificador personalizado, implementa la interfaz DrawModifier. De esta manera, tendrás acceso a un ContentDrawScope, que es lo mismo que se expone cuando usas Modifier.drawWithContent(). Luego, puedes extraer operaciones comunes de dibujo en modificadores personalizados de dibujo para limpiar el código y proporcionar wrappers prácticos. Por ejemplo, Modifier.background() es un DrawModifier conveniente.

Por ejemplo, si deseas implementar un Modifier que gire contenido de forma vertical, puedes crear uno de la siguiente manera:

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

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

Luego, aplica este modificador invertido en Text:

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

Modificador invertido personalizado en el texto
Figura 17: Modificador invertido personalizado en el texto

Recursos adicionales

Para obtener más ejemplos del uso de graphicsLayer y los dibujos personalizados, consulta los siguientes recursos: