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 formes différentes que vous pouvez créer avec la bibliothèque de formes graphiques

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

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

Cette bibliothèque vous permet de créer des formes à partir de polygones. Alors que les formes polygonales n'ont que des bords droits et des angles droits, elles autorisent des angles arrondis facultatifs. Il permet de se transformer facilement entre deux formes différentes. Le changement de forme est difficile entre des formes arbitraires et tend à être un problème au moment de la conception. Mais cette bibliothèque facilite la tâche en transformant ces 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 obtenir un objet Path pour obtenir la forme dans un format que Compose sait comment dessiner.

Arrondir les angles d'un polygone

Pour arrondir les angles d'un polygone, utilisez le paramètre CornerRounding. Elle utilise deux paramètres, radius et smoothing. Chaque angle arrondi est constitué d'une à trois courbes cubiques, dont le centre a une forme en arc circulaire, tandis que les deux courbes ("flanquées") font la transition entre le bord de la forme et la courbe centrale.

Rayon

radius est le rayon du cercle utilisé pour arrondir un sommet.

Par exemple, le triangle d'angle arrondi suivant se présente comme suit:

Triangle aux angles arrondis
Figure 3. Triangle aux angles arrondis
Le rayon d'arrondi r détermine la taille d'arrondi des angles arrondis
Figure 4. Le rayon d'arrondi r détermine la taille d'arrondi des angles arrondis.

Lissage

Le lissage est un facteur qui détermine le temps nécessaire pour passer de la partie arrondie circulaire de l'angle à l'arête. Un facteur de lissage de 0 (non lissé, la valeur par défaut pour CornerRounding) se traduit par un arrondi des coins purement circulaires. Un facteur de lissage non nul (jusqu'à 1,0 au maximum) entraîne l'arrondi de l'angle par trois courbes distinctes.

Un facteur de lissage de 0 (non lissé) produit une seule courbe cubique qui suit un cercle autour de l'angle 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 de l'angle avec le rayon d'arrondi spécifié, comme dans l'exemple précédent.
Un facteur de lissage non nul produit trois courbes cubiques pour arrondir le sommet: la courbe circulaire intérieure (comme précédemment) et deux courbes flanquées qui passent entre la courbe intérieure et les bords du polygone.
Figure 6. Un facteur de lissage non nul produit trois courbes cubiques pour arrondir le sommet: la courbe circulaire intérieure (comme précédemment) et deux courbes flanquées qui passent entre la courbe intérieure et les bords du polygone.

Par exemple, l'extrait ci-dessous illustre la différence subtile entre le paramétrage du 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 paramètre de lissage.
Figure 7 : Deux triangles noirs montrant 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 la forme est basée. Notez que le fait d'arrondir les angles produit une forme plus petite, car les angles arrondis sont plus proches du centre que les sommets arrondis. Pour dimensionner un polygone, ajustez la valeur radius. Pour ajuster la position, modifiez la centerX ou la centerY du polygone. Vous pouvez également transformer l'objet pour modifier sa taille, sa position et sa rotation à l'aide des fonctions de transformation DrawScope standards telles que DrawScope#translate().

Formes de transformation

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 un objet Morph prenant ces deux formes. Pour calculer une forme entre les formes de début et de fin, indiquez une valeur progress comprise entre zéro et un pour déterminer sa forme 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 est exactement à mi-chemin entre les deux formes (triangle arrondi et carré), ce qui produit le résultat suivant:

50% de la distance 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 dans le cadre d'un rendu statique. Pour créer une animation entre ces deux éléments, vous pouvez utiliser les API d'animation standards dans Compose afin de modifier la valeur de progression au fil du temps. Par exemple, vous pouvez animer à l'infini la transformation 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()
)

Transformation à l'infini entre un carré et un triangle arrondi
Figure 9 : Transformation à l'infini entre un 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 profiter des ombres qui apparaissent autour de la zone de rognage:

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 entraîne les résultats suivants:

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

Le rendu peut ne pas être très différent de ce qui se passait auparavant, mais cela permet d'exploiter d'autres fonctionnalités dans Compose. Par exemple, cette technique peut être utilisée pour rogner une image et appliquer une ombre autour de la zone découpé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 sur les bords
Figure 11 : Forme personnalisée appliquée en tant qu'extrait.

Bouton de transformation en un clic

Vous pouvez utiliser la bibliothèque graphics-shape pour créer un bouton qui se transforme en deux formes lorsque l'utilisateur appuie dessus. Tout d'abord, créez un MorphPolygonShape qui étend Shape, en le dimensionnant et en le traduisant pour l'adapter de manière appropriée. Notez le passage 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 de transformation, créez deux polygones, shapeA et shapeB. Créez et retenez la Morph. Appliquez ensuite la transformation au bouton en tant que contour d'extrait, en utilisant interactionSource lors d'une pression comme force motrice derrière l'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 se produit lorsque l'utilisateur appuie sur la case:

Conversion de forme appliquée en clic entre deux formes
Figure 12 : Conversion de forme appliquée en un clic entre deux formes

Animer le morphologie infinie

Pour animer une forme de transformation en continu, utilisez rememberInfiniteTransition. Vous trouverez ci-dessous un exemple de photo de profil qui change de forme (et pivote) à l'infini au fil du temps. Cette approche utilise un léger ajustement de la MorphPolygonShape illustrée 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 : Photo de profil rognée par une forme festonnée en rotation.

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 avec une liste de sommets. Par exemple, vous pouvez créer une forme de cœur comme ceci:

Mains formant un cœur
Figure 14 : Forme de cœur

Vous pouvez spécifier les sommets individuels de cette forme en utilisant la surcharge RoundedPolygon qui accepte un tableau à virgule flottante de coordonnées x et y.

Pour décomposer le polygone du cœur, notez que le système de coordonnées polaire permettant de spécifier des points facilite cette opération que l'utilisation du système cartésien (x,y), où commence sur le côté droit et se poursuit dans le sens des aiguilles d'une montre, avec 270° à la position "12 heures" :

Mains formant un cœur
Figure 15 : Forme de cœur avec coordonnées.

Il est désormais possible de définir la forme plus facilement en spécifiant l'angle (Θ) et le rayon à partir du centre en chaque point:

Mains formant un cœur
Figure 16 : Forme de cœur avec coordonnées, sans arrondi.

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 traduits en coordonnées cartésiennes à l'aide de la 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 angles spécifiques pour obtenir la forme de cœur choisie. Les angles aux niveaux 90° et 270° ne sont pas arrondis, contrairement aux autres angles. Pour effectuer un arrondi personnalisé pour des angles individuels, 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)
)

Cela donne 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 couvrent pas votre cas d'utilisation, envisagez d'utiliser la classe Path pour dessiner une forme personnalisée ou de charger un fichier ImageVector à partir du disque. La bibliothèque graphics-shapes n'est pas destinée à être utilisée pour des formes arbitraires. Elle est spécifiquement conçue pour simplifier la création de polygones arrondis et les animations de transformation entre eux.

Ressources supplémentaires

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