Formen in Compose

Mit Compose können Sie Formen aus Polygonen erstellen. Sie können beispielsweise die folgenden Arten von Formen erstellen:

Blaues Sechseck in der Mitte des Zeichenbereichs
Abbildung 1: Beispiele für verschiedene Formen, die Sie mit der Bibliothek „graphics-shapes“ erstellen können

Wenn Sie in Compose ein benutzerdefiniertes abgerundetes Polygon erstellen möchten, fügen Sie die Abhängigkeit graphics-shapes in Ihre app/build.gradle ein:

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

Mit dieser Bibliothek können Sie Formen aus Polygonen erstellen. Während polygonale Formen nur gerade Kanten und scharfe Ecken haben, können diese Formen optional abgerundete Ecken haben. Damit lassen sich zwei verschiedene Formen ganz einfach ineinander morphen. Das Morphing zwischen beliebigen Formen ist schwierig und stellt eher ein Problem während der Designphase dar. Diese Bibliothek vereinfacht das Ganze, indem sie zwischen diesen Formen mit ähnlichen polygonalen Strukturen morpht.

Polygone erstellen

Mit dem folgenden Snippet wird eine einfache Polygonform mit sechs Punkten in der Mitte des Zeichenbereichs erstellt:

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

Blaues Sechseck in der Mitte des Zeichenbereichs
Abbildung 2: Blaues Sechseck in der Mitte des Zeichenbereichs.

In diesem Beispiel erstellt die Bibliothek ein RoundedPolygon, das die Geometrie für die angeforderte Form enthält. Wenn Sie diese Form in einer Compose-Anwendung zeichnen möchten, müssen Sie ein Path-Objekt daraus abrufen, um die Form in ein Format zu bringen, das von Compose gezeichnet werden kann.

Ecken eines Polygons abrunden

Verwenden Sie den Parameter CornerRounding, um die Ecken eines Polygons abzurunden. Dafür sind zwei Parameter erforderlich: radius und smoothing. Jede abgerundete Ecke besteht aus 1–3 kubischen Kurven, deren Mitte die Form eines Kreisbogens hat, während die beiden seitlichen („flankierenden“) Kurven von der Kante der Form zur mittleren Kurve übergehen.

Radius

radius ist der Radius des Kreises, der zum Abrunden eines Eckpunkts verwendet wird.

Das folgende Dreieck mit abgerundeten Ecken wird beispielsweise so erstellt:

Dreieck mit abgerundeten Ecken
Abbildung 3: Dreieck mit abgerundeten Ecken.
Der Rundungsradius r bestimmt die Größe der kreisförmigen Rundung von abgerundeten Ecken.
Abbildung 4: Der Abrundungsradius r bestimmt die Größe der kreisförmigen Abrundung von abgerundeten Ecken.

Glättung

Die Glättung ist ein Faktor, der bestimmt, wie lange es dauert, bis der kreisförmige Rundungsabschnitt der Ecke die Kante erreicht. Ein Glättungsfaktor von 0 (nicht geglättet, der Standardwert für CornerRounding) führt zu einer rein kreisförmigen Eckenabrundung. Ein Glättungsfaktor ungleich null (bis maximal 1,0) führt dazu, dass die Ecke durch drei separate Kurven abgerundet wird.

Ein Glättungsfaktor von 0 (nicht geglättet) erzeugt eine einzelne kubische Kurve, die einem Kreis um die Ecke mit dem angegebenen Rundungsradius folgt, wie im vorherigen Beispiel.
Abbildung 5: Ein Glättungsfaktor von 0 (nicht geglättet) erzeugt eine einzelne kubische Kurve, die einem Kreis um die Ecke mit dem angegebenen Rundungsradius folgt, wie im vorherigen Beispiel.
Ein Glättungsfaktor ungleich null erzeugt drei kubische Kurven, um den Scheitelpunkt abzurunden: die innere kreisförmige Kurve (wie zuvor) sowie zwei flankierende Kurven, die den Übergang zwischen der inneren Kurve und den Polygonkanten bilden.
Abbildung 6: Ein Glättungsfaktor ungleich null erzeugt drei kubische Kurven, um den Scheitelpunkt abzurunden: die innere kreisförmige Kurve (wie zuvor) sowie zwei flankierende Kurven, die den Übergang zwischen der inneren Kurve und den Polygonkanten bilden.

Das folgende Snippet veranschaulicht beispielsweise den feinen Unterschied zwischen der Einstellung der Glättung auf 0 und 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)
)

Zwei schwarze Dreiecke, die den Unterschied im Glättungsparameter zeigen.
Abbildung 7. Zwei schwarze Dreiecke, die den Unterschied im Glättungsparameter zeigen.

Größe und Position

Standardmäßig wird eine Form mit einem Radius von 1 um den Mittelpunkt (0, 0) erstellt. Dieser Radius entspricht dem Abstand zwischen dem Mittelpunkt und den äußeren Eckpunkten des Polygons, auf dem die Form basiert. Beachten Sie, dass das Abrunden der Ecken zu einer kleineren Form führt, da die abgerundeten Ecken näher am Mittelpunkt liegen als die abgerundeten Eckpunkte. Wenn Sie die Größe eines Polygons anpassen möchten, ändern Sie den Wert radius. Wenn Sie die Position anpassen möchten, ändern Sie den centerX- oder centerY-Wert des Polygons. Alternativ können Sie das Objekt transformieren, um seine Größe, Position und Drehung mit Standard-DrawScope-Transformationsfunktionen wie DrawScope#translate() zu ändern.

Formen morphen

Ein Morph-Objekt ist eine neue Form, die eine Animation zwischen zwei polygonalen Formen darstellt. Wenn Sie zwei Formen morphen möchten, erstellen Sie zwei RoundedPolygons-Objekte und ein Morph-Objekt, das diese beiden Formen annimmt. Wenn Sie eine Form zwischen der Start- und der Endform berechnen möchten, geben Sie einen progress-Wert zwischen null und eins an, um die Form zwischen der Startform (0) und der Endform (1) zu bestimmen:

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

Im obigen Beispiel liegt der Fortschritt genau in der Mitte zwischen den beiden Formen (abgerundetes Dreieck und Quadrat), was zu folgendem Ergebnis führt:

50% des Weges zwischen einem abgerundeten Dreieck und einem Quadrat
Abbildung 8. 50% des Weges zwischen einem abgerundeten Dreieck und einem Quadrat.

In den meisten Szenarien wird das Morphing im Rahmen einer Animation und nicht nur als statisches Rendering durchgeführt. Wenn Sie zwischen diesen beiden Werten animieren möchten, können Sie die standardmäßigen Animation APIs in Compose verwenden, um den Fortschrittswert im Laufe der Zeit zu ändern. Sie können beispielsweise die Morph-Animation zwischen diesen beiden Formen wie folgt unendlich animieren:

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

Unendliche Verwandlung zwischen einem Quadrat und einem abgerundeten Dreieck
Abbildung 9. Unendliche Verwandlung zwischen einem Quadrat und einem abgerundeten Dreieck.

Polygon als Ausschnitt verwenden

In Compose wird häufig der Modifikator clip verwendet, um die Darstellung einer zusammensetzbaren Funktion zu ändern und Schatten um den Zuschneidebereich zu zeichnen:

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

Anschließend können Sie das Polygon als Clip verwenden, wie im folgenden Snippet gezeigt:

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

Daraus ergibt sich Folgendes:

Sechseck mit dem Text „hello compose“ in der Mitte.
Abbildung 10. Sechseck mit dem Text „Hello Compose“ in der Mitte.

Das sieht vielleicht nicht viel anders aus als vorher, aber wir können jetzt andere Funktionen in Compose nutzen. Mit dieser Technik lässt sich beispielsweise ein Bild zuschneiden und ein Schatten um den zugeschnittenen Bereich anwenden:

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)

    )
}

Hund in einem Sechseck mit Schatten um die Ränder
Abbildung 11: Benutzerdefinierte Form als Clip angewendet.

Schaltfläche bei Klick morphen

Mit der graphics-shape-Bibliothek können Sie eine Schaltfläche erstellen, die sich beim Drücken zwischen zwei Formen verwandelt. Erstellen Sie zuerst ein MorphPolygonShape-Objekt, das Shape erweitert, und skalieren und verschieben Sie es entsprechend. Beachten Sie, dass der Fortschritt übergeben wird, damit die Form animiert werden kann:

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

Wenn Sie diese Morph-Form verwenden möchten, erstellen Sie zwei Polygone, shapeA und shapeB. Erstellen Sie die Morph und merken Sie sie sich. Wenden Sie dann die Morph-Animation als Clip-Umriss auf die Schaltfläche an und verwenden Sie interactionSource beim Drücken als treibende Kraft hinter der Animation:

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

Das führt zu folgender Animation, wenn auf das Feld getippt wird:

Morphing wird als Klick zwischen zwei Formen angewendet
Abbildung 12. Morphing wird als Klick zwischen zwei Formen angewendet.

Animation mit einer Form, die sich unendlich verändert

Wenn Sie eine Morph-Form endlos animieren möchten, verwenden Sie rememberInfiniteTransition. Unten sehen Sie ein Beispiel für ein Profilbild, das seine Form im Laufe der Zeit unendlich ändert (und sich dreht). Bei diesem Ansatz wird die oben gezeigte MorphPolygonShape leicht angepasst:

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

Dieser Code liefert das folgende lustige Ergebnis:

Herzform
Abbildung 13. Profilbild, das von einer rotierenden, gezackten Form umgeben ist.

Benutzerdefinierte Polygone

Wenn Formen, die aus regelmäßigen Polygonen erstellt wurden, Ihren Anwendungsfall nicht abdecken, können Sie eine benutzerdefinierte Form mit einer Liste von Eckpunkten erstellen. Sie möchten beispielsweise eine Herzform wie diese erstellen:

Herzform
Abbildung 14: Herzform.

Sie können die einzelnen Eckpunkte dieser Form mit der Überladung RoundedPolygon angeben, die ein Float-Array mit x- und y-Koordinaten verwendet.

Um das Herzpolygon aufzuschlüsseln, beachten Sie,dass das Polarkoordinatensystem zur Angabe von Punkten dies einfacher macht als das kartesische (x, y)-Koordinatensystem, bei dem 0° auf der rechten Seite beginnt und im Uhrzeigersinn verläuft, wobei 270° auf der 12-Uhr-Position liegt:

Herzform
Abbildung 15: Herzform mit Koordinaten.

Die Form kann jetzt einfacher definiert werden, indem der Winkel (𝜭) und der Radius vom Mittelpunkt an jedem Punkt angegeben werden:

Herzform
Abbildung 16: Herzform mit Koordinaten, ohne Rundung.

Die Eckpunkte können jetzt erstellt und an die RoundedPolygon-Funktion übergeben werden:

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

Die Eckpunkte müssen mit der folgenden radialToCartesian-Funktion in kartesische Koordinaten umgewandelt werden:

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

Der vorherige Code liefert die Rohdaten für die Eckpunkte des Herzens. Sie müssen jedoch bestimmte Ecken abrunden, um die gewünschte Herzform zu erhalten. Die Ecken bei 90° und 270° sind nicht abgerundet, die anderen Ecken jedoch schon. Wenn Sie einzelne Ecken benutzerdefiniert abrunden möchten, verwenden Sie den Parameter 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)
)

Das Ergebnis ist das pinke Herz:

Herzform
Abbildung 17. Ergebnis in Herzform.

Wenn die oben genannten Formen für Ihren Anwendungsfall nicht geeignet sind, können Sie die Klasse Path verwenden, um eine benutzerdefinierte Form zu zeichnen, oder eine ImageVector-Datei von der Festplatte laden. Die graphics-shapes-Bibliothek ist nicht für die Verwendung mit beliebigen Formen gedacht, sondern soll speziell die Erstellung von abgerundeten Polygonen und Morph-Animationen zwischen ihnen vereinfachen.

Zusätzliche Ressourcen

Weitere Informationen und Beispiele finden Sie in den folgenden Ressourcen: