Gráficos en Compose

Jetpack Compose facilita el trabajo con gráficos personalizados. Muchas apps deben poder controlar con precisión lo que se dibuja en la pantalla. Puede ser en algo tan pequeño como colocar un cuadrado o un círculo en la pantalla en el lugar correcto, o podría ser una disposición compleja de elementos gráficos de muchos estilos diferentes. Con el enfoque declarativo de Compose, toda la configuración gráfica se produce en un solo lugar, en lugar de dividir entre una llamada de método y un objeto auxiliar Paint. Compose se encarga de crear y actualizar los objetos necesarios de forma eficiente.

Gráficos declarativos con Compose

Compose extiende su enfoque declarativo respecto de cómo maneja los gráficos. El enfoque de Compose ofrece varias ventajas:

  • Compose minimiza el estado en sus elementos gráficos, lo que te ayuda a evitar las dificultades de la programación de estados.
  • Cuando dibujas algo, todas las opciones se muestran donde lo esperas: en la función que admite composición.
  • Las API de gráficos de Compose se encargan de crear y liberar objetos de forma eficiente.

Canvas

El elemento componible principal para gráficos personalizados es Canvas. Coloca el Canvas en tu diseño de la misma manera que lo harías con cualquier otro elemento de la IU de Compose. Dentro de Canvas, puedes dibujar elementos con un control preciso sobre su estilo y ubicación.

Por ejemplo, este código crea un elemento componible Canvas que llena todo el espacio disponible en su elemento principal:

Canvas(modifier = Modifier.fillMaxSize()) {
}

Canvas expone automáticamente un DrawScope, un entorno de dibujo con alcance que mantiene su propio estado. Eso te permite establecer los parámetros de un grupo de elementos gráficos. El DrawScope proporciona varios campos útiles, como size, un objeto Size que especifica las dimensiones actuales y máximas del DrawScope.

Por ejemplo, imagina que deseas dibujar una línea diagonal desde la esquina superior derecha del lienzo hasta la esquina inferior izquierda. Para hacerlo, agrega una opción drawLine componible:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height

    drawLine(
        start = Offset(x = canvasWidth, y = 0f),
        end = Offset(x = 0f, y = canvasHeight),
        color = Color.Blue
    )
}

Un teléfono con una línea delgada dibujada diagonalmente en toda la pantalla.

Figura 1: Usa drawLine para dibujar una línea a lo largo del lienzo. El código establece el color de la línea, pero usa el ancho predeterminado.

Puedes usar otros parámetros para personalizar el dibujo. Por ejemplo, de forma predeterminada, la línea se dibuja con un ancho de línea, que se muestra como un pixel de ancho, independientemente de la escala del dibujo. Puedes anular el valor predeterminado configurando un valor strokeWidth:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height

    drawLine(
        start = Offset(x = canvasWidth, y = 0f),
        end = Offset(x = 0f, y = canvasHeight),
        color = Color.Blue,
        strokeWidth = 5F
    )
}

Un teléfono con una línea gruesa que dibujada diagonalmente en toda la pantalla.

Figura 2: Modifica la línea de la Figura 1 anulando el ancho predeterminado.

Hay muchas otras funciones de dibujo simples, como drawRect y drawCircle. Por ejemplo, este código dibuja un círculo con relleno en medio del lienzo, con un diámetro que equivale a la mitad de la dimensión más corta del lienzo:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasWidth = size.width
    val canvasHeight = size.height
    drawCircle(
        color = Color.Blue,
        center = Offset(x = canvasWidth / 2, y = canvasHeight / 2),
        radius = size.minDimension / 4
    )
}

Un teléfono con un círculo azul centrado en la pantalla.

Figura 3: Usa drawCircle para colocar un círculo en el centro del lienzo. De forma predeterminada, drawCircle dibuja un círculo relleno, por lo que no es necesario especificar esa configuración de forma explícita.

Las funciones de dibujo tienen parámetros predeterminados útiles. Por ejemplo, de forma predeterminada, drawRectangle() completa todo el alcance de su elemento superior, y drawCircle() tiene un radio que equivale a la mitad de la dimensión más corta de su elemento superior. Como siempre ocurre con Kotlin, puedes hacer que tu código sea mucho más simple y claro aprovechando los valores de parámetros predeterminados y estableciendo únicamente los parámetros que necesitas cambiar. Puedes aprovechar esto proporcionando parámetros explícitos a los métodos de dibujo de DrawScope, ya que tus elementos dibujados definirán su configuración predeterminada en la configuración del alcance del elemento superior.

DrawScope

Como se mencionó, cada Canvas de Compose expone un DrawScope, un entorno de dibujo con alcance, en el que emites tus comandos de dibujo.

Por ejemplo, el siguiente código dibuja un rectángulo en la esquina superior izquierda del lienzo:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasQuadrantSize = size / 2F
    drawRect(
        color = Color.Green,
        size = canvasQuadrantSize
    )
}

Puedes usar la función DrawScope.inset() para ajustar los parámetros predeterminados del alcance actual, cambiar los límites del dibujo y traducir los dibujos según corresponda. Las operaciones como inset() se aplican a todas las operaciones de dibujo dentro de la lambda correspondiente:

val canvasQuadrantSize = size / 2F
inset(50F, 30F) {
    drawRect(
        color = Color.Green,
        size = canvasQuadrantSize
    )
}

DrawScope ofrece otras transformaciones simples, como rotate(). Por ejemplo, este código dibuja un rectángulo que rellena el noveno medio del lienzo:

val canvasSize = size
val canvasWidth = size.width
val canvasHeight = size.height
drawRect(
    color = Color.Gray,
    topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
    size = canvasSize / 3F
)

Un teléfono con un rectángulo relleno en el medio de la pantalla.

Figura 4: Usa drawRect para dibujar un rectángulo relleno en el medio de la pantalla.

Puedes rotar el rectángulo aplicando una rotación en su DrawScope:

rotate(degrees = 45F) {
    drawRect(
        color = Color.Gray,
        topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
        size = canvasSize / 3F
    )
}

Un teléfono con un rectángulo rotado de 45 grados en el centro de la pantalla.

Figura 5: Usamos rotate() para aplicar una rotación al alcance del dibujo actual, que rota el rectángulo en 45 grados.

Si deseas aplicar varias transformaciones a tus dibujos, el mejor enfoque es no crear entornos DrawScope anidados. En su lugar, debes usar la función withTransform(), que crea y aplica una sola transformación que combina todos los cambios deseados. El uso de withTransform() es más eficiente que realizar llamadas anidadas a transformaciones individuales, ya que todas las transformaciones se realizan juntas en una sola operación, en lugar de que Compose deba calcular y guardar cada una de las transformaciones anidadas.

Por ejemplo, este código aplica una traducción y una rotación al rectángulo:

withTransform({
    translate(left = canvasWidth / 5F)
    rotate(degrees = 45F)
}) {
    drawRect(
        color = Color.Gray,
        topLeft = Offset(x = canvasWidth / 3F, y = canvasHeight / 3F),
        size = canvasSize / 3F
    )
}

Un teléfono con un rectángulo rotado hacia un lado de la pantalla.

Figura 6: Aquí usamos withTransform para aplicar tanto una rotación como una traducción, girando el rectángulo y moviéndolo a la izquierda.