Com o Compose, é possível criar formas feitas de polígonos. Por exemplo, é possível fazer 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-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() )
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:
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.
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) )
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:
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() )
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:
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 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:
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:
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:
É 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 0°
começa no lado direito e segue no sentido horário, com
270°
na posição 12 horas:
Agora, a forma pode ser definida de uma 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 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:
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:
- Blog: The Shape of Things to Come - Shapes
- Blog: Shape morphing no Android
- Demonstração de formas no GitHub