Forme in Compose

Con Scrivi, puoi creare forme realizzate a partire da poligoni. Ad esempio, puoi creare i seguenti tipi di forme:

Esagono blu al centro dell'area di disegno
Figura 1. Esempi di diverse forme che puoi creare con la raccolta di forme grafiche

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

implementation "androidx.graphics:graphics-shapes:1.0.0-alpha05"

Questa libreria ti consente di creare forme create a partire da poligoni. Sebbene le forme poligonali abbiano solo bordi retti e angoli appuntiti, queste forme consentono di utilizzare angoli arrotondati facoltativi. Semplifica il passaggio tra due forme diverse. Il morphing è difficile tra le forme arbitrarie e tende a essere un problema in fase di progettazione. Ma questa libreria semplifica il tutto modificando le forme con strutture poligonali simili.

Creare poligoni

Lo snippet seguente 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 elemento RoundedPolygon contenente la geometria che rappresenta la forma richiesta. Per disegnare quella forma in un'app Compose, devi ottenere un oggetto Path al suo interno per ottenere la forma che Compose sai come disegnare.

Arrotondare gli angoli di un poligono

Per arrotondare gli angoli di un poligono, utilizza il parametro CornerRounding. Questa operazione richiede due parametri, radius e smoothing. Ciascun angolo arrotondato è costituito da 1-3 curve cubiche, il cui centro ha una forma ad arco circolare, mentre le due curve laterali ("fianchi") 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 è realizzato come segue:

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

Sfumatura

La levigatura è un fattore che determina il tempo necessario per passare dalla porzione circolare dell'angolo al bordo. Un fattore di livellamento pari a 0 (non uniforme, valore predefinito per CornerRounding) genera un arrotondamento puramente circolare degli angoli. Un fattore di arrotondamento diverso da zero (fino a un massimo di 1,0) comporta l'arrotondamento dell'angolo di tre curve separate.

Un fattore di levigatura pari a 0 (non uniforme) 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 arrotondamento pari a 0 (non uniforme) produce una singola curva cubica che segue un cerchio dietro l'angolo con il raggio di arrotondamento specificato, come nell'esempio precedente.
Un fattore di arrotondamento diverso da zero produce tre curve cubiche per arrotondare il vertice: la curva circolare interna (come prima) più due curve fiancheggianti che effettuano la transizione tra la curva interna e i bordi del poligono.
Figura 6. Un fattore di arrotondamento diverso da zero produce tre curve cubiche per arrotondare il vertice: la curva circolare interna (come prima) più due curve fiancheggianti che effettuano la transizione tra la curva interna e i bordi del poligono.

Ad esempio, lo snippet seguente illustra la sottile differenza nell'impostazione del livellamento 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 livellamento.
Figura 7. Due triangoli neri che mostrano la differenza nel parametro di smussamento.

Dimensioni e posizione

Per impostazione predefinita, viene creata una forma 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 ha una forma più piccola poiché gli angoli arrotondati saranno più vicini al centro rispetto ai vertici arrotondati. Per ridimensionare un poligono, regola il valore radius. Per regolare la posizione, modifica i centerX o centerY del poligono. In alternativa, trasforma l'oggetto in modo da cambiarne dimensioni, posizione e rotazione utilizzando le funzioni di trasformazione DrawScope standard come DrawScope#translate().

Forme di morphing

Un oggetto Morph è una nuova forma che rappresenta un'animazione tra due forme poligonali. Per modificare la trasformazione tra due forme, crea due oggetti RoundedPolygons e un oggetto Morph che assuma queste due forme. Per calcolare una forma tra le forme di inizio e fine, fornisci un valore progress compreso tra zero e uno per determinarne la forma tra le forme iniziale (0) e 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à strada tra le due forme (triangolo arrotondato e quadrato), ottenendo il seguente risultato:

50% della distanza tra un triangolo arrotondato e un quadrato
Figura 8. il 50% della distanza tra un triangolo arrotondato e un quadrato.

Nella maggior parte degli scenari, il morphing avviene come parte di un'animazione, non solo come rendering statico. Per animare i due elementi, puoi utilizzare le API Animation standard in Compose per modificare il valore di avanzamento nel tempo. Ad esempio, puoi animare all'infinito il morph tra queste due forme come segue:

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

Deformazione continua tra un quadrato e un triangolo arrotondato
Figura 9. Deformazione continua tra un quadrato e un triangolo arrotondato.

Usa poligono come clip

È pratica comune utilizzare il modificatore di clip in Compose per cambiare la modalità di rendering di un componibile e per sfruttare le ombre che disegnano 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 nel seguente snippet:

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

Ne consegue che:

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

Potrebbe non sembrare così diverso dal rendering precedente, ma consente di sfruttare altre funzionalità di Compose. Ad esempio, questa tecnica può essere utilizzata per ritagliare un'immagine e applicare un'ombra intorno all'area 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 un'ombra applicata intorno ai bordi
Figura 11. Forma personalizzata applicata come clip.

Pulsante Morphing al clic

Puoi usare la libreria graphics-shape per creare un pulsante che si trasforma tra due forme alla pressione. Per prima cosa, crea un MorphPolygonShape che estenda Shape, scalando e traducendo il testo per adattarlo alle esigenze. Nota l'avanzamento 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 Morph. Quindi, applica la morphing al pulsante come contorno di un clip, utilizzando interactionSource alla pressione come forza trainante per l'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 tocchi la casella, viene generata la seguente animazione:

Morph applicato come clic tra due forme
Figura 12. Morphing applicato come un clic tra due forme.

Anima forma con morphing all'infinito

Per animare all'infinito una forma di trasformazione, usa rememberInfiniteTransition. Di seguito è riportato un esempio di un'immagine del profilo che cambia forma (e ruota) all'infinito nel tempo. Questo approccio utilizza un piccolo aggiustamento a 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 restituisce 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 sono adatte al tuo caso d'uso, puoi crearne una più personalizzata con un elenco di vertici. Ad esempio, potresti creare una forma di cuore come questa:

Forma di cuore
Figura 14. A forma di cuore.

Puoi specificare i singoli vertici di questa forma utilizzando il sovraccarico RoundedPolygon che richiede una matrice a virgola mobile di coordinate x, y.

Per analizzare il poligono del cuore, nota che il sistema di coordinate polari per specificare i punti semplifica questa operazione rispetto all'uso del sistema di coordinate cartesiane (x,y), dove inizia sul lato destro e procede in senso orario, con 270° a ore 12:

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

Ora è possibile definire la forma in modo più semplice specificando l'angolo (Θ) e il raggio dal centro in ogni punto:

Forma di cuore
Figura 16. Forma di un 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 tradotti 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 gli angoli specifici per ottenere la forma del cuore scelta. Gli angoli 90° e 270° non hanno arrotondamento, al contrario degli altri angoli. 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)
)

Questo si traduce in un cuore rosa:

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

Se le forme precedenti non sono adatte al tuo caso d'uso, valuta l'utilizzo della classe Path per disegnare una forma personalizzata o per caricare un file ImageVector dal disco. La libreria graphics-shapes non è destinata all'utilizzo per forme arbitrarie, ma è specificamente pensata per semplificare la creazione di poligoni arrotondati e animazioni morphing tra loro.

Risorse aggiuntive

Per ulteriori informazioni ed esempi, consulta le seguenti risorse: