Formen in Compose

Mit der Funktion "Schreiben" können Sie Formen aus Polygonen erstellen. Sie können z. B. die folgenden Arten von Formen erstellen:

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

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

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

Mit dieser Bibliothek können Sie Formen aus Polygonen erstellen. Polygone haben nur gerade Kanten und scharfe Ecken, aber es sind auch abgerundete Ecken möglich. Es vereinfacht das Morphen zwischen zwei verschiedenen Formen. Das Morphieren zwischen beliebigen Formen ist schwierig und ist in der Regel ein Problem bei der Entwicklung. Diese Bibliothek macht es jedoch einfach, indem sie zwischen diesen Formen mit ähnlichen polygonalen Strukturen wandelt.

Polygone erstellen

Mit dem folgenden Snippet wird eine grundlegende Polygonform mit 6 Punkten in der Mitte des Zeichnungsbereichs 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 einen RoundedPolygon, der die Geometrie enthält, die die angeforderte Form darstellt. Wenn Sie diese Form in einer App zum Zeichnen verwenden möchten, müssen Sie ein Path-Objekt daraus abrufen. So wird die Form in ein Formular umgewandelt, das in der Funktion „Compose“ gezeichnet wird.

Ecken eines Polygons abrunden

Verwenden Sie den Parameter CornerRounding, um die Ecken eines Polygons abzurunden. Dafür sind die beiden Parameter radius und smoothing erforderlich. Jede abgerundete Ecke besteht aus 1–3 kubischen Kurven, wobei die Mitte eine kreisförmige Bogenform hat, während die beiden Seitenkurven („flankierende Kurven“) vom Rand der Form zur mittleren Kurve übergehen.

Radius

radius ist der Radius des Kreises, der zum Runden eines Scheitelpunkts verwendet wird.

Das folgende abgerundete Eckdreieck wird beispielsweise so erstellt:

Dreieck mit abgerundeten Ecken
Abbildung 3: Dreieck mit abgerundeten Ecken
Der Rundungsradius r bestimmt die kreisförmige Rundungsgröße der abgerundeten Ecken.
Abbildung 4: Der Rundungsradius r bestimmt die kreisförmige Rundungsgröße der abgerundeten Ecken.

Glättung

Die Glättung ist ein Faktor, der festlegt, wie lange es dauert, bis der kreisförmige Rundungsabschnitt der Ecke bis zum Rand benötigt wird. Ein Glättungsfaktor von 0 (ungeglättet, der Standardwert für CornerRounding) führt zu einer rein kreisförmigen Rundung der Ecken. Ein Glättungsfaktor ungleich null (bis zu 1,0) führt dazu, dass die Ecke durch drei separate Kurven gerundet 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 zum Runden des Scheitelpunkts: die innere kreisförmige Kurve (wie zuvor) plus zwei flankierende Kurven, die einen Übergang zwischen der inneren Kurve und den Polygonkanten ergeben.
Abbildung 6: Ein Glättungsfaktor ungleich null erzeugt drei kubische Kurven zum Runden des Scheitelpunkts: die innere kreisförmige Kurve (wie zuvor) plus zwei flankierende Kurven, die einen Übergang zwischen der inneren Kurve und den Polygonkanten ergeben.

Das folgende Snippet veranschaulicht beispielsweise den geringfügigen Unterschied bei 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 in den Glättungsparametern darstellen.
Abbildung 7: Zwei schwarze Dreiecke, die den Unterschied bei den Glättungsparametern darstellen.

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 Außeneckpunkten des Polygons, auf dem die Form basiert. Durch das Abrunden der Ecken wird eine kleinere Form erzielt, da die abgerundeten Ecken näher an der Mitte liegen als die abgerundeten Eckpunkte. Passen Sie die Größe eines Polygons an den Wert radius an. Um die Position anzupassen, ändern Sie centerX oder centerY des Polygons. Alternativ können Sie das Objekt mit Standard-Transformationsfunktionen wie DrawScope#translate() transformieren, um seine Größe, Position und Drehung zu ändern.DrawScope

Morphformen

Ein Morph-Objekt ist eine neue Form, die eine Animation zwischen zwei Polygonformen darstellt. Wenn Sie zwischen zwei Formen wandeln möchten, erstellen Sie zwei RoundedPolygons und ein Morph-Objekt, das diese beiden Formen verwendet. Um eine Form zwischen der Start- und Endform zu berechnen, 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 auf halber Strecke zwischen den beiden Formen (abgerundetes Dreieck und ein Quadrat), was zu folgendem Ergebnis führt:

50% der Strecke zwischen einem abgerundeten Dreieck und einem Quadrat
Abbildung 8: 50% der Strecke zwischen einem abgerundeten Dreieck und einem Quadrat.

In den meisten Szenarien erfolgt das Morphen im Rahmen einer Animation und nicht nur als statisches Rendering. Zur Animation zwischen diesen beiden Funktionen können Sie die standardmäßigen Animations-APIs in Compose verwenden, um den Fortschrittswert im Laufe der Zeit zu ändern. So lässt sich beispielsweise das Morphen zwischen diesen beiden Formen 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 Veränderung zwischen einem Quadrat und einem abgerundeten Dreieck
Abbildung 9: Unendliche Veränderung zwischen einem Quadrat und einem abgerundeten Dreieck.

Polygon als Clip verwenden

Mit dem clip-Modifikator in Compose ändern Sie, wie eine zusammensetzbare Funktion gerendert wird, und nutzen die Schatten, die den Zuschnittbereich umgeben:

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

Sie können das Polygon dann 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.

Dies unterscheidet sich möglicherweise nicht so sehr vom vorherigen Rendering, ermöglicht jedoch die Nutzung weiterer Funktionen in Compose. Diese Technik kann beispielsweise verwendet werden, um ein Bild zuzuschneiden und einen Schatten um den zugeschnittenen Bereich herum anzuwenden:

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 Sechseck mit Schatten an den Ecken
Abbildung 11: Benutzerdefinierte Form als Clip angewendet.

Morph-Schaltfläche beim Klicken

Sie können die graphics-shape-Bibliothek verwenden, um eine Schaltfläche zu erstellen, die sich beim Drücken zwischen zwei Formen wechselt. Erstellen Sie zuerst einen MorphPolygonShape, der Shape erweitert und ihn entsprechend skaliert und übersetzt. Wie Sie sehen, wird der Fortschritt übergeben, 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)
    }
}

Um diese Morphform zu verwenden, erstellen Sie die beiden Polygone shapeA und shapeB. Erstellen Sie die Morph und merken Sie sie sich. Wenden Sie das Morph auf die Schaltfläche als Clipumriss an. Verwenden Sie dazu das 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))
}

Wenn Sie auf das Feld tippen, wird die folgende Animation angezeigt:

Morph als Klick zwischen zwei Formen angewendet
Abbildung 12: Morph wird als Klick zwischen zwei Formen angewendet.

Unendliche Animierung der Formänderung

Verwenden Sie rememberInfiniteTransition, um eine Morphform endlos zu animieren. Unten siehst du ein Beispiel für ein Profilbild, das seine Form im Laufe der Zeit unendlich ändert (und sich dreht). Bei diesem Ansatz wird eine kleine Anpassung an der oben gezeigten MorphPolygonShape vorgenommen:

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 unterhaltsame Ergebnis:

Herzform
Abbildung 13: Profilbild, das von einem rotierenden muschelförmigen Muster abgeschnitten ist.

Benutzerdefinierte Polygone

Wenn aus regelmäßigen Polygonen erstellte Formen Ihren Anwendungsfall nicht abdecken, können Sie eine benutzerdefinierte Form mit einer Liste von Eckpunkten erstellen. Sie können z. B. eine Herzform wie diese erstellen:

Herzform
Abbildung 14: Herzform.

Sie können die einzelnen Eckpunkte dieser Form mithilfe der RoundedPolygon-Überlastung angeben, für die ein Float-Array aus x- und y-Koordinaten verwendet wird.

Beachten Sie beim Aufschlüsseln des Herzpolygons, dass das polare Koordinatensystem zum Angeben von Punkten dies einfacher macht als die Verwendung des kartesischen Koordinatensystems (x, y), bei dem auf der rechten Seite beginnt und im Uhrzeigersinn weiterläuft, wobei 270° bei 12 Uhr steht:

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 Funktion RoundedPolygon ü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))

Mit dem vorherigen Code erhalten Sie die unbearbeiteten Eckpunkte für das Herz. Sie müssen jedoch bestimmte Ecken abrunden, um die ausgewählte Herzform zu erhalten. Die Ecken bei 90° und 270° haben keine Rundung, aber die anderen Ecken. Um eine individuelle Rundung einzelner Ecken zu erzielen, 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)
)

Dies führt zum rosa Herz:

Herzform
Abbildung 17: Ergebnis der Herzform.

Wenn die vorherigen Formen Ihren Anwendungsfall nicht abdecken, können Sie die Klasse Path verwenden, um eine benutzerdefinierte Form zu zeichnen, oder eine ImageVector-Datei vom Laufwerk laden. Die graphics-shapes-Bibliothek ist nicht für die Verwendung für beliebige Formen vorgesehen, soll jedoch das Erstellen abgerundeter Polygone und Morph-Animationen zwischen ihnen vereinfachen.

Weitere Informationen

Weitere Informationen und Beispiele finden Sie in den folgenden Ressourcen: