Gráficos no Compose

Vários apps exigem um controle preciso sobre o que é desenhado na tela. Isso pode ser algo simples, como 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.

Desenho básico com modificadores e DrawScope

A principal forma de desenhar algo personalizado no Compose é com modificadores, como Modifier.drawWithContent, Modifier.drawBehind e Modifier.drawWithCache.

Por exemplo, se quiser desenhar algo atrás do seu elemento combinável, você pode usar o modificador drawBehind para começar a executar comandos de desenho:

Spacer(
    modifier = Modifier
        .fillMaxSize()
        .drawBehind {
            // this = DrawScope
        }
)

Se você só precisa de um combinável, use o elemento Canvas. Ele atua como um wrapper conveniente ao redor do Modifier.drawBehind. O Canvas é posicionado no layout da mesma forma que qualquer outro elemento de interface do Compose. Dentro do Canvas, é possível desenhar elementos com controle preciso sobre o estilo e a localização.

Todos os modificadores de desenho expõem um DrawScope, um 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 fornece vários campos úteis, como size, um objeto Size que especifica as dimensões atuais do DrawScope.

Para desenhar algo, você pode usar uma das várias funções de desenho no DrawScope. 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.Magenta,
        size = canvasQuadrantSize
    )
}

Retângulo rosa desenhado em um fundo branco que ocupa um quarto da tela
Figura 1. Retângulo desenhado usando Canvas no Compose.

Para saber mais sobre os diferentes modificadores de desenho, consulte a documentação Modificadores gráficos.

Sistema de coordenadas

Para desenhar algo na tela, é necessário saber o deslocamento (x e y) e o tamanho do item. Em vários dos métodos de desenho no DrawScope, a posição e o tamanho são fornecidos por valores de parâmetro padrão. Os parâmetros padrão geralmente posicionam o item no ponto [0, 0] da tela e fornecem um size padrão que preenche toda a área de desenho, como no exemplo acima. Observe que o retângulo está posicionado no canto superior esquerdo. Para ajustar o tamanho e a posição do item, é necessário entender o sistema de coordenadas no Compose.

A origem do sistema de coordenadas ([0,0]) está no pixel superior esquerdo na área de desenho. O x aumenta com o movimento para a direita, e o y com o movimento para baixo.

Grade com o sistema de coordenadas mostrando o canto superior esquerdo [0, 0] e o canto inferior direito [largura, altura]
Figura 2. Sistema de coordenadas / grade de desenho.

Por exemplo, se você quiser desenhar uma linha diagonal do canto superior direito da área da tela até o canto inferior esquerdo, use a função DrawScope.drawLine() e especifique o deslocamento inicial e final com as posições correspondentes de x e y:

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

Transformações básicas

O DrawScope oferece transformações para mudar onde ou como os comandos de desenho são executados.

Escala

Use DrawScope.scale() para aumentar o tamanho das operações de desenho por um fator. Operações como scale() são válidas para todas as operações de desenho no lambda correspondente. Por exemplo, o código a seguir aumenta a scaleX em 10 vezes e a scaleY em 15 vezes:

Canvas(modifier = Modifier.fillMaxSize()) {
    scale(scaleX = 10f, scaleY = 15f) {
        drawCircle(Color.Blue, radius = 20.dp.toPx())
    }
}

Um círculo dimensionado de maneira não uniforme
Figura 3. Uma operação de escalonamento aplicada a um círculo no Canvas.

Translação

Use DrawScope.translate() para mover as operações de desenho para cima, para baixo, para a esquerda ou para a direita. Por exemplo, o código abaixo move o desenho 100 px para a direita e 300 px para cima:

Canvas(modifier = Modifier.fillMaxSize()) {
    translate(left = 100f, top = -300f) {
        drawCircle(Color.Blue, radius = 200.dp.toPx())
    }
}

Um círculo movido para fora do centro
Figura 4. Uma operação de translação aplicada a um círculo no Canvas.

Rotação

Use DrawScope.rotate() para girar as operações de desenho em torno de um ponto. Por exemplo, o código a seguir gira um retângulo 45 graus:

Canvas(modifier = Modifier.fillMaxSize()) {
    rotate(degrees = 45F) {
        drawRect(
            color = Color.Gray,
            topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
            size = size / 3F
        )
    }
}

Um smartphone com um retângulo rotacionado em 45 graus no centro da tela
Figura 5. Usamos rotate() para aplicar uma rotação ao escopo de desenho atual, girando o retângulo em 45 graus.

Encarte

Use DrawScope.inset() para ajustar os parâmetros padrão do DrawScope atual, mudando os limites e fazendo a translação dos desenhos adequadamente:

Canvas(modifier = Modifier.fillMaxSize()) {
    val canvasQuadrantSize = size / 2F
    inset(horizontal = 50f, vertical = 30f) {
        drawRect(color = Color.Green, size = canvasQuadrantSize)
    }
}

Esse código adiciona padding aos comandos de desenho:

Um retângulo com padding ao redor dele
Figura 6. Um encarte aplicado a comandos de desenho.

Várias transformações

Para aplicar várias transformações aos seus desenhos, use a função DrawScope.withTransform(). Ela cria e aplica uma única transformação que combina todas as mudanças desejadas. Usar withTransform() é mais eficiente do que fazer chamadas aninhadas para transformações individuais. Isso ocorre porque todas as transformações são executadas em uma única operação, e o Compose não precisa 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:

Canvas(modifier = Modifier.fillMaxSize()) {
    withTransform({
        translate(left = size.width / 5F)
        rotate(degrees = 45F)
    }) {
        drawRect(
            color = Color.Gray,
            topLeft = Offset(x = size.width / 3F, y = size.height / 3F),
            size = size / 3F
        )
    }
}

Um smartphone com um retângulo rotacionado, deslocado para a lateral da tela
Figura 7. A função withTransform foi usada para aplicar uma rotação e uma translação, girando e deslocando o retângulo para a esquerda.

Operações comuns de desenho

Desenhar texto

Para desenhar texto no Compose, normalmente você pode usar o elemento combinável Text. No entanto, se você estiver em um DrawScope ou quiser desenhar o texto manualmente com personalização, use o método DrawScope.drawText().

Para desenhar texto, crie um TextMeasurer usando rememberTextMeasurer e chame drawText com o medidor:

val textMeasurer = rememberTextMeasurer()

Canvas(modifier = Modifier.fillMaxSize()) {
    drawText(textMeasurer, "Hello")
}

O texto "Hello" aparece desenhado no Canvas
Figura 8. Texto desenhado no Canvas.

Medir o texto

Desenhar texto funciona de forma um pouco diferente de outros comandos de desenho. Normalmente, você dá ao comando de desenho o tamanho (largura e altura) para desenhar a forma/imagem. No caso do texto, há alguns parâmetros que controlam o tamanho renderizado, como tamanho da fonte, tipos de fontes, ligaduras e espaçamento entre letras.

Com o Compose, é possível usar um TextMeasurer para ter acesso ao tamanho de texto medido, dependendo dos fatores acima. Se você quiser desenhar um plano de fundo por trás do texto, poderá usar as informações medidas para descobrir o tamanho da área em que o texto aparece:

val textMeasurer = rememberTextMeasurer()

Spacer(
    modifier = Modifier
        .drawWithCache {
            val measuredText =
                textMeasurer.measure(
                    AnnotatedString(longTextSample),
                    constraints = Constraints.fixedWidth((size.width * 2f / 3f).toInt()),
                    style = TextStyle(fontSize = 18.sp)
                )

            onDrawBehind {
                drawRect(pinkColor, size = measuredText.size.toSize())
                drawText(measuredText)
            }
        }
        .fillMaxSize()
)

Este snippet de código produz um plano de fundo rosa para o texto:

Texto com várias linhas ocupando ⅔ da área total e um retângulo como plano de fundo
Figura 9. Texto com várias linhas ocupando ⅔ da área total e um retângulo como plano de fundo.

Ajustar as restrições, o tamanho da fonte ou qualquer propriedade que afete o tamanho medido resulta em um novo tamanho informado. É possível definir um tamanho fixo para a width e a height, e o texto vai seguir o TextOverflow definido. Por exemplo, o código a seguir renderiza o texto em ⅓ da altura e ⅓ da largura da área combinável e define o TextOverflow como TextOverflow.Ellipsis:

val textMeasurer = rememberTextMeasurer()

Spacer(
    modifier = Modifier
        .drawWithCache {
            val measuredText =
                textMeasurer.measure(
                    AnnotatedString(longTextSample),
                    constraints = Constraints.fixed(
                        width = (size.width / 3f).toInt(),
                        height = (size.height / 3f).toInt()
                    ),
                    overflow = TextOverflow.Ellipsis,
                    style = TextStyle(fontSize = 18.sp)
                )

            onDrawBehind {
                drawRect(pinkColor, size = measuredText.size.toSize())
                drawText(measuredText)
            }
        }
        .fillMaxSize()
)

Agora, o texto é desenhado dentro das restrições, com reticências no final:

Texto desenhado em um plano de fundo rosa, terminando com reticências
Figura 10. TextOverflow.Ellipsis com restrições fixas na medição do texto.

Desenhar imagem

Para desenhar uma ImageBitmap com DrawScope, carregue a imagem usando ImageBitmap.imageResource() e, em seguida, chame drawImage:

val dogImage = ImageBitmap.imageResource(id = R.drawable.dog)

Canvas(modifier = Modifier.fillMaxSize(), onDraw = {
    drawImage(dogImage)
})

Imagem de um cachorro desenhada no Canvas
Figura 11. Desenho de uma ImageBitmap no Canvas.

Desenhar formas básicas

Há várias funções de desenho de forma no DrawScope. Para desenhar uma forma, use uma das funções de desenho predefinidas, como drawCircle:

val purpleColor = Color(0xFFBA68C8)
Canvas(
    modifier = Modifier
        .fillMaxSize()
        .padding(16.dp),
    onDraw = {
        drawCircle(purpleColor)
    }
)

API

Saída

drawCircle()

drawCircle

drawRect()

drawRect

drawRoundedRect()

drawRoundedRect

drawLine()

drawLine

drawOval()

drawOval

drawArc()

drawArc

drawPoints()

drawPoints

Desenhar caminho

Um caminho é uma série de instruções matemáticas que resultam em um desenho depois de executado. O DrawScope pode desenhar um caminho usando o método DrawScope.drawPath().

Por exemplo, digamos que você queira desenhar um triângulo. É possível gerar um caminho com funções como lineTo() e moveTo() usando o tamanho da área de desenho. Em seguida, chame drawPath() com esse caminho recém-criado para que um triângulo apareça na tela.

Spacer(
    modifier = Modifier
        .drawWithCache {
            val path = Path()
            path.moveTo(0f, 0f)
            path.lineTo(size.width / 2f, size.height / 2f)
            path.lineTo(size.width, 0f)
            path.close()
            onDrawBehind {
                drawPath(path, Color.Magenta, style = Stroke(width = 10f))
            }
        }
        .fillMaxSize()
)

Triângulo de caminho roxo invertido desenhado no Compose
Figura 12. Um Path criado e desenhado no Compose.

Como acessar o objeto Canvas

Com o DrawScope, você não tem acesso direto a um objeto Canvas. Você pode usar DrawScope.drawIntoCanvas() para ter acesso a ele e usá-lo para chamar funções.

Por exemplo, se você tem um Drawable personalizado em que gostaria de desenhar, acesse a tela e chame Drawable#draw(), transmitindo o objeto Canvas:

val drawable = ShapeDrawable(OvalShape())
Spacer(
    modifier = Modifier
        .drawWithContent {
            drawIntoCanvas { canvas ->
                drawable.setBounds(0, 0, size.width.toInt(), size.height.toInt())
                drawable.draw(canvas.nativeCanvas)
            }
        }
        .fillMaxSize()
)

Um ShapeDrawable preto oval ocupando todo o espaço
Figura 13. O Canvas é acessado para desenhar um Drawable.

Saiba mais

Para mais informações sobre como desenhar no Compose, consulte estes recursos: