Forme in Compose

Con Componi puoi creare forme costituite 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 le forme grafiche libreria

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

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

Questa libreria ti consente di creare forme costituite da poligoni. Mentre la modalità poligonale le forme hanno solo bordi rette e angoli taglienti, angoli arrotondati facoltativi. Semplifica il morphing tra due diversi forme. Il morphing è difficile tra forme arbitrarie e tende a essere un problema di progettazione. Tuttavia, questa libreria semplifica il compito grazie alla trasformazione tra queste forme con strutture poligonali simili.

Creare poligoni

Il seguente snippet crea una forma di poligono base con sei 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 rappresentante la forma richiesta. Per disegnare quella forma in un'app Compose, devi estrarre un oggetto Path per ottenere la forma in un modulo che Compose sa disegnare.

Arrotondare gli angoli di un poligono

Per arrotondare gli angoli di un poligono, utilizza il parametro CornerRounding. Richiede due parametri, radius e smoothing. Ogni angolo arrotondato è costituito da 1-3 curve cubiche, il cui centro ha la forma di un arco circolare, mentre le due curve laterali ("di accompagnamento") 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 circolare di
angoli arrotondati
Figura 4. Il raggio di arrotondamento r determina la dimensione di arrotondamento circolare di angoli arrotondati.

Sfumatura

Il perfezionamento è un fattore che determina quanto tempo occorre per ottenere porzione arrotondata circolare dell'angolo fino al bordo. Un fattore di smussamento pari a 0 (non smussato, il valore predefinito per CornerRounding) comporta un smusso puramente circolare degli angoli. Un fattore di livellamento diverso da zero (fino a un massimo di 1,0) l'angolo a cui viene arrotondato con tre curve separate.

Un fattore di spianamento pari a 0 (non levigato) produce una singola curva cubica che segue un cerchio attorno all'angolo con il raggio di arrotondamento specificato, come nell'esempio precedente
Figura 5. Un fattore di interpolazione pari a 0 (non interpolata) produce una singola curva cubica che segue un cerchio attorno all'angolo con il raggio di arrotondamento specificato, come nell'esempio precedente.
Un fattore di smussamento diverso da zero produce tre curve cubiche per arrotondare
il vertice: la curva circolare interna (come prima) più due curve laterali che
transizione tra la curva interna e i bordi del poligono.
Figura 6. Un fattore di smussamento diverso da zero produce tre curve cubiche per arrotondare il vertice: la curva circolare interna (come prima) più due curve laterali che 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 funzionalità di smussamento su 0 e su 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 di levigatura
.
Figura 7. Due triangoli neri che mostrano la differenza nel parametro di spianamento.

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. A questo punto, arrotondare gli angoli riduce la forma poiché gli angoli arrotondati sono più vicini centrale rispetto ai vertici arrotondati. Per ridimensionare un poligono, regola l'radius valore. 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, come DrawScope#translate().

Trasformare le forme

Un oggetto Morph è una nuova forma che rappresenta un'animazione tra due poligonali forme. Per eseguire la morphing tra due forme, crea due oggetti RoundedPolygons e un oggetto Morph che assumono queste due forme. Per calcolare una forma tra le forme iniziale e finale, 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 è esattamente a metà strada tra le due forme. (triangolo arrotondato e un quadrato), generando il seguente risultato:

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

Nella maggior parte degli scenari, il morphing avviene all'interno di un'animazione e non solo il rendering statico. Per animare questi due elementi, puoi utilizzare il modello API di animazione in Compose per apportare modifiche il valore dell'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()
)

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

Utilizzare un poligono come clip

È comune utilizzare il modificatore clip in Compose per modificare il modo in cui viene visualizzato un composable 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)
    )
}

Il risultato è il seguente:

Esagono con il testo "hello compose" al centro.
Figura 10. Esagono con il testo "Un saluto da Scrivi" al centro.

L'aspetto potrebbe non essere molto diverso da quello del rendering precedente, ma consente per 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 un 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 passa da una forma all'altra quando viene premuto. Innanzitutto, crea un MorphPolygonShape che espanda Shape, riscalandolo e traducendolo in modo appropriato. Tieni presente che il progresso viene passato 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 ricordati il Morph. Quindi, applica la trasformazione al pulsante come contorno del clip, utilizzando il pulsante interactionSource premuto 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 tocchi la casella, viene visualizzata la seguente animazione:

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

Anima il morphing della forma all'infinito

Per animare all'infinito una forma morph, usa rememberInfiniteTransition Di seguito è riportato un esempio di immagine di profilo che cambia forma (e ruota) all'infinito nel tempo. Questo approccio prevede una piccola modifica MorphPolygonShape 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 genera il seguente risultato divertente:

Forma di cuore
Figura 13. Immagine del profilo ritagliata da una forma smerlato 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 crea una forma a forma di cuore come questa:

Forma di cuore
Figura 14. A forma di cuore.

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

Per scomporre il poligono del cuore, nota che il sistema di coordinate polari per specificare punti lo rende più facile rispetto all'uso della coordinata cartesiana (x,y) in cui inizia sul lato destro e procede in senso orario, con 270° a ore 12:

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

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

Forma di cuore
Figura 16. Forma a 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 funzioneradialToCartesian:

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 non elaborati del cuore, ma devi arrotondare angoli specifici per ottenere la forma del cuore scelta. Angoli in 90° e 270° non presenta arrotondamenti, mentre gli altri angoli sì. Per ottenere l'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)
)

Viene visualizzato 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 l'utilizzo della Path per disegnare un modello personalizzato di una forma o di caricare File ImageVector da disco. La libreria graphics-shapes non è destinata a essere utilizzata per di forme, ma specificamente pensato per semplificare la creazione di poligoni arrotondati morphing le animazioni tra di loro.

Risorse aggiuntive

Per ulteriori informazioni ed esempi, consulta le seguenti risorse: