Com o Compose, é possível criar formas feitas de polígonos. Por exemplo, você pode criar os seguintes tipos de formas:
Para criar um polígono arredondado personalizado no Compose, adicione a
graphics-shapes dependência ao seu
app/build.gradle:
implementation "androidx.graphics:graphics-shapes:1.0.1"
Essa biblioteca permite criar formas feitas de polígonos. Embora as formas poligonais tenham apenas bordas retas e cantos nítidos, essas formas permitem cantos arredondados opcionais. Isso simplifica a transformação entre duas formas diferentes. A transformação é difícil entre formas arbitrárias e tende a ser um problema de tempo de design. No entanto, essa biblioteca simplifica a transformação entre essas formas com estruturas poligonais semelhantes.
Criar polígonos
O snippet a seguir cria uma forma poligonal 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() )
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, você precisa receber um objeto Path dela para que a forma seja desenhada pelo Compose.
Arredondar os cantos de um polígono
Para arredondar os cantos de um polígono, use o parâmetro CornerRounding. Ele usa dois parâmetros, radius e smoothing. Cada canto arredondado é composto por 1 a 3 curvas cúbicas, cujo centro tem um formato de arco circular, enquanto as duas curvas laterais ("flanqueadoras") fazem a transição da borda da forma para a curva central.
Raio
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:
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 de arredondamento circular do canto até a borda. Um fator de suavização de 0 (não suavizado, o valor padrão para CornerRounding) resulta em um arredondamento de canto puramente circular. Um fator de suavização diferente de zero (até o máximo de 1,0) resulta no canto sendo arredondado por três curvas separadas.
Por exemplo, o snippet abaixo ilustra a diferença sutil na configuração da suavização para 0 em comparação com 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) )
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. O arredondamento dos cantos resulta em uma forma menor, já que os cantos arredondados estarão mais próximos do centro do que os vértices que estão sendo arredondados. Para dimensionar um polígono, ajuste o valor radius. Para ajustar a posição, mude o centerX ou 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 DrawScopepadrão, como
DrawScope#translate().
Transformar formas
Um objeto Morph é uma nova forma que representa uma animação entre duas formas poligonais. Para transformar entre duas formas, crie dois 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 o formato 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:
Na maioria dos cenários, 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 de animação padrão no Compose para mudar o valor de 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() )
Usar polígono como clipe
É comum usar o
clip
modificador no Compose para mudar a forma como um elemento combinável é renderizado e aproveitar as
sombras que são desenhadas ao redor da área de recorte:
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) } }
Em seguida, você pode 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:
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) ) }
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 pressionar. Primeiro, crie um MorphPolygonShape que estenda Shape,
dimensionando e traduzindo-o para se ajustar adequadamente. Observe a passagem 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 ao 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:
Animar a transformação de formas infinitamente
Para animar uma forma de transformação infinitamente, use
rememberInfiniteTransition.
Confira abaixo um exemplo de uma foto do perfil que muda de forma (e gira) infinitamente ao longo do tempo. Essa abordagem usa um pequeno ajuste no MorphPolygonShape mostrado 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 oferece o seguinte resultado divertido:
Polígonos personalizados
Se as formas criadas a partir de polígonos regulares não cobrirem seu caso de uso, você poderá criar uma forma mais personalizada com uma lista de vértices. Por exemplo, talvez você queira criar uma forma de coração como esta:
É possível especificar os vértices individuais dessa forma usando a sobrecarga RoundedPolygon que usa uma matriz flutuante de coordenadas x, y.
Para detalhar o polígono do coração, observe que o sistema de coordenadas polares para especificar pontos facilita isso do que usar o sistema de coordenadas cartesianas (x, y) em que 0° começa no lado direito e prossegue no sentido horário, com 270° na posição das 12 horas:
Agora, a forma pode ser definida de maneira mais fácil especificando o ângulo (𝜭) e o raio do centro em cada ponto:
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 essa 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 fornece os vértices brutos para o coração, mas é necessário arredondar cantos específicos para obter a forma de coração escolhida. Os cantos em 90° e 270° não têm arredondamento, mas os outros cantos têm. Para conseguir um 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) )
Isso resulta no coração rosa:
Se as formas anteriores não cobrirem seu caso de uso, considere usar a Path
classe para desenhar uma forma personalizada, ou carregar um
ImageVector arquivo do
disco. A biblioteca graphics-shapes não se destina ao uso para formas arbitrárias, mas é especificamente destinada a simplificar a criação de polígonos arredondados e animações de transformação entre eles.
Outros recursos
Para mais informações e exemplos, consulte os seguintes recursos:
- Blog: The Shape of Things to Come - Shapes (link em inglês)
- Blog: Shape morphing in Android (link em inglês)
- Demonstração de formas no GitHub (link em inglês)