Formas en Compose

Con Compose, puedes crear formas hechas de polígonos. Por ejemplo: puedes hacer los siguientes tipos de formas:

Hexágono azul en el centro del área de trazado
Figura 1: Ejemplos de diferentes formas que puedes crear con gráficos biblioteca

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.1"

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 de diseño. Sin embargo, esta biblioteca lo simplifica combinando estas formas con estructuras poligonales similares.

Cómo crear 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()
)

Un hexágono azul en el centro del área de dibujo
Figura 2: Hexágono azul en el centro del área de trazado.

En este ejemplo, la biblioteca crea un objeto RoundedPolygon que contiene la geometría que representa la forma solicitada. Para dibujar esa forma en una app de Compose, haz lo siguiente: debes obtener un objeto Path para obtener la forma en un formulario en el que Compose sabe cómo dibujar.

Cómo redondear las esquinas de un polígono

Para redondear las esquinas de un polígono, usa el parámetro CornerRounding. Esta toma dos parámetros, radius y smoothing. Cada esquina redondeada se compone de 1 a 3 curvas cúbicas, cuyo centro tiene una forma de arco circular, mientras que las dos las curvas laterales (“flanking”) hacen una transición desde el borde de la forma hasta la curva central.

Radio

El elemento 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:

Triángulo con esquinas redondeadas
Figura 3: Triángulo con esquinas redondeadas
El radio de redondeo r determina el tamaño del redondeo circular de las esquinas redondeadas.
Figura 4. El radio de redondeo r determina el tamaño del redondeo circular de las esquinas redondeadas.

Suavizado

El suavizado es un factor que determina cuánto tiempo se tarda en llegar circular de la esquina redondeada hacia el borde. Un factor de suavizado de 0 (sin suavizar, el valor predeterminado para CornerRounding) da como resultado una red puramente circular. el redondeo de esquinas. Un factor de suavizado distinto de cero (hasta el máximo de 1.0) hace que la esquina se redondee con tres curvas separadas.

Un factor de suavizado de 0 (sin suavizar) produce una única curva cúbica que
sigue un círculo en una esquina con el radio de redondeo especificado, como en el
ejemplo anterior
Figura 5: Un factor de suavizado de 0 (sin suavizar) produce una sola curva cúbica que sigue un círculo alrededor de la esquina con el radio de redondeo especificado, como en el ejemplo anterior.
Un factor de suavizado distinto de cero produce tres curvas cúbicas para redondear
el vértice: la curva circular interior (como antes) más dos curvas flanqueas que
la transición entre la curva interna y los bordes del polígono.
Figura 6. Un factor de suavizado distinto de cero produce tres curvas cúbicas para redondear el vértice: la curva circular interna (como antes) más dos curvas laterales que hacen la transición entre la curva interna y los bordes del polígono.

Por ejemplo, el siguiente fragmento ilustra la sutil diferencia en la configuración suavizado a 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)
)

Dos triángulos negros que muestran la diferencia en el parámetro de suavización
Figura 7: Dos triángulos negros que muestran la diferencia en el parámetro de suavizado.

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 da como resultado una forma más pequeña, ya que las esquinas redondeadas estarán más cerca del central que los vértices que se redondean. Para ajustar el tamaño de un polígono, ajusta radius valor. 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 el inicio y extremos, 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 mitad de camino entre las dos formas (triángulo redondeado y un cuadrado), dando como resultado el siguiente resultado:

El 50% del camino entre un triángulo redondeado y un cuadrado
Figura 8: El 50% del camino entre un triángulo redondeado y un cuadrado.

En la mayoría de los casos, la transformación se realiza como parte de una animación y no solo como 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()
)

Transformación infinita entre un cuadrado y un triángulo redondeado
Figura 9: Transformación infinita entre un cuadrado y un triángulo redondeado

Cómo usar 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, tal 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:

Un hexágono con el texto "hello compose" en el centro.
Figura 10: Un hexágono con el texto "Hello Compose" en el centro.

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)

    )
}

Perro en hexágono con sombra aplicada en los bordes
Figura 11: Forma personalizada aplicada como clip.

Botón Modificar con un clic

Puedes usar la biblioteca graphics-shape para crear un botón que se transforme entre dos formas al presionar. 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 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, utilizando el interactionSource en la presión como la fuerza impulsora detrás del 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:

Transformación aplicada como un clic entre dos formas
Figura 12: Se aplica la transformación como un clic entre dos formas.

Anima la transformación de formas de forma infinita

Para animar sin cesar una forma, usa rememberInfiniteTransition A continuación, se incluye un ejemplo de una foto de perfil que cambia de forma (y rota) infinitamente con el tiempo. Este enfoque utiliza un pequeño ajuste en la Se muestra MorphPolygonShape 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 muestra el siguiente resultado divertido:

Manos que forman un corazón
Figura 13: Foto de perfil recortada por una forma festoneada giratoria.

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, es posible que quieras crea una forma de corazón como esta:

Manos que forman un corazón
Figura 14: Forma de corazón.

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 de especificar puntos hace que esto sea más fácil que usar la coordenada cartesiana (x,y) , donde comienza en el lado derecho y continúa en el sentido de las manecillas del reloj, con 270° en la posición de las 12:

Manos que forman un corazón
Figura 15: Forma de corazón con coordenadas.

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:

Manos que forman un corazón
Figura 16: Forma de corazón con coordenadas, sin redondeo.

Los vértices ahora 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 en 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 Las 270° no tienen redondeo, pero las otras esquinas sí. Para lograr un redondeo personalizado Para las 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:

Manos que forman un corazón
Figura 17: Resultado en forma de corazón.

Si las formas anteriores no cubren 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 conjuntos pero está diseñado específicamente para simplificar la creación de polígonos redondeados y transformar animaciones entre ellas.

Recursos adicionales

Para obtener más información y ejemplos, consulta los siguientes recursos: