Con Compose, puedes crear formas hechas de polígonos. Por ejemplo, puedes crear los siguientes tipos de formas:
Para crear un polígono redondeado personalizado en Compose, agrega la dependencia graphics-shapes
a tu app/build.gradle
:
implementation "androidx.graphics:graphics-shapes:1.0.0-rc01"
Esta biblioteca te permite crear formas hechas de polígonos. Si bien las formas poligonales solo tienen bordes rectos y esquinas afiladas, estas formas permiten esquinas redondeadas opcionales. Facilita la transformación entre dos formas diferentes. La transformación es difícil entre formas arbitrarias y suele ser un problema durante el tiempo de diseño. Sin embargo, esta biblioteca lo simplifica combinando estas formas con estructuras poligonales similares.
Crea polígonos
Con el siguiente fragmento, se crea una forma de polígono básica con 6 puntos en el centro del área de dibujo:
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() )
En este ejemplo, la biblioteca crea un RoundedPolygon
que contiene la geometría que representa la forma solicitada. Para dibujar esa forma en una app de Compose,
debes obtener un objeto Path
de ella para que la forma tenga una forma que Compose
sepa dibujar.
Redondea las esquinas de un polígono
Para redondear las esquinas de un polígono, usa el parámetro CornerRounding
. Este método toma dos parámetros, radius
y smoothing
. Cada esquina redondeada está formada por 1 a 3 curvas cúbicas, cuyo centro tiene una forma de arco circular, mientras que las dos curvas laterales ("laterales") pasan del borde de la forma a la curva central.
Radio
radius
es el radio del círculo que se usa para redondear un vértice.
Por ejemplo, el siguiente triángulo de esquina redondeada se crea de la siguiente manera:
Suavizado
El suavizado es un factor que determina cuánto tiempo se tarda en pasar de la parte redondeada circular de la esquina al borde. Un factor de suavizado de 0 (sin suavizar, el valor predeterminado para CornerRounding
) genera un redondeo de esquinas puramente circular. Un factor de suavizado distinto de cero (hasta el máximo de 1.0) hace que la esquina se redondee con tres curvas separadas.
Por ejemplo, en el siguiente fragmento, se ilustra la diferencia sutil entre establecer la suavización en 0 en comparación con 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) )
Tamaño y posición
De forma predeterminada, se crea una forma con un radio de 1
alrededor del centro (0, 0
).
Este radio representa la distancia entre el centro y los vértices exteriores
del polígono en el que se basa la forma. Ten en cuenta que redondear las esquinas genera una forma más pequeña, ya que las esquinas redondeadas estarán más cerca del centro que los vértices redondeados. Para ajustar el tamaño de un polígono, ajusta el valor de radius
. Para ajustar la posición, cambia el centerX
o el centerY
del polígono.
Como alternativa, transforma el objeto para cambiar su tamaño, posición y rotación con funciones de transformación DrawScope
estándar, como DrawScope#translate()
.
Cómo transformar formas
Un objeto Morph
es una forma nueva que representa una animación entre dos formas
poligonales. Para mutar entre dos formas, crea dos objetos RoundedPolygons
y un objeto Morph
que tome estas dos formas. Para calcular una forma entre las formas inicial y final, proporciona un valor progress
entre cero y uno para determinar su forma entre las formas inicial (0) y 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() )
En el ejemplo anterior, el progreso está exactamente a la mitad entre las dos formas (triángulo redondeado y cuadrado), lo que produce el siguiente resultado:
En la mayoría de los casos, la transformación se realiza como parte de una animación y no solo de una renderización estática. Para animar entre estos dos, puedes usar las APIs de animación estándar en Compose para cambiar el valor de progreso con el tiempo. Por ejemplo, puedes animar infinitamente la transformación entre estas dos formas de la siguiente manera:
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() )
Usa un polígono como clip
Es común usar el modificador clip
en Compose para cambiar la forma en que se renderiza un elemento componible y aprovechar las sombras que se dibujan alrededor del á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) } }
Luego, puedes usar el polígono como un clip, como se muestra en el siguiente fragmento:
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) ) }
Esto genera lo siguiente:
Es posible que no se vea muy diferente de lo que se renderizaba antes, pero permite aprovechar otras funciones en Compose. Por ejemplo, esta técnica se puede usar para recortar una imagen y aplicar una sombra alrededor de la región 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ón de transformación al hacer clic
Puedes usar la biblioteca graphics-shape
para crear un botón que cambie entre dos formas cuando se presione. Primero, crea un MorphPolygonShape
que extienda Shape
, a escala y transfiérela para que se ajuste de forma adecuada. Observa cómo se pasa el progreso para que se pueda animar la forma:
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 esta forma de transformación, crea dos polígonos: shapeA
y shapeB
. Crea y recuerda el Morph
. Luego, aplica la transformación al botón como un contorno de clip, con interactionSource
al presionar como la fuerza impulsora detrás de la animación:
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)) }
Esto genera la siguiente animación cuando se presiona el cuadro:
Anima la transformación de formas de forma infinita
Para animar una forma de transformación sin fin, usa rememberInfiniteTransition
.
A continuación, se muestra un ejemplo de una foto de perfil que cambia de forma (y rota)
infinitamente con el tiempo. Este enfoque usa un pequeño ajuste en el MorphPolygonShape
que se muestra arriba:
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 genera el siguiente resultado divertido:
Polígonos personalizados
Si las formas creadas a partir de polígonos regulares no cubren tu caso de uso, puedes crear una forma más personalizada con una lista de vértices. Por ejemplo, puedes crear una forma de corazón como esta:
Puedes especificar los vértices individuales de esta forma con la sobrecarga RoundedPolygon
que toma un array de números de punto flotante de coordenadas x e y.
Para desglosar el polígono del corazón, observa que el sistema de coordenadas polares para especificar puntos facilita esto más que usar el sistema de coordenadas cartesianas (x, y), en el que 0°
comienza en el lado derecho y avanza en el sentido de las manecillas del reloj, con 270°
en la posición de las 12 en punto:
Ahora se puede definir la forma de una manera más fácil si se especifica el ángulo (𝜭) y el radio desde el centro en cada punto:
Ahora, los vértices se pueden crear y pasar a la función 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, ) }
Los vértices deben traducirse a coordenadas cartesianas con esta función 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))
El código anterior te brinda los vértices sin procesar del corazón, pero debes redondear las esquinas específicas para obtener la forma de corazón elegida. Las esquinas en 90°
y 270°
no tienen redondeos, pero las otras sí. Para lograr redondeos personalizados para esquinas individuales, usa el 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) )
Esto genera el corazón rosa:
Si las formas anteriores no abarcan tu caso de uso, considera usar la clase Path
para dibujar una forma personalizada o cargar un archivo ImageVector
desde el disco. La biblioteca graphics-shapes
no está diseñada para usarse con formas arbitrarias, sino específicamente para simplificar la creación de polígonos redondeados y animaciones de transformación entre ellos.
Recursos adicionales
Para obtener más información y ejemplos, consulta los siguientes recursos:
- Blog: The Shape of Things to Come - Shapes
- Blog: Deformación de formas en Android
- Demostración de Shapes en GitHub