Forme in Compose

Con Crea, puoi creare forme composte da poligoni. Ad esempio, puoi creare i seguenti tipi di forme:

Esagono blu al centro dell'area di disegno
Figura 1. Esempi di forme diverse che puoi creare con la libreria graphics-shapes

Per creare un poligono arrotondato personalizzato in Compose, aggiungi la dipendenza graphics-shapes al tuo app/build.gradle:

implementation "androidx.graphics:graphics-shapes:1.0.1"

Questa libreria ti consente di creare forme costituite da poligoni. Mentre le forme poligonali hanno solo bordi dritti e angoli acuti, queste forme consentono angoli arrotondati opzionali. Semplifica la transizione tra due forme diverse. La trasformazione è difficile tra forme arbitrarie e tende a essere un problema in fase di progettazione. ma questa libreria semplifica la procedura trasformando queste forme con strutture poligonali simili.

Creare poligoni

Il seguente snippet crea una forma poligonale di base con 6 punti al centro dell'area di disegno:

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()
)

Esagono blu al centro dell'area di disegno
Figura 2. Esagono blu al centro dell'area di disegno.

In questo esempio, la libreria crea un RoundedPolygon che contiene la geometria che rappresenta la forma richiesta. Per disegnare questa forma in un'app Compose, devi ottenere un oggetto Path per trasformare la forma in un formato che Compose sa disegnare.

Arrotondare gli angoli di un poligono

Per arrotondare gli angoli di un poligono, utilizza il parametro CornerRounding. Questo accetta due parametri, radius e smoothing. Ogni angolo arrotondato è composto da 1-3 curve cubiche, il cui centro ha una forma ad arco circolare, mentre le due curve laterali ("di fianco") passano dal bordo della forma alla curva centrale.

Raggio

radius è il raggio del cerchio utilizzato per arrotondare un vertice.

Ad esempio, il seguente triangolo con angoli arrotondati viene creato nel seguente modo:

Triangolo con angoli arrotondati
Figura 3. Triangolo con angoli arrotondati.
Il raggio di arrotondamento r determina la dimensione dell'arrotondamento circolare degli angoli arrotondati
Figura 4. Il raggio di arrotondamento r determina la dimensione dell'arrotondamento circolare degli angoli arrotondati.

Sfumatura

L'arrotondamento è un fattore che determina il tempo necessario per passare dalla parte arrotondata circolare dell'angolo al bordo. Un fattore di smussatura pari a 0 (non smussato, il valore predefinito per CornerRounding) comporta un arrotondamento degli angoli puramente circolare. Un fattore di smussatura diverso da zero (fino a un massimo di 1,0) fa sì che l'angolo venga arrotondato da tre curve separate.

Un fattore di smussatura pari a 0 (senza smussatura) produce una singola curva cubica che
segue un cerchio intorno all'angolo con il raggio di arrotondamento specificato, come nell'
esempio precedente.
Figura 5. Un fattore di smussatura pari a 0 (non smussato) produce una singola curva cubica che segue un cerchio intorno all'angolo con il raggio di arrotondamento specificato, come nell'esempio precedente.
Un fattore di smussatura diverso da zero produce tre curve cubiche per arrotondare
il vertice: la curva circolare interna (come prima) più due curve laterali che
fanno la transizione tra la curva interna e i bordi del poligono.
Figura 6. Un fattore di smussatura diverso da zero produce tre curve cubiche per arrotondare il vertice: la curva circolare interna (come prima) più due curve laterali che fanno la transizione tra la curva interna e i bordi del poligono.

Ad esempio, lo snippet riportato di seguito illustra la sottile differenza tra l'impostazione della levigatura su 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)
)

Due triangoli neri che mostrano la differenza nel parametro di smoothing.
Figura 7. Due triangoli neri che mostrano la differenza nel parametro di smoothing.

Dimensioni e posizione

Per impostazione predefinita, una forma viene creata con un raggio di 1 intorno al centro (0, 0). Questo raggio rappresenta la distanza tra il centro e i vertici esterni del poligono su cui si basa la forma. Tieni presente che l'arrotondamento degli angoli produce una forma più piccola, poiché gli angoli arrotondati saranno più vicini al centro rispetto ai vertici arrotondati. Per dimensionare un poligono, modifica il valore di radius. Per regolare la posizione, modifica centerX o centerY del poligono. In alternativa, trasforma l'oggetto per modificarne le dimensioni, la posizione e la rotazione utilizzando le funzioni di trasformazione DrawScope standard, ad esempio DrawScope#translate().

Forme di Morph

Un oggetto Morph è una nuova forma che rappresenta un'animazione tra due forme poligonali. Per eseguire la morphing tra due forme, crea due RoundedPolygons e un oggetto Morph che accetta queste due forme. Per calcolare una forma tra quella iniziale e quella finale, fornisci un valore progress compreso tra zero e uno per determinarne la forma tra quella iniziale (0) e quella finale (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()
)

Nell'esempio precedente, l'avanzamento si trova esattamente a metà tra le due forme (triangolo arrotondato e quadrato), producendo il seguente risultato:

A metà strada tra un triangolo arrotondato e un quadrato
Figura 8. A metà strada tra un triangolo arrotondato e un quadrato.

Nella maggior parte degli scenari, la deformazione viene eseguita nell'ambito di un'animazione e non solo di un rendering statico. Per animare la transizione tra questi due valori, puoi utilizzare le API Animation in Compose standard per modificare il valore di avanzamento nel tempo. Ad esempio, puoi animare all'infinito la trasformazione tra queste due forme nel seguente modo:

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()
)

Si trasforma all'infinito tra un quadrato e un triangolo arrotondato
Figura 9. Si trasforma all'infinito tra un quadrato e un triangolo arrotondato.

Usa il poligono come clip

È comune utilizzare il modificatore clip in Compose per modificare il rendering di un elemento componibile e per sfruttare le ombre che vengono disegnate intorno all'area di ritaglio:

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)
    }
}

Puoi quindi utilizzare il poligono come clip, come mostrato nello snippet seguente:

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)
    )
}

Ciò comporta quanto segue:

Esagono con il testo "hello compose" al centro.
Figura 10. Esagono con il testo "Hello Compose" al centro.

Potrebbe non sembrare molto diverso da ciò che veniva visualizzato in precedenza, ma consente di sfruttare altre funzionalità di Compose. Ad esempio, questa tecnica può essere utilizzata per ritagliare un'immagine e applicare un'ombra intorno alla regione ritagliata:

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)

    )
}

Cane in esagono con ombra applicata intorno ai bordi
Figura 11. Forma personalizzata applicata come clip.

Pulsante Morph al clic

Puoi utilizzare la libreria graphics-shape per creare un pulsante che si trasforma tra due forme quando viene premuto. Per prima cosa, crea un MorphPolygonShape che si estenda a Shape, ridimensionandolo e traducendolo in modo appropriato. Tieni presente il passaggio dell'avanzamento in modo che la forma possa essere animata:

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)
    }
}

Per utilizzare questa forma di morphing, crea due poligoni, shapeA e shapeB. Crea e ricorda il Morph. Poi, applica la trasformazione al pulsante come contorno del clip, utilizzando interactionSource alla pressione come forza trainante dell'animazione:

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))
}

Quando si tocca la casella, viene visualizzata la seguente animazione:

Trasformazione applicata come clic tra due forme
Figura 12. Trasformazione applicata come clic tra due forme.

Animare la trasformazione della forma all'infinito

Per animare all'infinito una forma di morph, utilizza rememberInfiniteTransition. Di seguito è riportato un esempio di immagine del profilo che cambia forma (e ruota) all'infinito nel tempo. Questo approccio utilizza un piccolo aggiustamento per il MorphPolygonShape mostrato sopra:

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)
        )
    }
}

Questo codice produce il seguente risultato divertente:

Forma di cuore
Figura 13. Immagine del profilo ritagliata da una forma smerlata rotante.

Poligoni personalizzati

Se le forme create da poligoni regolari non coprono il tuo caso d'uso, puoi creare una forma più personalizzata con un elenco di vertici. Ad esempio, potresti voler creare una forma a cuore come questa:

Forma di cuore
Figura 14. Forma a cuore.

Puoi specificare i singoli vertici di questa forma utilizzando l'overload RoundedPolygon che accetta un array float di coordinate x, y.

Per analizzare il poligono del cuore, nota che il sistema di coordinate polari per specificare i punti rende questa operazione più semplice rispetto all'utilizzo del sistema di coordinate cartesiane (x,y), in cui inizia sul lato destro e procede in senso orario, con 270° nella posizione delle ore 12:

Forma di cuore
Figura 15. Forma di cuore con coordinate.

Ora la forma può essere definita in modo più semplice specificando l'angolo (𝜭) e il raggio dal centro in ogni punto:

Forma di cuore
Figura 16. Forma di cuore con coordinate, senza arrotondamento.

Ora i vertici possono essere creati e passati alla funzione 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,
    )
}

I vertici devono essere convertiti in coordinate cartesiane utilizzando questa funzione: 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))

Il codice precedente fornisce i vertici grezzi del cuore, ma devi arrotondare angoli specifici per ottenere la forma del cuore scelta. Gli angoli in corrispondenza di 90° e 270° non sono arrotondati, mentre gli altri sì. Per ottenere un arrotondamento personalizzato per i singoli angoli, utilizza il parametro 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)
)

Il risultato è il cuore rosa:

Forma di cuore
Figura 17. Risultato a forma di cuore.

Se le forme precedenti non coprono il tuo caso d'uso, valuta la possibilità di utilizzare la classe Path per disegnare una forma personalizzata o caricare un file ImageVector dal disco. La libreria graphics-shapes non è pensata per essere utilizzata per forme arbitrarie, ma è specificamente pensata per semplificare la creazione di poligoni arrotondati e animazioni di morphing tra loro.

Risorse aggiuntive

Per ulteriori informazioni ed esempi, consulta le seguenti risorse: