Com o Compose, é possível criar formas feitas de polígonos. Por exemplo, é possível criar os seguintes tipos de formas:
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-alpha05"
Essa biblioteca permite criar formas feitas de polígonos. Embora as formas poligonais tenham apenas bordas retas e cantos afiados, elas permitem cantos arredondados opcionais. Ele simplifica a transformação entre duas formas diferentes. A transformação é difícil entre formas arbitrárias e tende a ser um problema no momento do design. Mas essa biblioteca simplifica o processo, mudando 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 acessar um objeto Path
dele para transformar a forma em um formulário que o Compose
saiba como 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 1 a 3 curvas cúbicas, com um centro que tem uma forma de arco circular, enquanto as curvas de dois
lados ("flanque") 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 com canto arredondado abaixo é criado da seguinte maneira:
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 de CornerRounding
) resulta em arredondamento de cantos
puramente circular. Um fator de suavização diferente de zero (até o máximo de 1,0) resulta no
canto ser arredondado por três curvas separadas.
Por exemplo, o snippet abaixo ilustra a diferença sutil entre a suavização de configurações como 0 e 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
em torno 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 se baseia. O arredondamento dos cantos
resulta em uma forma menor, já que os cantos arredondados vão estar 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 a centerX
ou a centerY
do polígono.
Como alternativa, transforme o objeto para alterar 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 dois objetos RoundedPolygons
e um 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 na metade do caminho entre as duas formas (triângulo arredondado e quadrado), produzindo este resultado:
Na maioria dos cenários, a transformação é feita como parte de uma animação, e não apenas como uma renderização estática. Para animar entre os dois, use as APIs Animation no Compose padrão para mudar o valor do progresso ao longo do tempo. Por exemplo, você pode 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 recorte
É comum usar o modificador
clip
no Compose para mudar a forma como um elemento combinável é renderizado e aproveitar
as sombras que desenham 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) } }
Você pode usar o polígono como recorte, 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) ) }
O resultado será o seguinte:
Isso pode não parecer tão diferente da renderização anterior, mas permite aproveitar outros recursos no Compose. Por exemplo, esta técnica pode ser usada para recortar uma imagem e aplicar uma sombra ao redor da região cortada:
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) ) }
Transformação de botão ao clicar
Você pode 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-o e traduzindo-o para se ajustar corretamente. 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, 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 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 animação abaixo quando a caixa é tocada:
Animar formas que se transformam infinitamente
Para animar infinitamente uma forma transformada, use
rememberInfiniteTransition
.
Confira abaixo o exemplo de uma foto de 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) ) } }
Este 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, crie uma forma mais personalizada com uma lista de vértices. Por exemplo, você pode querer criar uma forma de coração como esta:
Você pode 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 torna isso mais fácil do que usar o sistema de coordenadas cartesiano (x, y),
em que 0°
começa no lado direito e prossegue no sentido horário, com
270°
na posição de 12 horas:
A forma agora pode ser definida de maneira mais fácil, especificando o ângulo (partial) e o raio a partir do centro em cada ponto:
Agora, os vértices 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 convertidos 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 fornece os vértices brutos do coração, mas você precisa
arredondar cantos específicos para ter a forma de coração escolhida. Os cantos em 90°
e 270°
não têm arredondamento, mas os outros cantos sim. Para ter 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 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 para formas
arbitrárias, mas tem como objetivo simplificar a criação de polígonos arredondados e
transformar animações entre eles.
Outros recursos
Para mais informações e exemplos, consulte os seguintes recursos:
- Blog: The Shape of Things to Come - Shapes (em inglês)
- Blog: transformação de formas no Android (link em inglês)
- Demonstração do Shapes no GitHub