Gráficos no Compose

Com o Jetpack Compose, fica mais fácil trabalhar com gráficos personalizados. Muitos apps precisam ter controle preciso sobre o que é desenhado na tela. Isso pode ser tão simples quanto colocar uma caixa ou um círculo no lugar certo da tela ou pode ser uma organização elaborada de elementos gráficos em vários estilos diferentes. Com a abordagem declarativa do Compose, toda a configuração gráfica ocorre em um só lugar, em vez de ser dividida entre uma chamada de método e um objeto auxiliar Paint. O Compose cuida da criação e atualização dos objetos necessários de forma eficiente.

Gráficos declarativos com o Compose

O Compose amplia a abordagem declarativa para a forma como ele processa gráficos. A abordagem do Compose apresenta diversas vantagens:

  • O Compose minimiza o state em elementos gráficos, o que ajuda a evitar dificuldades de programação do estado.
  • Quando algo é desenhado, todas as opções ficam no local esperado, dentro da função que pode ser composta.
  • As APIs gráficas do Compose cuidam da criação e da liberação de objetos de forma eficiente.

Canvas

A principal função que pode ser composta para gráficos personalizados é Canvas. A Canvas é posicionada no layout da mesma forma que qualquer outro elemento da IU do Compose. Na Canvas, é possível exibir elementos com controle preciso sobre o estilo e localização.

Por exemplo, o código a seguir cria Canvas que preenche todo o espaço disponível no elemento pai:

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

Canvas expõe automaticamente um DrawScope, ambiente de desenho com escopo que mantém o próprio estado. Isso permite que você defina os parâmetros de um grupo de elementos gráficos. O DrawScope oferece vários campos úteis, como size, um objeto Size que especifica as dimensões atuais e máximas do DrawScope.

Por exemplo, suponha que você queira desenhar uma linha diagonal do canto superior direito da tela para o canto inferior esquerdo. Isso poderia ser feito adicionando uma função drawLine que pode ser composta

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

Smartphone com uma linha fina desenhada na diagonal da tela

Figura 1. Uso do drawLine para desenhar uma linha na tela. O código define a cor da linha, mas usa a largura padrão.

É possível usar outros parâmetros para personalizar o desenho. Por exemplo, por padrão, a linha é desenhada com uma espessura fina, que é exibida como um pixel de largura, independentemente da escala do desenho. É possível modificar o padrão definindo um 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
    )
}

Smartphone com uma linha mais grossa desenhada na diagonal da tela.

Figura 2. Modificação da linha da figura 1, substituindo a largura padrão.

Existem muitas outras funções de desenho simples, como drawRect e drawCircle. Por exemplo, o código a seguir desenha um círculo preenchido no meio da tela, com diâmetro igual à metade da menor dimensão da tela:

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

Smartphone com um círculo azul centralizado na tela.

Figura 3. Uso de drawCircle para exibir um círculo no centro da tela. Por padrão, drawCircle desenha um círculo preenchido, portanto, não é necessário especificar essa configuração explicitamente.

As funções de desenho têm parâmetros padrão úteis. Por exemplo, por padrão, drawRectangle() preenche todo o escopo pai e drawCircle() tem um raio igual à metade da menor dimensão do pai. Como sempre é o caso com o Kotlin, é possível simplificar e facilitar ainda mais seu código aproveitando os valores dos parâmetros padrão e definindo somente os parâmetros que precisam ser modificados. Isso pode ser aproveitado ao fornecer parâmetros explícitos para os métodos de desenho do DrawScope, já que as configurações padrão dos elementos desenhados terão como base as configurações do escopo pai.

DrawScope

Como já observado, cada Canvas do Compose expõe um DrawScope, um ambiente de desenho com escopo, em que você emite os comandos de desenho.

Por exemplo, o código a seguir desenha um retângulo no canto superior esquerdo da tela:

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

É possível usar a função DrawScope.inset() para ajustar os parâmetros padrão do escopo atual, mudar os limites de desenho e realizar a translação dos desenhos adequadamente. Operações como inset() são válidas para todas as operações de desenho no lambda correspondente:

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

DrawScope oferece outras transformações simples, como rotate(). Por exemplo, o código a seguir desenha um retângulo no centro que preenche um nono da tela:

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
)

Smartphone com um retângulo preenchido no centro da tela.

Figura 4. Uso de drawRect para desenhar um retângulo preenchido no centro da tela.

É possível girar o retângulo aplicando uma rotação ao DrawScope:

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

Smartphone com um retângulo girado em 45 graus no centro da tela.

Figura 5. Usamos rotate() para aplicar uma rotação ao escopo de desenho atual, que gira o retângulo em 45 graus.

Caso você queira aplicar várias transformações aos desenhos, a melhor abordagem é não criar ambientes DrawScope aninhados. Em vez disso, use a função withTransform(), que cria e aplica uma única transformação que combina todas as mudanças esperadas. Usar withTransform() é mais eficiente que fazer chamadas aninhadas para transformações individuais, porque, dessa forma, todas as transformações serão executadas em uma única operação e o Compose não precisará calcular e salvar cada uma das transformações aninhadas.

Por exemplo, o código a seguir aplica uma translação e uma rotação ao retângulo:

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

Smartphone com um retângulo rotacionado, deslocado para a lateral da tela.

Figura 6. Aqui, usamos withTransform para aplicar uma rotação e uma translação, girando e deslocando o retângulo para a esquerda.