Formas no Compose

Com o Compose, é possível criar formas feitas de polígonos. Por exemplo, é possível fazer os seguintes tipos de formas:

Hexágono azul no centro da área de desenho
Figura 1. Exemplos de diferentes formas que podem ser feitas com a biblioteca de formas gráficas

Para criar um polígono arredondado personalizado no Compose, adicione a dependência graphics-shapes ao app/build.gradle:

implementation "androidx.graphics:graphics-shapes:1.0.0-rc01"

Essa biblioteca permite criar formas feitas de polígonos. Embora as formas poligonais tenham apenas bordas retas e cantos retos, essas formas permitem cantos arredondados opcionais. Ele simplifica a transformação entre duas formas diferentes. A transformação entre formas arbitrárias é difícil e tende a ser um problema do tempo de design. No entanto, essa biblioteca simplifica o processo ao transformar essas formas em estruturas poligonais semelhantes.

Criar polígonos

O snippet a seguir cria uma forma de polígono básica com seis pontos no centro da área de desenho:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 6,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Blue)
            }
        }
        .fillMaxSize()
)

Hexágono azul no centro da área de desenho
Figura 2. Hexágono azul no centro da área de desenho.

Neste exemplo, a biblioteca cria um RoundedPolygon que contém a geometria que representa a forma solicitada. Para desenhar essa forma em um app do Compose, é necessário extrair um objeto Path dele para que a forma seja colocada em uma forma que o Compose saiba desenhar.

Arredondar os cantos de um polígono

Para arredondar os cantos de um polígono, use o parâmetro CornerRounding. Isso usa dois parâmetros, radius e smoothing. Cada canto arredondado é composto de uma a três curvas cúbicas, e o centro tem um formato de arco circular, enquanto as duas curvas laterais ("laterais") fazem a transição da borda da forma para a curva central.

Radius

O radius é o raio do círculo usado para arredondar um vértice.

Por exemplo, o triângulo de canto arredondado a seguir é feito da seguinte maneira:

Triângulo com cantos arredondados
Figura 3. Triângulo com cantos arredondados.
O raio de arredondamento r determina o tamanho do arredondamento de cantos arredondados.
Figura 4. O raio de arredondamento r determina o tamanho do arredondamento circular dos cantos arredondados.

Suavização

A suavização é um fator que determina quanto tempo leva para ir da parte arredondada circular do canto até a borda. Um fator de suavização de 0 (não suavizado, o valor padrão de CornerRounding) resulta em arredondamento de canto puramente circular. Um fator de suavização diferente de zero (até o máximo de 1,0) faz com que o canto seja arredondado por três curvas separadas.

Um fator de suavização de 0 (sem suavização) produz uma única curva cúbica que
segue um círculo ao redor do canto com o raio de arredondamento especificado, como no
exemplo anterior.
Figura 5. Um fator de suavização de 0 (sem suavização) produz uma única curva cúbica que segue um círculo ao redor do canto com o raio de arredondamento especificado, como no exemplo anterior.
Um fator de suavização diferente de zero produz três curvas cúbicas para arredondar o vértice: a curva circular interna (como antes) mais duas curvas paralelas que fazem a transição entre a curva interna e as bordas do polígono.
Figura 6. Um fator de suavização diferente de zero produz três curvas cúbicas para arredondar o vértice: a curva circular interna (como antes) e mais duas curvas laterais que fazem a transição entre a curva interna e as bordas do polígono.

Por exemplo, o snippet abaixo ilustra a diferença sutil na configuração de suavização para 0 em vez de 1:

Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygon = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2,
                centerX = size.width / 2,
                centerY = size.height / 2,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val roundedPolygonPath = roundedPolygon.toPath().asComposePath()
            onDrawBehind {
                drawPath(roundedPolygonPath, color = Color.Black)
            }
        }
        .size(100.dp)
)

Dois triângulos pretos mostrando a diferença no parâmetro de
suavização.
Figura 7. Dois triângulos pretos mostrando a diferença no parâmetro de suavização.

Tamanho e posição

Por padrão, uma forma é criada com um raio de 1 ao redor do centro (0, 0). Esse raio representa a distância entre o centro e os vértices externos do polígono em que a forma é baseada. Arredondar os cantos resulta em uma forma menor, já que os cantos arredondados estarão mais próximos do centro do que os vértices sendo arredondados. Para dimensionar um polígono, ajuste o valor radius. Para ajustar a posição, mude o centerX ou a centerY do polígono. Como alternativa, transforme o objeto para mudar o tamanho, a posição e a rotação usando funções de transformação DrawScope padrão, como DrawScope#translate().

Transformar formas

Um objeto Morph é uma nova forma que representa uma animação entre duas formas poligonais. Para transformar duas formas, crie duas RoundedPolygons e um objeto Morph que use essas duas formas. Para calcular uma forma entre as formas inicial e final, forneça um valor progress entre zero e um para determinar a forma entre as formas inicial (0) e final (1):

Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = 0.5f).asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

No exemplo acima, o progresso está exatamente no meio das duas formas (triângulo arredondado e um quadrado), produzindo o seguinte resultado:

50% do caminho entre um triângulo arredondado e um quadrado
Figura 8. 50% do caminho entre um triângulo arredondado e um quadrado.

Na maioria dos casos, a transformação é feita como parte de uma animação, e não apenas uma renderização estática. Para animar entre esses dois, você pode usar as APIs Animation no Compose padrão para mudar o valor do progresso ao longo do tempo. Por exemplo, é possível animar infinitamente a transformação entre essas duas formas da seguinte maneira:

val infiniteAnimation = rememberInfiniteTransition(label = "infinite animation")
val morphProgress = infiniteAnimation.animateFloat(
    initialValue = 0f,
    targetValue = 1f,
    animationSpec = infiniteRepeatable(
        tween(500),
        repeatMode = RepeatMode.Reverse
    ),
    label = "morph"
)
Box(
    modifier = Modifier
        .drawWithCache {
            val triangle = RoundedPolygon(
                numVertices = 3,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f,
                rounding = CornerRounding(
                    size.minDimension / 10f,
                    smoothing = 0.1f
                )
            )
            val square = RoundedPolygon(
                numVertices = 4,
                radius = size.minDimension / 2f,
                centerX = size.width / 2f,
                centerY = size.height / 2f
            )

            val morph = Morph(start = triangle, end = square)
            val morphPath = morph
                .toPath(progress = morphProgress.value)
                .asComposePath()

            onDrawBehind {
                drawPath(morphPath, color = Color.Black)
            }
        }
        .fillMaxSize()
)

Transformando infinitamente um quadrado e um triângulo arredondado
Figura 9. Transformação infinita entre um quadrado e um triângulo arredondado.

Usar polígono como clipe

É comum usar o modificador clip no Compose para mudar a forma como um elemento combinável é renderizado e aproveitar as sombras que aparecem ao redor da área de corte:

fun RoundedPolygon.getBounds() = calculateBounds().let { Rect(it[0], it[1], it[2], it[3]) }
class RoundedPolygonShape(
    private val polygon: RoundedPolygon,
    private var matrix: Matrix = Matrix()
) : Shape {
    private var path = Path()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        path.rewind()
        path = polygon.toPath().asComposePath()
        matrix.reset()
        val bounds = polygon.getBounds()
        val maxDimension = max(bounds.width, bounds.height)
        matrix.scale(size.width / maxDimension, size.height / maxDimension)
        matrix.translate(-bounds.left, -bounds.top)

        path.transform(matrix)
        return Outline.Generic(path)
    }
}

Depois, é possível usar o polígono como um clipe, conforme mostrado no snippet a seguir:

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier
        .clip(clip)
        .background(MaterialTheme.colorScheme.secondary)
        .size(200.dp)
) {
    Text(
        "Hello Compose",
        color = MaterialTheme.colorScheme.onSecondary,
        modifier = Modifier.align(Alignment.Center)
    )
}

Isso resulta no seguinte:

Hexágono com o texto "hello compose" no centro.
Figura 10. Hexágono com o texto "Hello Compose" no centro.

Isso pode não parecer muito diferente do que estava sendo renderizado antes, mas permite aproveitar outros recursos no Compose. Por exemplo, essa técnica pode ser usada para recortar uma imagem e aplicar uma sombra ao redor da região recortada:

val hexagon = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val clip = remember(hexagon) {
    RoundedPolygonShape(polygon = hexagon)
}
Box(
    modifier = Modifier.fillMaxSize(),
    contentAlignment = Alignment.Center
) {
    Image(
        painter = painterResource(id = R.drawable.dog),
        contentDescription = "Dog",
        contentScale = ContentScale.Crop,
        modifier = Modifier
            .graphicsLayer {
                this.shadowElevation = 6.dp.toPx()
                this.shape = clip
                this.clip = true
                this.ambientShadowColor = Color.Black
                this.spotShadowColor = Color.Black
            }
            .size(200.dp)

    )
}

Cachorro em hexágono com sombra aplicada nas bordas
Figura 11. Forma personalizada aplicada como clipe.

Botão de transformação ao clicar

É possível usar a biblioteca graphics-shape para criar um botão que se transforma entre duas formas ao ser pressionado. Primeiro, crie um MorphPolygonShape que estenda Shape, redimensionando e traduzindo-o para se ajustar adequadamente. Observe a transmissão do progresso para que a forma possa ser animada:

class MorphPolygonShape(
    private val morph: Morph,
    private val percentage: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)
        return Outline.Generic(path)
    }
}

Para usar essa forma de transformação, crie dois polígonos, shapeA e shapeB. Crie e lembre-se do Morph. Em seguida, aplique a transformação no botão como um contorno de clipe, usando o interactionSource ao pressionar como a força motriz por trás da animação:

val shapeA = remember {
    RoundedPolygon(
        6,
        rounding = CornerRounding(0.2f)
    )
}
val shapeB = remember {
    RoundedPolygon.star(
        6,
        rounding = CornerRounding(0.1f)
    )
}
val morph = remember {
    Morph(shapeA, shapeB)
}
val interactionSource = remember {
    MutableInteractionSource()
}
val isPressed by interactionSource.collectIsPressedAsState()
val animatedProgress = animateFloatAsState(
    targetValue = if (isPressed) 1f else 0f,
    label = "progress",
    animationSpec = spring(dampingRatio = 0.4f, stiffness = Spring.StiffnessMedium)
)
Box(
    modifier = Modifier
        .size(200.dp)
        .padding(8.dp)
        .clip(MorphPolygonShape(morph, animatedProgress.value))
        .background(Color(0xFF80DEEA))
        .size(200.dp)
        .clickable(interactionSource = interactionSource, indication = null) {
        }
) {
    Text("Hello", modifier = Modifier.align(Alignment.Center))
}

Isso resulta na seguinte animação quando a caixa é tocada:

Morph aplicado como um clique entre duas formas
Figura 12. A transformação é aplicada como um clique entre duas formas.

Animar a transformação de forma infinitamente

Para animar uma forma de transformação sem fim, use rememberInfiniteTransition. Confira abaixo um exemplo de foto do perfil que muda de forma (e gira) infinitamente ao longo do tempo. Essa abordagem usa um pequeno ajuste na MorphPolygonShape mostrada acima:

class CustomRotatingMorphShape(
    private val morph: Morph,
    private val percentage: Float,
    private val rotation: Float
) : Shape {

    private val matrix = Matrix()
    override fun createOutline(
        size: Size,
        layoutDirection: LayoutDirection,
        density: Density
    ): Outline {
        // Below assumes that you haven't changed the default radius of 1f, nor the centerX and centerY of 0f
        // By default this stretches the path to the size of the container, if you don't want stretching, use the same size.width for both x and y.
        matrix.scale(size.width / 2f, size.height / 2f)
        matrix.translate(1f, 1f)
        matrix.rotateZ(rotation)

        val path = morph.toPath(progress = percentage).asComposePath()
        path.transform(matrix)

        return Outline.Generic(path)
    }
}

@Preview
@Composable
private fun RotatingScallopedProfilePic() {
    val shapeA = remember {
        RoundedPolygon(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val shapeB = remember {
        RoundedPolygon.star(
            12,
            rounding = CornerRounding(0.2f)
        )
    }
    val morph = remember {
        Morph(shapeA, shapeB)
    }
    val infiniteTransition = rememberInfiniteTransition("infinite outline movement")
    val animatedProgress = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 1f,
        animationSpec = infiniteRepeatable(
            tween(2000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    val animatedRotation = infiniteTransition.animateFloat(
        initialValue = 0f,
        targetValue = 360f,
        animationSpec = infiniteRepeatable(
            tween(6000, easing = LinearEasing),
            repeatMode = RepeatMode.Reverse
        ),
        label = "animatedMorphProgress"
    )
    Box(
        modifier = Modifier.fillMaxSize(),
        contentAlignment = Alignment.Center
    ) {
        Image(
            painter = painterResource(id = R.drawable.dog),
            contentDescription = "Dog",
            contentScale = ContentScale.Crop,
            modifier = Modifier
                .clip(
                    CustomRotatingMorphShape(
                        morph,
                        animatedProgress.value,
                        animatedRotation.value
                    )
                )
                .size(200.dp)
        )
    }
}

Esse código gera o seguinte resultado divertido:

Duas mãos formando um coração
Figura 13. foto do perfil recortada por uma forma recortada em rotação.

Polígonos personalizados

Se as formas criadas a partir de polígonos regulares não atenderem ao seu caso de uso, crie uma forma mais personalizada com uma lista de vértices. Por exemplo, você pode criar um coração assim:

Duas mãos formando um coração
Figura 14. Formato de coração.

É possível especificar os vértices individuais dessa forma usando a sobrecarga RoundedPolygon, que assume uma matriz flutuante de coordenadas x, y.

Para dividir o polígono do coração, observe que o sistema de coordenadas polar para especificar pontos facilita isso do que usar o sistema de coordenadas cartesianas (x,y), em que começa no lado direito e segue no sentido horário, com 270° na posição 12 horas:

Duas mãos formando um coração
Figura 15. Forma de coração com coordenadas.

Agora, a forma pode ser definida de uma maneira mais fácil, especificando o ângulo (𝜭) e o raio do centro em cada ponto:

Duas mãos formando um coração
Figura 16. Forma de coração com coordenadas, sem arredondamento.

Os vértices agora podem ser criados e transmitidos para a função RoundedPolygon:

val vertices = remember {
    val radius = 1f
    val radiusSides = 0.8f
    val innerRadius = .1f
    floatArrayOf(
        radialToCartesian(radiusSides, 0f.toRadians()).x,
        radialToCartesian(radiusSides, 0f.toRadians()).y,
        radialToCartesian(radius, 90f.toRadians()).x,
        radialToCartesian(radius, 90f.toRadians()).y,
        radialToCartesian(radiusSides, 180f.toRadians()).x,
        radialToCartesian(radiusSides, 180f.toRadians()).y,
        radialToCartesian(radius, 250f.toRadians()).x,
        radialToCartesian(radius, 250f.toRadians()).y,
        radialToCartesian(innerRadius, 270f.toRadians()).x,
        radialToCartesian(innerRadius, 270f.toRadians()).y,
        radialToCartesian(radius, 290f.toRadians()).x,
        radialToCartesian(radius, 290f.toRadians()).y,
    )
}

Os vértices precisam ser traduzidos em coordenadas cartesianas usando esta função radialToCartesian:

internal fun Float.toRadians() = this * PI.toFloat() / 180f

internal val PointZero = PointF(0f, 0f)
internal fun radialToCartesian(
    radius: Float,
    angleRadians: Float,
    center: PointF = PointZero
) = directionVectorPointF(angleRadians) * radius + center

internal fun directionVectorPointF(angleRadians: Float) =
    PointF(cos(angleRadians), sin(angleRadians))

O código anterior mostra os vértices brutos do coração, mas é necessário arredondar cantos específicos para conseguir a forma de coração escolhida. Os cantos em 90° e 270° não têm arredondamento, mas os outros, sim. Para ter arredondamento personalizado para cantos individuais, use o parâmetro perVertexRounding:

val rounding = remember {
    val roundingNormal = 0.6f
    val roundingNone = 0f
    listOf(
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNormal),
        CornerRounding(roundingNone),
        CornerRounding(roundingNormal),
    )
}

val polygon = remember(vertices, rounding) {
    RoundedPolygon(
        vertices = vertices,
        perVertexRounding = rounding
    )
}
Box(
    modifier = Modifier
        .drawWithCache {
            val roundedPolygonPath = polygon.toPath().asComposePath()
            onDrawBehind {
                scale(size.width * 0.5f, size.width * 0.5f) {
                    translate(size.width * 0.5f, size.height * 0.5f) {
                        drawPath(roundedPolygonPath, color = Color(0xFFF15087))
                    }
                }
            }
        }
        .size(400.dp)
)

O resultado é o coração rosa:

Duas mãos formando um coração
Figura 17. Resultado em forma de coração.

Se as formas anteriores não atenderem ao seu caso de uso, use a classe Path para desenhar uma forma personalizada ou carregue um arquivo ImageVector do disco. A biblioteca graphics-shapes não se destina ao uso de formas arbitrárias, mas especificamente a simplificar a criação de polígonos arredondados e transformações de animação entre eles.

Outros recursos

Para mais informações e exemplos, consulte os seguintes recursos: