Formes dans Compose

Avec Compose, vous pouvez créer des formes à partir de polygones. Par exemple, vous pouvez créer les types de formes suivants :

Hexagone bleu au centre de la zone de dessin
Figure 1. Exemples de différentes formes que vous pouvez créer avec des formes graphiques bibliothèque

Pour créer un polygone arrondi personnalisé dans Compose, ajoutez le Dépendance graphics-shapes à votre app/build.gradle:

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

Cette bibliothèque vous permet de créer des formes composées de polygones. Bien que polygonale les formes n'ont que des bords droits et des angles tranchants, ces formes permettent et des angles arrondis facultatifs. Il permet de passer facilement d'un texte à un autre formes. Il est difficile de transformer des formes arbitraires en problème au moment de la conception. Mais cette bibliothèque vous facilite la tâche en effectuant des morphologies entre ces des formes avec des structures polygonales similaires.

Créer des polygones

L'extrait de code suivant crée une forme de polygone de base avec six points au centre de la zone de dessin :

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

Hexagone bleu au centre de la zone de dessin
Figure 2. Hexagone bleu au centre de la zone de dessin.

Dans cet exemple, la bibliothèque crée un RoundedPolygon qui contient la géométrie représentant la forme demandée. Pour dessiner cette forme dans une application Compose, vous devez en obtenir un objet Path afin de la mettre dans une forme que Compose sait dessiner.

Arrondir les angles d'un polygone

Pour arrondir les angles d'un polygone, utilisez le paramètre CornerRounding. Ce accepte deux paramètres, radius et smoothing. Chaque coin arrondi est composé de 1 à 3 courbes cubiques, dont le centre a une forme d'arc circulaire tandis que les deux les courbes d'un côté ("flanque") passent de l'arête de la forme à la courbe centrale.

Radius

radius correspond au rayon du cercle utilisé pour arrondir un sommet.

Par exemple, le triangle aux coins arrondis suivant est créé comme suit :

Triangle avec coins arrondis
Figure 3. Triangle avec des coins arrondis.
Le rayon d'arrondi r détermine la taille de l'arrondi circulaire des coins arrondis.
Figure 4. Le rayon d'arrondi r détermine la taille de l'arrondi circulaire des coins arrondis.

Lissage

Le lissage est un facteur qui détermine le temps nécessaire pour passer arrondit la partie arrondie de l'angle par rapport au bord. Un facteur de lissage de 0 (non lissé, valeur par défaut pour CornerRounding) entraîne un arrondi des coins purement circulaire. Un facteur de lissage non nul (jusqu'à 1,0) génère l'angle étant arrondi par trois courbes distinctes.

Un facteur de lissage de 0 (non lissé) produit une seule courbe cubique qui suit un cercle autour du coin avec le rayon d'arrondi spécifié, comme dans l'exemple précédent.
Figure 5 : Un facteur de lissage de 0 (non lissé) produit une seule courbe cubique qui suit un cercle autour du coin avec le rayon d'arrondi spécifié, comme dans l'exemple précédent.
Un facteur de lissage non nul produit trois courbes cubiques à arrondir
le sommet: la courbe circulaire intérieure (comme précédemment) plus deux courbes flanques qui
entre la courbe intérieure et les bords du polygone.
Figure 6 : Un facteur de lissage non nul produit trois courbes cubiques à arrondir le sommet: la courbe circulaire intérieure (comme précédemment) plus deux courbes flanques qui transition entre la courbe intérieure et les bords du polygone.

Par exemple, l'extrait de code ci-dessous illustre la différence subtile entre définir le lissage sur 0 et sur 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)
)

Deux triangles noirs montrant la différence de lissage
.
Figure 7 : Deux triangles noirs illustrant la différence de paramètre de lissage.

Taille et position

Par défaut, une forme est créée avec un rayon de 1 autour du centre (0, 0). Ce rayon représente la distance entre le centre et les sommets extérieurs. du polygone sur lequel se base la forme. Notez que l'arrondi des coins donne une forme plus petite, car les coins arrondis sont plus proches du centre que les sommets arrondis. Pour dimensionner un polygone, ajustez la radius . Pour ajuster la position, modifiez le centerX ou le centerY du polygone. Vous pouvez également transformer l'objet pour modifier sa taille, sa position et sa rotation à l'aide de fonctions de transformation DrawScope standards telles que DrawScope#translate().

Morpher des formes

Un objet Morph est une nouvelle forme représentant une animation entre deux formes polygonales. Pour passer d'une forme à une autre, créez deux RoundedPolygons et une Morph. qui prend ces deux formes. Pour calculer une forme entre le début et d'extrémité, indiquez une valeur progress comprise entre 0 et 1 pour déterminer forme située entre les formes de début (0) et de fin (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()
)

Dans l'exemple ci-dessus, la progression se situe exactement à mi-chemin entre les deux formes (triangle arrondi et carré), ce qui donne le résultat suivant :

50 % du chemin entre un triangle arrondi et un carré
Figure 8 : 50% de la distance entre un triangle arrondi et un carré.

Dans la plupart des cas, le morphing est effectué dans le cadre d'une animation, et pas seulement d'un rendu statique. Pour créer une animation entre ces deux éléments, vous pouvez utiliser API d'animation dans Compose pour modifier la valeur de progression au fil du temps. Par exemple, vous pouvez animer à l'infini la morphose entre ces deux formes comme suit :

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

Morphing infini entre un carré et un triangle arrondi
Figure 9 : Transformation à l'infini entre un triangle carré et un triangle arrondi.

Utiliser un polygone comme rognage

Il est courant d'utiliser le modificateur clip dans Compose pour modifier le rendu d'un composable et exploiter les ombres qui s'affichent autour de la zone de découpe :

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

Vous pouvez ensuite utiliser le polygone comme extrait, comme illustré dans l'extrait de code suivant :

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

Cela se traduit par:

Hexagone avec le texte "hello compose" au centre.
Figure 10 : Hexagone avec le texte "Hello Compose" au centre.

Cela peut ne pas sembler si différent de ce qui était affiché auparavant, mais cela permet d'exploiter d'autres fonctionnalités de Compose. Par exemple, cette technique peut être utilisée pour rogner une image et appliquer une ombre autour de la zone rognée:

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)

    )
}

Chien dans un hexagone avec une ombre appliquée autour des bords
Figure 11 : Forme personnalisée appliquée en tant qu'extrait.

Bouton "Transformer" lors d'un clic

Vous pouvez utiliser la bibliothèque graphics-shape pour créer un bouton qui bascule entre deux formes à la presse. Tout d'abord, créez un MorphPolygonShape qui étend Shape, en le redimensionnant et en le traduisant pour qu'il s'adapte correctement. Notez la transmission de la progression afin que la forme puisse être animée :

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

Pour utiliser cette forme transformée, créez deux polygones : shapeA et shapeB. Créez et mémorisez le Morph. Ensuite, appliquez la morphologie au bouton en guise de contour de clip, en utilisant l'interactionSource à la pression comme force motrice derrière le 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))
}

L'animation suivante s'affiche lorsque l'utilisateur appuie sur la case:

Morph appliqué en cliquant entre deux formes
Figure 12 : Transformation appliquée en tant que clic entre deux formes.

Animer la métamorphose infinie d'une forme

Pour animer une forme de morphing sans fin, utilisez rememberInfiniteTransition. Vous trouverez ci-dessous un exemple de photo de profil qui change de forme (et effectue une rotation) à l'infini au fil du temps. Cette approche nécessite un léger ajustement MorphPolygonShape affiché ci-dessus:

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

Ce code donne le résultat amusant suivant:

Mains formant un cœur
Figure 13 : Image de profil rognée par une forme festonnée qui tourne.

Polygones personnalisés

Si les formes créées à partir de polygones réguliers ne couvrent pas votre cas d'utilisation, vous pouvez créer une forme plus personnalisée à l'aide d'une liste de sommets. Par exemple, vous pouvez créez un cœur comme ceci:

Mains formant un cœur
Figure 14. en forme de cœur.

Vous pouvez spécifier les sommets individuels de cette forme à l'aide de la surcharge RoundedPolygon qui prend un tableau de nombres à virgule flottante de coordonnées x, y.

Pour décomposer le polygone du cœur, notez que le système de coordonnées polaires spécifier des points facilite cette opération par rapport à l'utilisation de la coordonnée cartésienne (x,y). commence à droite et se poursuit dans le sens des aiguilles d'une montre, avec 270° à 12 heures:

Mains formant un cœur
Figure 15 : En forme de cœur avec des coordonnées

La forme peut désormais être définie plus facilement en spécifiant l'angle (𝜭) et le rayon du centre à chaque point :

Mains formant un cœur
Figure 16. Forme en forme de cœur avec coordonnées, sans arrondis

Les sommets peuvent maintenant être créés et transmis à la fonction 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,
    )
}

Les sommets doivent être convertis en coordonnées cartésiennes à l'aide de cette fonction 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))

Le code précédent vous donne les sommets bruts du cœur, mais vous devez arrondir des coins spécifiques pour obtenir la forme de cœur choisie. Les coins de 90° et 270° n'est pas arrondi, contrairement aux autres angles. Pour obtenir des arrondis personnalisés Pour chaque angle, utilisez le paramètre 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)
)

Vous obtenez le cœur rose :

Mains formant un cœur
Figure 17 : Résultat en forme de cœur.

Si les formes précédentes ne correspondent pas à votre cas d'utilisation, envisagez d'utiliser Path. pour dessiner une image personnalisée forme ou le chargement d'une ImageVector fichier de disque. La bibliothèque graphics-shapes n'est pas destinée à être utilisée pour des formes arbitraires, mais est spécifiquement conçue pour simplifier la création de polygones arrondis et d'animations de morphing entre eux.

Ressources supplémentaires

Pour obtenir plus d'informations et d'exemples, consultez les ressources suivantes: