Ingénieur logiciel
Ingénieur Relations avec les développeurs
Prêt à transformer l'UI de votre application Android en véritable chef-d'œuvre ?
Rejoignez-nous pour découvrir les dernières fonctionnalités graphiques de Jetpack Compose. Nous allons vous montrer comment créer des effets visuels, des dégradés de maillage aux flous progressifs, en utilisant les nouvelles fonctionnalités graphiques puissantes d'Android. Nous vous montrerons également comment améliorer les performances de vos fonctionnalités graphiques, sans sacrifier l'autonomie de la batterie ni la fréquence d'images.
PolygonShape et MorphPolygonShape natives pour les géométries personnalisées. MorphPolygonShape lit la progression pendant la phase de résolution du contour, en mettant à jour les formes image par image sans déclencher de recomposition.RenderNode.setBackdropRenderEffect pour l'échantillonnage du contenu en arrière-plan. Les développeurs peuvent enchaîner les effets d'arrière-plan pour créer des effets de verre dépoli ou de loupe.graphicsLayer.Transcription
Bonjour à tous et bienvenue dans notre session "Améliorez vos applications avec les dernières fonctionnalités graphiques de Compose et Android".
Je m'appelle Rebecca Franks et je suis accompagnée aujourd'hui sur scène par…
Je m'appelle Aayush Chaudhary.
Bonjour, je m'appelle Rahul Ray.
Nous constatons une tendance émergente : vous repoussez tous les limites du possible sur Android et Compose, en utilisant l'IA pour donner vie à vos idées incroyables.
Nous aimons voir des applications qui sont premium, natives et attrayantes.
Nous savons que les applications qui sont agréables à utiliser et qui obtiennent régulièrement d'excellentes notes de la part des utilisateurs sont celles qui tirent généralement parti des dernières modifications de l'API au niveau du système, qui assurent la continuité entre les surfaces avec des éléments partagés et du flou, qui s'adaptent parfaitement aux différents facteurs de forme, qui sont performantes, qui ne laissent pas les utilisateurs dans l'incertitude et qui repoussent les limites de ce qu'il est possible de faire avec votre appareil.
Dans cette présentation, nous allons vous montrer quelques-unes des dernières fonctionnalités qui vous aident à tirer parti d'Android, qu'il s'agisse de ravir les utilisateurs avec des dégradés de maillage, d'activer la prise en charge d'une large gamme de couleurs, de proposer des transitions agréables avec des formes polygonales, de découvrir les nouveautés en matière de floutage et, enfin, de réfléchir aux performances lorsque vous ajoutez de nouvelles fonctionnalités graphiques à vos applications.
Mais tout au long de la présentation d'aujourd'hui, nous allons aborder certaines des nouvelles API à différents niveaux de la pile.
Bien que l'ancien kit d'outils d'UI View soit désormais en mode maintenance, nous continuons d'investir dans les moteurs de rendu matériel graphique Android sous-jacents qui alimentent Jetpack Compose.
On peut considérer les graphismes dans Android comme trois couches différentes : les API Compose au premier niveau, où se trouvent toutes les nouvelles API graphiques d'UI de haut niveau, comme les peintres de dégradés de maillage, les formes polygonales et les API de flou progressif.
Ensuite, nous avons la couche intermédiaire, où DrawScope de Compose, les nœuds de modificateur de dessin et AGSL vous permettent de créer des effets personnalisés.
Enfin, nous avons la plate-forme Android. Compose peut toujours accéder à un canevas natif et utiliser de nouvelles primitives de rendu de l'OS directement dans vos modificateurs Compose.
Nous allons vous montrer quelques exemples de ce que nous faisons aujourd'hui.
C'est parti.
Commençons par les dégradés de maillage, une nouvelle API introduite dans Compose 1.12.
Auparavant, nous avions des dégradés linéaires, des dégradés de balayage et des dégradés radiaux, qui sont parfaits pour les transitions simples. Toutefois, si vous souhaitez des mélanges multicolores riches, comme des arrière-plans ambiants ou des effets d'éclairage, vous avez besoin d'une grille 2D de système où les positions et les couleurs peuvent se mélanger dans l'espace.
Pour créer un dégradé de maillage comme celui-ci, commencez par spécifier le nombre de lignes et de colonnes de patchs que vous souhaitez.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}Ensuite, dans le lambda de fin, appelez setVertex et attribuez un décalage normalisé compris entre 0F et 1F, ainsi qu'une couleur pour chaque intersection de la grille.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
}
}Pour animer ces sommets, ajoutez des décalages animés pour huit sommets.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
// ....
setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
// ....
setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
// ....
}
}
Box(
modifier = modifier.padding(32.dp)
.safeContentPadding()
.aspectRatio(16 / 9f)
.fillMaxWidth()
.paint(gradientPainter)
)Une fois votre Painter défini, il vous suffit de l'appeler avec n'importe quel composable via un modificateur de peinture.
Nous avons ajouté une fonctionnalité à l'application JetChat pour permettre d'aimer un message en appuyant deux fois dessus.
Pour ce faire, nous configurons un maillage de 2 par 3 et plaçons nos points de dégradé sur le composant.
MeshGradientPainter(
rows = 2,
columns = 3,
hasBicubicColor = true,
) {
// Row 0 (top edge, y = 0.0f)
setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))
// ... remaining vertices for Rows 0, 1, and 2
}Notez que nous avons défini hasBicubicColor sur "true" ici.
Par défaut, les couleurs sont interpolées de manière linéaire sur les triangles, ce qui peut parfois laisser des zones visibles.
Si vous activez cette option, le nuanceur passe à l'interpolation bicubique lisse, de sorte que les couleurs se fondent parfaitement les unes dans les autres.
Maintenant, que faire si vous souhaitez créer une lueur ambiante animée comme celle-ci derrière le champ de saisie ?
Pour obtenir un effet similaire avec un dégradé de maillage, vous avez besoin de deux éléments.
Tout d'abord, un mouvement organique fluide pour les sommets intérieurs et un moyen d'adoucir les bords extérieurs pour éviter d'obtenir un rectangle rigide autour de la carte.
Nous commençons donc par configurer un rememberInfiniteTransition avec deux angles de phase s'exécutant sur des durées légèrement différentes, de sorte que le mouvement semble organique plutôt que mécanique.
val transition = rememberInfiniteTransition(label = "userInputGlowMesh")
val phase1 by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2800, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "glowPhase1",
)
val phase2 by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "glowPhase2",
)Ici, nous animons les sommets intérieurs et nous épinglons les anneaux extérieurs, l'anneau extérieur des sommets avec des bords avec une valeur alpha de 0.
Comme la limite extérieure est fixe et transparente, la lueur s'estompe naturellement.
Enfin, nous transmettons ces décalages animés à notre MeshGradientPainter.
MeshGradientPainter(
rows = 6,
columns = 5,
hasBicubicColor = true,
) {
for (r in 0..6) {
for (c in 0..5) {
val offset = if (r in 1..5 && c in 1..4) {
val ampX = when (r) {
1, 2 -> 0.048f
3 -> 0.042f
4 -> 0.028f
else -> 0.036f
}
val ampY = when (r) {
1, 2 -> 0.028f
3 -> 0.020f
4 -> 0.022f
else -> 0.016f
}
val d = orbit(c, r, ampX, ampY)
Offset(
x = (baseU + d.x).coerceIn(0.04f, 0.96f),
y = (baseV + d.y).coerceIn(0.05f, 0.95f),
)
} else {
Offset(baseU, baseV)
}
setVertex(r, c, offset, gridColors[r][c])
}
}
}Le dégradé de maillage que vous venez de voir est dessiné à l'aide de l'API drawMesh sur Android 14 ou version ultérieure.
Examinons de plus près cette API en créant un effet de toutes pièces.
Pour l'écran d'accueil de JetChat, nous voulons dessiner un tracé qui ressemble à un ruban.
Notez deux choses ici : la largeur du trait n'est pas constante et la couleur s'étend le long de chaque trait du tracé.
Voyons ce dont nous avons réellement besoin pour créer cet effet.
Pour chaque pixel, nous devons décider s'il faut le dessiner ou non, et quelle couleur utiliser.
Pour le savoir, nous avons besoin de deux distances.
La première valeur correspond à la distance entre un pixel et le centre du trait.
La deuxième distance correspond à la distance parcourue par un pixel le long du tracé, du début à la fin.
Cela nous indique quelle couleur choisir dans le dégradé, et le fait de mélanger un peu de la première valeur permet de courber la couleur sur toute la largeur.
Est-il possible de dessiner un dégradé le long d'un tracé en utilisant uniquement un tracé et un pinceau ?
Donc, oui, nous le pouvons.
Pour cela, il faut aplatir un tracé en une liste de courtes lignes droites, ce qui donne également la distance le long du tracé.
val lines = remember {
path.asAndroidPath().flatten(0.5f)
}
//... inside draw scope
lines.forEach { line ->
//...
drawLine(
brush = Brush.linearGradient(...),
start = Offset(line.start.x,
line.start.y),
end = Offset(line.end.x, line.end.y),
strokeWidth = 30f,
)
//...
}Ensuite, nous dessinons chaque ligne avec son propre dégradé.
Cela fonctionne, mais chaque ligne nécessite un appel de dessin distinct et un objet de peinture, et l'ensemble du tracé n'a toujours qu'une seule largeur.
L'autre option est un nuanceur d'exécution, mais il n'obtient que la position des pixels dans la limite du conteneur.
Il ne nous indique pas ce dont nous avons besoin pour dessiner ce ruban.
Et nous ne pouvons pas non plus le calculer facilement à partir de la position des pixels.
Pour cela, Draw Mesh nous fournit un nuanceur de sommets.
Qu'est-ce qu'un réseau maillé ?
Un maillage est constitué de triangles avec des données à chaque angle, et chaque angle est appelé un sommet.
Chaque sommet contient un petit enregistrement de données.
En général, cet enregistrement contient une position, mais ce n'est pas obligatoire.
Le nuanceur de sommets lit cet enregistrement et calcule la position à partir de celui-ci.
Voici le chemin emprunté par un triangle : le nuanceur de sommets s'exécute pour chaque angle, calcule la position des angles et toute valeur que nous voulons transmettre pour chaque pixel.
Ensuite, le rastériseur trouve chaque pixel couvert par ce triangle et interpole les valeurs de ces trois angles pour chaque pixel.
Enfin, le nuanceur de fragments s'exécute pour ces pixels, utilise ces valeurs interpolées et calcule la couleur des pixels.
Voyons comment écrire un nuanceur de vertex et de fragment avec un exemple plus simple : une ligne droite avec une impulsion qui se déplace le long de celle-ci.
LineShaders.VERTEX
uniform float2 uStart;
uniform float2 uEnd;
uniform float uHalfWidth;
uniform float uTime;
Varyings main(const Attributes a) {
Varyings v;
float2 dir = uEnd - uStart;
float2 normal = normalize(float2(-dir.y, dir.x));
float along = a.corner.x * length(dir);
float pulse = (a.corner.x - uTime) / 0.18;
float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
float across = a.corner.y * (radius + 1.0);
v.position = uStart + dir * a.corner.x + normal * across;
v.stroke = float3(along, across, radius);
return v;
}Le nuanceur de sommets prend un attribut en entrée et renvoie une variable de nuanceur.
Les attributs sont les données d'un seul angle.
Ici, il n'y a que deux nombres : sa progression le long de la ligne et de quel côté de la ligne il se trouve.
Les variables sont ensuite interpolées et transmises au nuanceur de fragments.
Nous calculons la distance en pixels depuis le début et, pour la largeur du trait, nous utilisons le temps pour créer une impulsion sur le rayon.
Ensuite, nous multiplions ce rayon par le côté du coin, ce qui nous donne la distance signée sur le trait.
Lorsque le GPU interpole entre ces signes, les lignes centrales deviennent nulles.
Enfin, nous calculons la position des angles, nous stockons toutes ces valeurs dans des variables de trait et nous les transmettons au nuanceur de fragment.
Contrairement au nuanceur d'exécution, un nuanceur de fragment de maillage génère une couleur via un paramètre et renvoie une position pour échantillonner la peinture si elle est définie.
LineShaders.FRAGMENT
uniform float uLength;
layout(color) uniform float4 uStartColor;
layout(color) uniform float4 uEndColor;
float2 main(const Varyings v, out float4 color) {
float distance = abs(v.stroke.y);
float coverage = 1.0 - smoothstep(
v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
float side = distance / v.stroke.z;
float4 ink = mix(uStartColor, uEndColor,
clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
color = ink * coverage;
return v.position;
}La valeur absolue de notre coordonnée transversale nous donne la distance pixel par pixel par rapport à la ligne centrale.
Nous comparons cette distance au rayon pour obtenir la couverture. Pour la couleur, nous mélangeons le long du tracé de la ligne et courbons légèrement le dégradé vers le bord.
Multipliez cette couverture par la couverture, et vous obtenez la couleur finale.
Côté Compose, nous associons nos nuanceurs et notre mise en page des données à l'objet MeshSpecification.
val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}Tout d'abord, nous déclarons notre attribut d'angle comme deux floats commençant à l'octet 0.
Comme deux floats occupent 8 octets, chaque stride d'angle dans le tampon est de 8.
Ensuite, nous déclarons nos variables de trait sous la forme de trois nombres à virgule flottante, et nous transmettons le nuanceur de sommets et le nuanceur de fragments que nous venons d'écrire.
Nous créons le tampon de sommets.
fun lineCorners(segments: Int): ByteBuffer {
val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
.order(ByteOrder.nativeOrder())
for (i in 0..segments) {
val t = i / segments.toFloat()
buffer.putFloat(t).putFloat(-1f) // one side
buffer.putFloat(t).putFloat(1f) // the other side
}
return buffer.rewind() as ByteBuffer
}À chaque étape de la ligne, nous calculons la progression de 0 à 1 et écrivons deux angles dans le tampon.
Enfin, nous combinons la spécification et le tampon dans un objet de maillage.
val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)Le canevas Compose n'expose pas encore drawMesh. Nous passons donc à nativeCanvas et dessinons le maillage.
Box(Modifier.fillMaxSize().drawWithCache {
val mesh = Mesh(...)
onDrawBehind {
mesh.setFloatUniform("uTime", time.value)
drawIntoCanvas {
it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
}
}
})Comme vous contrôlez la géométrie et les pixels, vous pouvez dessiner n'importe quel effet visuel basé sur la géométrie à l'aide de la méthode "draw mesh".
Pour en savoir plus sur cette API, consultez la documentation.
Dans Compose 1.12, nous avons introduit la compatibilité avec les larges gammes de couleurs.
Qu'est-ce que cela signifie ?
Saviez-vous que les écrans peuvent reproduire une gamme de couleurs différente de l'espace colorimétrique sRVB traditionnel ?
Par exemple, si vous regardez cette image, vous verrez que le triangle blanc correspond à la gamme de couleurs sRGB standards que la plupart des écrans peuvent afficher.
Cependant, certains écrans plus récents sont compatibles avec une gamme de couleurs plus large, comme celle représentée par le triangle jaune, avec des couleurs plus saturées et plus éclatantes.
Mais lorsque nous représentons une couleur, nous la représentons en fait avec la description de chaque sommet de ce triangle.
Lorsque vous examinez cette comparaison de dégradés, il est important de faire la distinction entre la gamme de couleurs de l'écran et l'interpolation des dégradés.
Sur un écran sRVB standard ou sur ce projecteur, l'écran ne peut pas afficher physiquement les couleurs en dehors de cette gamme sRVB.
Il est donc très difficile de vous faire une démonstration sur scène en ce moment.
Vous allez donc devoir me croire sur parole : les couleurs sont plus belles sur Android avec la prise en charge d'une large gamme de couleurs.
Dans cet exemple, ce que vous voyez en réalité, c'est l'effet de l'interpolation des points de dégradé dans un espace colorimétrique plus large.
Le mélange des couleurs dans un espace colorimétrique étendu ou linéaire modifie le tracé mathématique réel de ce dégradé, ce qui évite l'apparition de gris moyen et de bandes dures qui peuvent se produire lorsque vous n'utilisez pas une large gamme de couleurs.
Avec Compose 1.12, vous bénéficiez d'un mélange de dégradés plus fluide sur n'importe quel écran, ainsi que d'une véritable prise en charge de la large gamme de couleurs lorsque vous utilisez un matériel compatible.
Pour activer la compatibilité sur l'appareil, vous devez vous assurer que votre activité ou votre fenêtre est configurée pour activer le mode large gamme de couleurs.
Pour ce faire, vous pouvez utiliser votre fichier AndroidManifest.
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>ou de manière programmatique dans votre composable.
// Dynamic toggle in code
DisposableEffect(window) {
window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
onDispose {
window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
}
}Si vous ne définissez pas colorMode, la fenêtre reste en sRVB 8 bits standard, et toutes les couleurs de la large gamme de couleurs affichées par le GPU seront tronquées.
Il est donc important d'éviter d'utiliser des couleurs à large gamme si l'écran ne peut pas les afficher.
Pour économiser la batterie et améliorer les performances, vous ne devez activer les larges gammes de couleurs que sur les activités ou les chemins de code qui affichent réellement des contenus multimédias, des photos ou des graphiques enrichis.
Cela signifie que vous pouvez créer des couleurs dans différents espaces colorimétriques dans Compose, et qu'elles sont désormais respectées.
// 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
val VividP3Red = Color(
red = 1.0f,
green = 0.05f,
blue = 0.05f,
alpha = 1.0f,
colorSpace = ColorSpaces.DisplayP3
)
// 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
val HdrNeonCyan = Color(
red = 0.0f,
green = 1.8f, // Peak brightness > 1.0 in extended gamut
blue = 2.0f,
alpha = 1.0f,
colorSpace = ColorSpaces.ExtendedSrgb
)
// 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
val HdrSunsetOrange = Color(
red = 1.0f,
green = 0.45f,
blue = 0.1f,
alpha = 1.0f,
colorSpace = ColorSpaces.Bt2020Hlg
)Vous pouvez transmettre le type de colorSpace dans lequel vous souhaitez créer votre couleur. Si vous ne transmettez aucun colorSpace, le sRGB standard sera utilisé.
Pour les bitmaps, vous pouvez vérifier la source du bitmap à l'aide de getColorSpace. Les bibliothèques de chargement d'images populaires gèrent automatiquement les espaces colorimétriques appropriés pour vous.
Vous devez utiliser Bitmap.Config.HARDWARE pour conserver les informations de couleur, car une configuration telle que 565 perdra en précision et présentera des bandes visibles pour les utilisateurs.
Pour en savoir plus sur les couleurs, vous pouvez remonter le temps jusqu'en 2017 et regarder Romain Guy parler des couleurs dans Compose ou dans Android.
Avec Compose 1.13, nous introduisons la compatibilité native avec la forme de polygone.
Cela facilitera grandement la création d'éléments d'interface utilisateur de forme personnalisée et d'aspect organique.
Auparavant, pour dessiner une géométrie complexe comme celle-ci, vous deviez utiliser RoundedPolygon pour décrire une géométrie qui génère un tracé, puis mapper ce tracé à une API de forme Compose.
fun cookiePolygon(): RoundedPolygon {
val vertices = FloatArray(32)
for (i in 0 until 16) {
val radius = if (i % 2 == 0) 1f else 0.84f
// …
}
return RoundedPolygon(
vertices,
rounding = CornerRounding(0.12f)
)
}Par exemple, vous définissez d'abord les proportions à l'aide d'un RoundedPolygon. Dans cet extrait, nous définissons nos points intérieurs à 84 % du centre par rapport à nos points extérieurs.
Vous pouvez également appliquer un arrondi des angles ici, mais cela ne décrit que les courbes mathématiques.
Pour utiliser ce polygone pour le découpage ou les arrière-plans dans Compose, vous devez écrire un wrapper Shape personnalisé comme celui-ci.
class RoundedPolygonShape(
polygon: RoundedPolygonShape
) : Shape {
// …
override fun createOutline(size: Size, layoutDirection: LayoutDirection,
density: Density,
): Outline {
path = polygon.toPath().asComposePath()
// …
matrix.scale(size.width / maxDimension, size.height / maxDimension)
matrix.translate(-bounds.left, -bounds.top)
// …
return Outline.Generic(path)
}
}Dans cet extrait createOutline, vous pouvez voir que vous devez créer le chemin d'accès, puis le mettre à l'échelle et le traduire manuellement.
Désormais, avec la nouvelle API PolygonShape, vous n'avez plus besoin de ce wrapper Shape personnalisé.
private val AvatarShape = PolygonShape {
val vertices = List(16) { i ->
val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
val angle = i * PI.toFloat() / 8f
size.center + Offset(cos(angle), sin(angle)) * radius
}
polygon(vertices, rounding = CornerRounding(radius = 8.dp))
}
// …
Modifier.clip(AvatarShape)Comme il s'agit du compilateur de formes Compose dédié, il reçoit directement la densité et la taille.
Cela signifie que vous pouvez calculer vos sommets de manière dynamique et spécifier l'arrondi des angles directement en dp ou même en pourcentage.
Une fois défini, il vous suffit de le transmettre à n'importe quelle API qui accepte un Shape.
Nous lançons également l'API MorphPolygonShape, qui vous permet de passer facilement d'une forme de polygone à une autre.
val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}Dans cet exemple, nous avons un bouton d'enregistrement qui se transforme d'un cercle en étoile lorsque vous interagissez avec lui.
Il vous suffit de fournir la forme de départ, la forme d'arrivée et la valeur de progression.
L'API MorphPolygonShape lit la valeur de progression pendant la phase de résolution du contour, et non pendant la composition.
Cela signifie que lorsque votre forme se transforme d'une image à l'autre, elle se résout simplement en un nouveau contour sans jamais déclencher de recomposition.
Parlons maintenant du floutage.
Au cours des dernières versions, nous avons essayé de rendre l'implémentation des flous beaucoup plus simple et expressive.
Chaque fois que nous avons parlé de floutage, nous avons vu ce modificateur couramment utilisé.
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)Compose dispose de ce modificateur depuis Android 12.
Il est idéal lorsque vous souhaitez obtenir un flou uniforme, où le même rayon de flou est appliqué à l'ensemble d'un composable.
Cependant, les conceptions d'UI modernes nécessitent souvent une transition fluide entre les zones nettes et floues, plutôt qu'une coupure nette.
Cela nous amène au flou progressif, qui est désormais une API officielle à partir de Compose 1.13.
Au lieu d'un rayon constant unique sur l'ensemble du composable, vous pouvez désormais faire varier l'intensité du flou dans l'espace.
Voici l'exemple de notre application JetChat sur l'écran du profil utilisateur.
Le nouveau modificateur de flou utilise une spécification de rayon de flou.
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}Ici, nous utilisons un dégradé vertical avec deux points de flou, en gardant la moitié supérieure de la photo complètement nette à 0 dp et en augmentant le flou jusqu'à 32 dp sur le bord inférieur.
Notez que nous définissons également edgeTreatment sur "Unbounded", ce qui indique au flou d'échantillonner également les pixels transparents en dehors des limites du composable.
Au-delà des fondus verticaux, cette spécification de rayon de flou vous offre plusieurs méthodes intégrées pour appliquer un flou progressif.
Par exemple, à gauche, vous pouvez voir un dégradé horizontal, où le rayon de départ est de 0 dp et le rayon de fin est déterminé par la valeur d'un curseur.
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)À droite, nous avons un dégradé radial, où le rayon de flou est de 0 dp au centre et augmente progressivement jusqu'à 24 dp à mesure que l'on se rapproche de fallOffRadius, qui est de 300 dp dans ce cas.
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)Et si tous ces dégradés verticaux, radiaux et horizontaux ne vous suffisent pas, vous pouvez toujours contrôler votre flou progressif à l'aide de nuanceurs personnalisés.
val customShader = remember {
RuntimeShader(
"""
uniform float2 size;
uniform float time;
half4 main(float2 coords) {
// Diagonal wave mask
float normalizedX = coords.x / size.x;
float normalizedY = coords.y / size.y;
float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
return half4(0.0, 0.0, 0.0, wave);
}
""".trimIndent()
)
}
val customBlurRadius = remember(customShader) {
BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
}
Modifier.blur {
val w = size.width.toPx()
val h = size.height.toPx()
customShader.setFloatUniform("size", w, h)
customShader.setFloatUniform("time", animatedTime)
blurRadiusSpec = customBlurRadius
},Ici, nous écrivons un petit nuanceur de fragments qui calcule une onde sinusoïdale diagonale animée et génère cette intensité dans un canal alpha.
Ensuite, la spécification du nuanceur mappe directement ce masque de 0 à 1 à un masque de 24 dp maxRadius.
Avant de parler du flou d'arrière-plan, voyons la différence entre les deux.
À gauche, nous avons une barre d'application supérieure à laquelle nous avons appliqué un flou de calque.
Autrement dit, il floute le composable lui-même.
Comme vous pouvez le voir, le titre et les icônes d'action sont flous, mais le composant de chat derrière ne l'est pas du tout.
À droite, nous avons le flou d'arrière-plan.
Ici, seuls les messages de chat qui défilent derrière la barre supérieure sont échantillonnés et floutés.
Cependant, le texte et les icônes sont parfaitement nets et lisibles.
À partir d'Android 37.2, vous pouvez flouter l'arrière-plan à l'aide d'une nouvelle API appelée RenderNode.setBackdropRenderEffect.
Mais avant de voir comment le flou s'améliore, voyons comment Android fonctionne en arrière-plan pour garantir l'efficacité du floutage.
Android utilise des RenderNodes en arrière-plan, qui sont des conteneurs de listes d'affichage à accélération matérielle. Cela signifie qu'ils peuvent compacter et stocker un ensemble de commandes de dessin différentes via les fonctions beginRecording et endRecording.
RenderNode contient des informations telles que vos translations, rotations, alpha, échelles et limites de position 3D.
Compose utilise en fait RenderNode en arrière-plan.
Pour utiliser les effets de rendu de toile de fond dans Compose, nous pouvons créer un Modifier.Node personnalisé afin de pouvoir réutiliser ce modificateur.
fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
this then BackdropBlurElement(spec, shape, tint)
private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
private var renderNode: RenderNode? = null
override fun ContentDrawScope.draw() {
val node = getOrCreateNode()
if (isDirty || sizeChanged) {
node.setPosition(0, 0, widthPx, heightPx)
node.setBackdropRenderEffect(spec.createRenderEffect(this))
}
drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
drawContent()
}
}Nous conservons une référence à un RenderNode, et le RenderNode est configuré avec les effets que nous voulons dans une autre fonction.
Par exemple, nous transmettons un effet de flou ici.
Ensuite, nous appelons drawIntoCanvas, en appelant drawRenderNode sur notre canevas natif.
Enfin, nous appelons drawContent pour dessiner le contenu réel non flouté par-dessus.
Pour utiliser le modificateur que nous venons de créer, nous appelons ensuite Modifier.backdropBlur et transmettons une spécification de rayon de flou.
JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.uniform(5.dp)
)
)Notez qu'avec cette approche, nous n'avons rien à placer sur la couche sous-jacente.
RenderNode.setBackdropRenderEffect échantillonne automatiquement le contenu qui se trouve sous la surface de la barre d'application JetChat.
Le résultat est un flou d'arrière-plan très agréable.
Nous pourrions également définir une spécification de rayon de flou progressif au lieu d'un simple flou progressif, et obtenir également un flou vertical sur le contenu.
JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 32.dp),
BlurStop(1f, 0.dp),
),
)
)
)Avec un dégradé vertical, le contenu en dessous est flouté avec différents rayons à différentes fractions du conteneur.
Nous travaillons sur un modificateur de flou d'arrière-plan intégré dans Compose.
Il s'agit simplement d'un exemple de la technologie sous-jacente sur Android avec Compose.
L'exemple que je vous ai montré ne fonctionnerait qu'au niveau d'API 37.2, mais nous travaillons également sur un rétroportage pour Compose. Restez à l'écoute.
Un autre exemple amusant avec les effets de fond RenderNode est que nous pouvons également les enchaîner pour former des interfaces utilisateur très esthétiques, et qui effectuent plusieurs effets différents par-dessus.
Pour enchaîner des effets, nous créons ces deux effets.
val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)
// Boost saturation by 30% and apply blur together
val matrix = ColorMatrix().apply { setSaturation(1.3f) }
val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)Dans cet exemple, nous créons un effet de flou et une saturation de 30 %.
Nous appelons createChainEffect en appliquant d'abord l'effet de flou, puis la saturation.
Voici un exemple dans JetChat.
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)Le bouton "Aller en bas", auquel nous pouvons donner l'apparence d'une petite loupe en combinant différents effets pour arrondir les bords de ce composable.
Mais vous pouvez non seulement appliquer des effets de loupe, mais aussi appliquer n'importe quel type de nuanceur en chaîne à un arrière-plan.
Voici quelques exemples amusants de ce qu'il est possible de faire en les combinant.
L'effet traditionnel de type verre dépoli est désormais possible.
Mais nous pouvons aussi créer d'autres effets amusants, comme un kaléidoscope.
Si nous voulions transformer cette mascotte Android en un élément d'interface utilisateur qui se métamorphose et transforme le contenu sous-jacent, nous pourrions également appliquer ces effets de rendu d'arrière-plan pour donner l'impression que nous regardons à travers ce petit droïde.
Nous pouvons également le faire avec les effets de rendu de l'arrière-plan.
Ainsi, non seulement notre floutage d'arrière-plan et notre verre dépoli ont été débloqués avec cette nouvelle API, mais désormais, la seule limite est votre imagination.
Une autre nouvelle API que nous avons introduite dans la plate-forme est SurfaceView.setBlurRegions.
Actuellement, vous pouvez flouter les vues de texture et n'importe quel composable à l'aide des effets d'arrière-plan Modifier.blur et RenderNode. Toutefois, flouter des parties d'une SurfaceView est difficile, et cela ne fait pas partie de l'arborescence RenderNode de la vue de l'application, car cela crée un trou dans la fenêtre.
Compose ne peut donc pas échantillonner le tampon de sauvegarde de SurfaceView, ce qui était impossible jusqu'à l'API 37.2.
La méthode setBlurRegions de SurfaceView permet donc à l'application de déclarer directement des zones de flou.
Par exemple, sur ce lecteur vidéo dans JetChat, nous avons ajouté un flou SurfaceView lorsque les commandes s'affichent et se masquent.
Pour utiliser cette nouvelle API, vous pouvez accéder à setBlurRegions sur une méthode SurfaceView.
val region = RoundedRectBlurRegion().apply {
bounds = RectF(left, top, right, bottom)
setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
alpha = spec.alpha.coerceIn(0f, 1f)
blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
}
surfaceView.setBlurRegions(listOf(region))
surfaceView.setBlurRegions(emptyList()) // ClearIl prend la liste des zones de limites, le rayon de flou, l'alpha et une liste de rayons d'angle.
Lorsque vous transmettez ces limites, elles doivent être relatives à la SurfaceView à l'écran.
Vous devez donc suivre les coordonnées de la SurfaceView et la zone que vous souhaitez flouter.
RuntimeColorFilter est une autre nouveauté d'Android 16 et versions ultérieures.
Auparavant, avec les images, vous ne pouviez définir qu'un filtre de couleur matriciel statique et appliquer des effets comme la saturation.
Toutefois, avec Android 16 et versions ultérieures, vous pouvez désormais transmettre un nuanceur qui prend une couleur d'entrée, le pixel d'entrée pour ce contenu particulier, et produit une nouvelle couleur.
Par exemple, si nous voulions appliquer un filtre cyberpunk à une photo, nous pourrions définir un petit nuanceur utilisé comme entrée dans RuntimeColorFilter.
@Language("AGSL")
private const val CYBERPUNK_SHADER = """
vec4 main(half4 in_color) {
float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
vec3 shadow = vec3(0.06, 0.04, 0.25);
vec3 mid = vec3(0.92, 0.12, 0.58);
vec3 high = vec3(0.18, 0.94, 0.98);
vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
}
"""Ce nuanceur produit un thème cyberpunk, mais vous pouvez créer n'importe quel autre effet.
Pour utiliser le nuanceur, nous appliquons le RuntimeColorFilter et le convertissons en filtre de couleur Compose.
AsyncImage(
model = imageUri,
contentDescription = filter.displayName,
colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
.asComposeColorFilter(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)Comme vous pouvez le voir dans cet exemple, nous pouvons désormais créer des effets plus dynamiques qu'auparavant sans avoir à nous plonger dans OpenGL, les configurations SurfaceView ou d'autres éléments de ce type.
Cela peut également s'appliquer à n'importe quel composable, et pas seulement aux images.
Merci.
Nous avons créé un effet très sympa, mais assurons-nous que notre application reste rapide et fluide.
Lorsque nous faisons défiler la conversation, tous nos effets s'exécutent ensemble.
Commençons par mesurer ce défilement.
@Test
fun scrollFling() = measureScroll(swipeSteps = 5)
private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = defaultComposeScrollingMetrics(),
compilationMode = CompilationMode.Full(),
iterations = 5,
setupBlock = {
killProcess()
}
//...Nous utilisons un benchmark macro qui lance une application dans un nouveau processus et fait défiler la conversation.
Ensuite, nous interrogeons la trace pour compter les images qui ne respectent pas leurs délais.
select count(*) as late_frames
from actual_frame_timeline_slice
where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
and jank_type like '%App Deadline Missed%'Nous avons donc six images en retard.
Ouvrons l'un des frames tardifs.
Juste avant cette frame, Compose précharge le message suivant avant qu'il ne défile vers le haut dans la vue.
La pré-récupération est censée s'insérer dans le court intervalle entre les images, mais la préparation de ce message décode également son image directement sur le thread principal, qui s'exécute longtemps sans céder.
Comme le thread principal est bloqué par le décodage de cette image, aucune nouvelle frame ne peut démarrer.
Ensuite, lorsque ce message défile réellement dans la vue, RenderThread doit allouer de la mémoire et importer tous ces pixels dans le GPU. Ainsi, la même image retarde un deuxième frame.
Nous voyons maintenant où notre temps est passé, mais avons-nous vraiment besoin de tout ce travail pour dessiner cette image ?
Comment le savoir ?
La trace nous le dit.
Chaque fois qu'un bitmap volumineux est dessiné dans une zone beaucoup plus petite de l'écran, Android enregistre un repère directement dans la trace.
Il affiche deux nombres en pixels : la dimension complète du bitmap et la zone qu'il couvre réellement à l'écran.
Divisez ce nombre par la densité de l'écran pour obtenir la valeur en DP.
Ensuite, nous recherchons cette valeur en dp dans notre code pour trouver l'image.
Voici ce que nous trouvons.
L'image est chargée avec painterResource, qui ne connaît pas la taille du conteneur. Elle décode donc l'image entière en résolution maximale.
Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)Nous le remplaçons donc par AsyncImage de Coil.
AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)Il mesure d'abord le conteneur, de sorte qu'il ne décode que le pixel dont il a besoin, et il effectue ce travail sur un thread différent.
En exécutant à nouveau le même benchmark, le décodage d'image sur le thread principal est tombé à 0 et le nombre d'images en retard est tombé à 2.
Nous n'allons pas déboguer ces deux images ici, mais l'idée est simple : essayez de ne pas calculer de pixels qui n'atteindront jamais l'image.
Le décodage d'image était donc suffisamment important pour retarder les images, mais tous les coûts ne se traduisent pas par une perte d'images, et il existe encore des coûts plus faibles que nous pouvons éviter lors de l'écriture du code.
Voyons deux d'entre eux.
Chaque fois que nous utilisons un RuntimeShader, nous devons le créer une seule fois, car même s'il ne fait que compiler et être mis en cache, il analyse toujours sa chaîne source chaque fois que nous en créons un.
Et chaque fois qu'un effet anime chaque frame, nous pouvons l'encapsuler dans un graphicsLayer, de sorte que seul l'élément d'animation soit réenregistré, au lieu de redessiner l'UI autour de lui.
Le bouton "Aller en bas" s'affiche chaque fois que nous faisons défiler l'écran.
Pour cela, nous utilisons deux RuntimeShader. Chaque fois que le bouton apparaît, nous recréons ces deux nuanceurs à partir de leur chaîne source.
val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
setFloatUniform("size", size.width, size.height)
setFloatUniform("zoom", zoom)
// …
val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
// …
val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {L'analyse de ces deux nuanceurs et la création de l'effet prennent des centaines de microsecondes sur le thread principal chaque fois que le bouton s'affiche.
Au lieu de cela, nous créons le nuanceur une fois pour l'ensemble du processus, ou nous pouvons le mémoriser, puis mettre à jour son uniforme.
val lensShader = MagnifierShaders.lens.apply {
setFloatUniform("size", size.width, size.height)
setFloatUniform("zoom", zoom)
// …
private object MagnifierShaders {
val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }Grâce à ce changement, la création de cet effet est près de 40 fois plus rapide, et le coût d'affichage du bouton est divisé par 20.
Dans le halo de dégradé de maillage que nous avons vu précédemment, le halo s'anime à chaque frame dans drawBehind.
Box(
modifier = Modifier
.matchParentSize()
.drawBehind {
// Behind the card, scaled past its bounds.
translate(top = -58.dp.toPx()) {
scale(scaleX = 1.38f, scaleY = 2.85f) {
with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}Ainsi, la lecture de l'état lors de la phase de dessin conserve la composition et la mise en page, mais la liste d'affichage ne peut pas mettre à jour une seule commande de dessin sur place.
Compose remonte donc l'arborescence pour trouver le nœud le plus proche avec une couche graphique, qui est ici le contour rouge, c'est-à-dire l'écran entier, et réenregistre l'intégralité de cette couche à chaque frame.
En plaçant un graphicsLayer directement sur la boîte, le parcours s'arrête au contour vert. Ainsi, seule la boîte lumineuse est réenregistrée, et la tranche de dessin du thread principal est réduite de deux dixièmes de milliseconde.
Box(
modifier = Modifier
.matchParentSize()
.graphicsLayer()
.drawBehind {
// Behind the card, scaled past its bounds.
translate(top = -58.dp.toPx()) {
scale(scaleX = 1.38f, scaleY = 2.85f) {
with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}Avec Perfetto, il peut être difficile d'analyser les problèmes d'une trace. Vous pouvez donc utiliser la compétence d'analyse des traces pour vous aider à recueillir des insights.
Si vous souhaitez obtenir de l'aide pour écrire du code Compose performant, installez la CLI Android et utilisez la compétence de performances Compose pour commencer.
android skills add perfetto-trace-analysis
android skills add jetpack-compose-performanceval gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
}
}val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
// ....
setVertex(1, 1, Offset(0.2f, 0.4f) + Offset(animatedOffset, animatedOffset), coral)
// ....
setVertex(3, 0, Offset(0.0f, 1.0f), sunshine)
// ....
}
}
Box(
modifier = modifier.padding(32.dp)
.safeContentPadding()
.aspectRatio(16 / 9f)
.fillMaxWidth()
.paint(gradientPainter)
)MeshGradientPainter(
rows = 2,
columns = 3,
hasBicubicColor = true,
) {
// Row 0 (top edge, y = 0.0f)
setVertex(0, 0, Offset(0.0000f, 0.0000f), Color(0xFFFF6B6B))
setVertex(0, 1, Offset(0.3300f, 0.0000f), Color(0xFFFFD6D6))
setVertex(0, 2, Offset(0.6700f, 0.0000f), Color(0xFFE8C3FF))
setVertex(0, 3, Offset(1.0000f, 0.0000f), Color(0xFFE396FF))
// ... remaining vertices for Rows 0, 1, and 2
}val transition = rememberInfiniteTransition(label = "userInputGlowMesh")
val phase1 by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 2800, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "glowPhase1",
)
val phase2 by transition.animateFloat(
initialValue = 0f,
targetValue = (2 * PI).toFloat(),
animationSpec = infiniteRepeatable(
animation = tween(durationMillis = 4200, easing = LinearEasing),
repeatMode = RepeatMode.Restart,
),
label = "glowPhase2",
)MeshGradientPainter(
rows = 6,
columns = 5,
hasBicubicColor = true,
) {
for (r in 0..6) {
for (c in 0..5) {
val offset = if (r in 1..5 && c in 1..4) {
val ampX = when (r) {
1, 2 -> 0.048f
3 -> 0.042f
4 -> 0.028f
else -> 0.036f
}
val ampY = when (r) {
1, 2 -> 0.028f
3 -> 0.020f
4 -> 0.022f
else -> 0.016f
}
val d = orbit(c, r, ampX, ampY)
Offset(
x = (baseU + d.x).coerceIn(0.04f, 0.96f),
y = (baseV + d.y).coerceIn(0.05f, 0.95f),
)
} else {
Offset(baseU, baseV)
}
setVertex(r, c, offset, gridColors[r][c])
}
}
}val lines = remember {
path.asAndroidPath().flatten(0.5f)
}
//... inside draw scope
lines.forEach { line ->
//...
drawLine(
brush = Brush.linearGradient(...),
start = Offset(line.start.x,
line.start.y),
end = Offset(line.end.x, line.end.y),
strokeWidth = 30f,
)
//...
}LineShaders.VERTEX
uniform float2 uStart;
uniform float2 uEnd;
uniform float uHalfWidth;
uniform float uTime;
Varyings main(const Attributes a) {
Varyings v;
float2 dir = uEnd - uStart;
float2 normal = normalize(float2(-dir.y, dir.x));
float along = a.corner.x * length(dir);
float pulse = (a.corner.x - uTime) / 0.18;
float radius = uHalfWidth * (1.0 + 0.6 * exp(-pulse * pulse));
float across = a.corner.y * (radius + 1.0);
v.position = uStart + dir * a.corner.x + normal * across;
v.stroke = float3(along, across, radius);
return v;
}LineShaders.FRAGMENT
uniform float uLength;
layout(color) uniform float4 uStartColor;
layout(color) uniform float4 uEndColor;
float2 main(const Varyings v, out float4 color) {
float distance = abs(v.stroke.y);
float coverage = 1.0 - smoothstep(
v.stroke.z - 0.5, v.stroke.z + 0.5, distance);
float side = distance / v.stroke.z;
float4 ink = mix(uStartColor, uEndColor,
clamp(v.stroke.x / uLength + 0.4 * side * side, 0.0, 1.0));
color = ink * coverage;
return v.position;
}val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}fun lineCorners(segments: Int): ByteBuffer {
val buffer = ByteBuffer.allocateDirect((segments + 1) * 2 * 8)
.order(ByteOrder.nativeOrder())
for (i in 0..segments) {
val t = i / segments.toFloat()
buffer.putFloat(t).putFloat(-1f) // one side
buffer.putFloat(t).putFloat(1f) // the other side
}
return buffer.rewind() as ByteBuffer
}val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)Box(Modifier.fillMaxSize().drawWithCache {
val mesh = Mesh(...)
onDrawBehind {
mesh.setFloatUniform("uTime", time.value)
drawIntoCanvas {
it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
}
}
})<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>// Dynamic toggle in code
DisposableEffect(window) {
window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
onDispose {
window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
}
}// 1. Display P3 Color (Wider gamut: greens & reds are more saturated) colorMode=wideColorGamut
val VividP3Red = Color(
red = 1.0f,
green = 0.05f,
blue = 0.05f,
alpha = 1.0f,
colorSpace = ColorSpaces.DisplayP3
)
// 2. Extended sRGB (Enables HDR values > 1.0f) use colorMode=hdr
val HdrNeonCyan = Color(
red = 0.0f,
green = 1.8f, // Peak brightness > 1.0 in extended gamut
blue = 2.0f,
alpha = 1.0f,
colorSpace = ColorSpaces.ExtendedSrgb
)
// 3. BT.2020 HLG (Broadcast HDR standard, supported API 34+) - use colorMode=hdr
val HdrSunsetOrange = Color(
red = 1.0f,
green = 0.45f,
blue = 0.1f,
alpha = 1.0f,
colorSpace = ColorSpaces.Bt2020Hlg
)fun cookiePolygon(): RoundedPolygon {
val vertices = FloatArray(32)
for (i in 0 until 16) {
val radius = if (i % 2 == 0) 1f else 0.84f
// …
}
return RoundedPolygon(
vertices,
rounding = CornerRounding(0.12f)
)
}class RoundedPolygonShape(
polygon: RoundedPolygonShape
) : Shape {
// …
override fun createOutline(size: Size, layoutDirection: LayoutDirection,
density: Density,
): Outline {
path = polygon.toPath().asComposePath()
// …
matrix.scale(size.width / maxDimension, size.height / maxDimension)
matrix.translate(-bounds.left, -bounds.top)
// …
return Outline.Generic(path)
}
}private val AvatarShape = PolygonShape {
val vertices = List(16) { i ->
val radius = size.minDimension / 2f * if (i % 2 == 0) 1f else 0.86f
val angle = i * PI.toFloat() / 8f
size.center + Offset(cos(angle), sin(angle)) * radius
}
polygon(vertices, rounding = CornerRounding(radius = 8.dp))
}
// …
Modifier.clip(AvatarShape)val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)val customShader = remember {
RuntimeShader(
"""
uniform float2 size;
uniform float time;
half4 main(float2 coords) {
// Diagonal wave mask
float normalizedX = coords.x / size.x;
float normalizedY = coords.y / size.y;
float wave = sin((normalizedX + normalizedY) * 6.0 + time) * 0.5 + 0.5;
return half4(0.0, 0.0, 0.0, wave);
}
""".trimIndent()
)
}
val customBlurRadius = remember(customShader) {
BlurRadiusSpec.shader(maxRadius = 24.dp) { customShader }
}
Modifier.blur {
val w = size.width.toPx()
val h = size.height.toPx()
customShader.setFloatUniform("size", w, h)
customShader.setFloatUniform("time", animatedTime)
blurRadiusSpec = customBlurRadius
},fun Modifier.backdropBlur(spec: BlurRadiusSpec, shape: Shape, tint: Color): Modifier =
this then BackdropBlurElement(spec, shape, tint)
private class BackdropBlurNode(...) : Modifier.Node(), DrawModifierNode {
private var renderNode: RenderNode? = null
override fun ContentDrawScope.draw() {
val node = getOrCreateNode()
if (isDirty || sizeChanged) {
node.setPosition(0, 0, widthPx, heightPx)
node.setBackdropRenderEffect(spec.createRenderEffect(this))
}
drawIntoCanvas { it.nativeCanvas.drawRenderNode(node) }
drawContent()
}
}JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.uniform(5.dp)
)
)JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 32.dp),
BlurStop(1f, 0.dp),
),
)
)
)val blur = RenderEffect.createBlurEffect(24f, 24f, Shader.TileMode.CLAMP)
// Boost saturation by 30% and apply blur together
val matrix = ColorMatrix().apply { setSaturation(1.3f) }
val vibrancy = RenderEffect.createColorFilterEffect(ColorMatrixColorFilter(matrix))
val vibrantBlur = RenderEffect.createChainEffect(vibrancy, blur)val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)val region = RoundedRectBlurRegion().apply {
bounds = RectF(left, top, right, bottom)
setCornerRadii(spec.cornerRadiusPx.coerceAtLeast(0f))
alpha = spec.alpha.coerceIn(0f, 1f)
blurRadius = spec.blurRadiusPx.coerceAtLeast(0f)
}
surfaceView.setBlurRegions(listOf(region))
surfaceView.setBlurRegions(emptyList()) // Clear@Language("AGSL")
private const val CYBERPUNK_SHADER = """
vec4 main(half4 in_color) {
float lum = dot(in_color.rgb, vec3(0.299, 0.587, 0.114));
vec3 shadow = vec3(0.06, 0.04, 0.25);
vec3 mid = vec3(0.92, 0.12, 0.58);
vec3 high = vec3(0.18, 0.94, 0.98);
vec3 col = lum < 0.5 ? mix(shadow, mid, lum * 2.0) : mix(mid, high, (lum - 0.5) * 2.0);
return vec4(mix(in_color.rgb, col, 0.7), in_color.a);
}
"""AsyncImage(
model = imageUri,
contentDescription = filter.displayName,
colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
.asComposeColorFilter(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)@Test
fun scrollFling() = measureScroll(swipeSteps = 5)
private fun measureScroll(swipeSteps: Int) = rule.measureRepeated(
packageName = TARGET_PACKAGE,
metrics = defaultComposeScrollingMetrics(),
compilationMode = CompilationMode.Full(),
iterations = 5,
setupBlock = {
killProcess()
}
//...select count(*) as late_frames
from actual_frame_timeline_slice
where upid = (select upid from process where name = 'com.example.compose.jetchat.perf')
and jank_type like '%App Deadline Missed%'Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)val lensShader = RuntimeShader(MAGNIFIER_LENS_SHADER).apply {
setFloatUniform("size", size.width, size.height)
setFloatUniform("zoom", zoom)
// …
val lensEffect = RenderEffect.createRuntimeShaderEffect(lensShader, "content")
// …
val glassShader = RuntimeShader(GLASS_FINISH_SHADER).apply {val lensShader = MagnifierShaders.lens.apply {
setFloatUniform("size", size.width, size.height)
setFloatUniform("zoom", zoom)
// …
private object MagnifierShaders {
val lens: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(MAGNIFIER_LENS_SHADER) }
val glassFinish: RuntimeShader by lazy(LazyThreadSafetyMode.NONE) { RuntimeShader(GLASS_FINISH_SHADER) }Box(
modifier = Modifier
.matchParentSize()
.drawBehind {
// Behind the card, scaled past its bounds.
translate(top = -58.dp.toPx()) {
scale(scaleX = 1.38f, scaleY = 2.85f) {
with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}Box(
modifier = Modifier
.matchParentSize()
.graphicsLayer()
.drawBehind {
// Behind the card, scaled past its bounds.
translate(top = -58.dp.toPx()) {
scale(scaleX = 1.38f, scaleY = 2.85f) {
with(glowMeshPainter) {
draw(size, alpha = glowAlpha)
}android skills add perfetto-trace-analysis
android skills add jetpack-compose-performance