Inżynier ds. relacji z deweloperami
Inżynier oprogramowania
Inżynier ds. relacji z deweloperami
Chcesz, aby interfejs Twojej aplikacji na Androida nie tylko działał, ale też zachwycał?
Dołącz do nas, aby poznać najnowsze możliwości graficzne w Jetpack Compose. Pokażemy Ci, jak tworzyć nowe efekty wizualne, od zachwycających gradientów siatki po progresywne rozmycia, i jak korzystać z nowych, zaawansowanych funkcji graficznych na Androidzie. Pokażemy Ci też, jak poprawić wydajność funkcji graficznych bez skracania czasu pracy na baterii i zmniejszania liczby klatek na sekundę.
PolygonShape i MorphPolygonShape do obsługi niestandardowych geometrii. MorphPolygonShape odczytuje postęp podczas fazy rozpoznawania konturu, aktualizując kształty klatka po klatce bez wywoływania rekompozycji.RenderNode.setBackdropRenderEffect do próbkowania treści w tle. Deweloperzy mogą łączyć efekty tła, aby tworzyć efekty matowego szkła lub powiększenia.graphicsLayer.Transkrypcja
Cześć wszystkim. Witamy na sesji „Ulepszanie aplikacji dzięki najnowszym funkcjom graficznym w Compose i Androidzie”.
Nazywam się Rebecca Franks, a na scenie towarzyszy mi…
Nazywam się Aayush Chaudhary.
Cześć, nazywam się Rahul Ray.
Zauważamy nowy trend, w ramach którego przesuwacie granice tego, co jest możliwe na Androidzie i w Compose, wykorzystując AI do urzeczywistniania swoich niesamowitych pomysłów.
Zależy nam też na tym, aby aplikacje były wysokiej jakości, natywne i przyjemne w użyciu.
Wiemy, że aplikacje, które są dobrze odbierane i stale otrzymują świetne oceny od użytkowników, to te, które zazwyczaj korzystają z najnowszych zmian w interfejsie API na poziomie systemu, zachowują ciągłość między platformami dzięki udostępnionym elementom i rozmyciu, dostosowują się do różnych formatów, są wydajne, nie pozostawiają użytkowników w niepewności i wykorzystują wszystkie możliwości urządzenia.
W tym wystąpieniu pokażemy niektóre z najnowszych funkcji, które pomogą Ci wykorzystać możliwości Androida, takie jak zachwycanie użytkowników gradientami siatki, obsługa szerokiej gamy kolorów, efektowne przejścia z kształtami wielokątów, nowości w rozmyciu, a na koniec omówimy, jak możesz myśleć o wydajności, dodając do aplikacji nowe funkcje graficzne.
Podczas dzisiejszej prezentacji omówimy niektóre z nowych interfejsów API na różnych poziomach stosu.
Chociaż starszy zestaw narzędzi UI widoku jest obecnie w trybie konserwacji, nadal inwestujemy w podstawowe silniki renderowania sprzętowego grafiki na Androidzie, które obsługują Jetpack Compose.
Grafikę w Androidzie można podzielić na 3 warstwy. Pierwsza warstwa to interfejsy API Compose, które zawierają wszystkie nowe interfejsy API grafiki wysokiego poziomu, takie jak interfejsy API do malowania gradientów siatki, kształtów wielokątów i progresywnego rozmycia.
Mamy też warstwę pośrednią, w której DrawScope, węzły modyfikatora rysowania i AGSL w Compose umożliwiają tworzenie niestandardowych efektów.
Na koniec mamy platformę Androida, a Compose zawsze może uzyskać dostęp do natywnego Canvas i używać zupełnie nowych elementów renderowania systemu operacyjnego bezpośrednio w modyfikatorach Compose.
Pokażemy Ci kilka przykładów, jak to zrobić.
Zaczynajmy.
Najpierw przyjrzyjmy się gradientom siatki, czyli nowemu interfejsowi API wprowadzonemu w Compose 1.12.
Wcześniej mieliśmy gradienty liniowe, gradienty liniowe, gradienty przesunięcia i gradienty promieniowe, które świetnie nadają się do prostych przejść, ale jeśli chcesz uzyskać bogate wielokolorowe mieszanki, takie jak tła otoczenia lub efekty oświetlenia, potrzebujesz siatki 2D systemu, w której pozycje i kolory mogą się mieszać w przestrzeni.
Aby utworzyć taki gradient siatki, najpierw określ, ile wierszy i kolumn fragmentów chcesz mieć.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}Następnie w końcowej funkcji lambda wywołaj setVertex i przypisz znormalizowane przesunięcie z zakresu od 0F do 1F oraz kolor dla każdego przecięcia siatki.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
}
}Aby animować te wierzchołki, dodaj animowane przesunięcia dla 8 wierzchołków.
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)
)Po zdefiniowaniu obiektu Painter wystarczy wywołać go z dowolnym elementem kompozycyjnym za pomocą modyfikatora Paint.
W aplikacji JetChat dodaliśmy funkcję polubienia wiadomości po dwukrotnym kliknięciu.
Aby to zrobić, skonfigurujemy siatkę 2 x 3 i rozmieścimy punkty kolorów w komponencie.
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
}Zwróć uwagę, że ustawiliśmy tu wartość hasBicubicColor na „true”.
Domyślnie kolory są interpolowane liniowo między trójkątami, co może czasami pozostawiać widoczne plamy.
Włączenie tej opcji powoduje przełączenie cieniowania na płynną interpolację dwusześcienną, dzięki czemu kolory mogą płynnie się przenikać.
A co, jeśli chcesz utworzyć animowaną poświatę, taką jak ta za polem wprowadzania?
Aby uzyskać taki efekt za pomocą gradientu siatki, potrzebujesz 2 rzeczy.
Po pierwsze, płynny, naturalny ruch wewnętrznych wierzchołków i sposób na zmiękczenie zewnętrznych krawędzi, aby wokół karty nie było twardego prostokąta.
Zaczynamy od skonfigurowania rememberInfiniteTransition z 2 kątami fazowymi, które działają przez nieco inny czas, dzięki czemu ruch wydaje się naturalny, a nie mechaniczny.
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",
)Tutaj animujemy wewnętrzne wierzchołki i przypinamy zewnętrzne pierścienie, zewnętrzny pierścień wierzchołków z krawędziami o wartości alfa 0.
Ponieważ zewnętrzna granica jest nieruchoma i przezroczysta, blask naturalnie się rozprasza.
Na koniec przekazujemy te animowane przesunięcia do 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])
}
}
}Widoczny przed chwilą gradient siatki jest rysowany za pomocą interfejsu API drawMesh na Androidzie 14 lub nowszym.
Przyjrzyjmy się bliżej temu interfejsowi API, tworząc efekt od podstaw.
Na ekranie powitalnym JetChat chcemy narysować ścieżkę, która wygląda jak wstążka.
Zwróć uwagę na 2 rzeczy: szerokość pociągnięcia nie jest stała, a kolor jest nakładany wzdłuż każdego pociągnięcia ścieżki.
Przeanalizujmy, czego potrzebujemy, aby uzyskać ten efekt.
W przypadku każdego piksela musimy zdecydować, czy go narysować i jakiego koloru użyć.
Aby to obliczyć, potrzebujemy 2 odległości.
Pierwsza wartość to odległość piksela od środka obrysu.
Druga odległość to odległość piksela od początku do końca ścieżki.
Dzięki temu wiemy, który kolor wybrać z gradientu, a dodanie odrobiny pierwszej wartości sprawia, że kolor zakrzywia się na całej szerokości.
Czy możemy narysować gradient wzdłuż ścieżki, używając tylko ścieżki i pędzla?
Tak, możemy.
Wymaga to spłaszczenia ścieżki do listy krótkich linii prostych, co pozwala też określić odległość wzdłuż ścieżki.
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,
)
//...
}Następnie rysujemy każdą linię z własnym gradientem.
To działa, ale każda linia wymaga osobnego wywołania rysowania i obiektu Paint, a cała ścieżka nadal ma jedną szerokość.
Inną opcją jest cieniowanie środowiska wykonawczego, ale pobiera ono tylko pozycję piksela w granicach kontenera.
Nie mówi nam, co jest potrzebne do narysowania tej wstążki.
Nie możemy też łatwo obliczyć tego na podstawie pozycji pikseli.
Draw mesh gives us vertex shader for that.
Czym jest siatka?
Siatka składa się z trójkątów z danymi w każdym rogu, a każdy róg jest nazywany wierzchołkiem.
Każdy wierzchołek zawiera niewielki rekord danych.
Zwykle ten rekord zawiera pozycję, ale nie musi.
Program do cieniowania wierzchołków odczytuje ten rekord i oblicza na jego podstawie pozycję.
Oto ścieżka, którą pokonuje jeden trójkąt: moduł cieniowania wierzchołków jest uruchamiany dla każdego rogu, oblicza pozycję rogów i dowolną wartość, którą chcemy przekazać dla każdego piksela.
Następnie rasteryzator znajduje każdy piksel objęty tym trójkątem i interpoluje wartości z tych 3 rogów dla każdego piksela.
Na koniec dla tych pikseli uruchamiany jest program do cieniowania fragmentów, który wykorzystuje te interpolowane wartości i oblicza kolor pikseli.
Zobaczmy, jak możemy napisać kod modułu cieniującego wierzchołki i fragmenty na prostszym przykładzie – linii prostej z poruszającym się po niej impulsem.
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;
}Program do cieniowania wierzchołków pobiera atrybut jako dane wejściowe i zwraca zmienne.
Atrybuty to dane dotyczące pojedynczego rogu.
Oto 2 liczby: postęp wzdłuż linii i strona linii.
Zmienne są następnie interpolowane i przekazywane do programu do cieniowania fragmentów.
Obliczamy odległość pikseli od początku, a w przypadku szerokości pociągnięcia używamy czasu, aby utworzyć puls na promieniu.
Następnie mnożymy ten promień przez bok narożnika, co daje nam odległość znaku od obrysu.
Gdy GPU interpoluje między tymi znakami, linie środkowe stają się zerem.
Na koniec obliczamy pozycję narożników i zapisujemy wszystkie te wartości w zmiennych obrysu, a następnie przekazujemy je do fragmentu kodu cieniowania.
W odróżnieniu od cieniowania środowiska wykonawczego cieniowanie fragmentu siatki generuje kolor za pomocą parametru i zwraca pozycję do próbkowania farby, jeśli jest ustawiona.
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;
}Wartość bezwzględna współrzędnej poprzecznej daje nam odległość od linii środkowej w pikselach.
Porównujemy tę odległość z promieniem, aby uzyskać zasięg, a w przypadku koloru mieszamy go wzdłuż ścieżki linii i lekko zaginamy gradient w kierunku krawędzi.
Pomnóż to pokrycie przez pokrycie, a otrzymasz ostateczny kolor.
Po stronie Compose łączymy shadery i układ danych z obiektem MeshSpecification.
val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}Najpierw deklarujemy atrybut narożnika jako 2 wartości zmiennoprzecinkowe zaczynające się od bajtu 0.
Ponieważ 2 liczby zmiennoprzecinkowe zajmują 8 bajtów, każdy krok narożnika w buforze wynosi 8.
Następnie deklarujemy zmienne stroke jako 3 wartości zmiennoprzecinkowe i przekazujemy napisany właśnie moduł cieniujący wierzchołki i fragmenty.
Tworzymy bufor wierzchołków.
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
}Na każdym etapie linii obliczamy postęp od 0 do 1 i zapisujemy 2 rogi w buforze.
Na koniec łączymy specyfikację i bufor w obiekt siatki.
val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)Obszar roboczy Compose nie udostępnia jeszcze drawMesh, więc przechodzimy do nativeCanvas i rysujemy siatkę.
Box(Modifier.fillMaxSize().drawWithCache {
val mesh = Mesh(...)
onDrawBehind {
mesh.setFloatUniform("uTime", time.value)
drawIntoCanvas {
it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
}
}
})Ponieważ kontrolujesz geometrię i piksele, możesz narysować dowolny efekt wizualny oparty na geometrii, używając funkcji draw mesh.
Więcej informacji o tym interfejsie API znajdziesz w dokumentacji.
W Compose 1.12 wprowadziliśmy obsługę szerokiej gamy kolorów.
Co to tak naprawdę oznacza?
Czy wiesz, że ekrany mogą odtwarzać inną gamę kolorów niż tradycyjna przestrzeń barw sRGB?
Na przykład na tym obrazie biały trójkąt to zakres standardowych kolorów sRGB, które może wyświetlać większość ekranów.
Jednak nowsze ekrany obsługują szerszą gamę kolorów, jak pokazuje żółty trójkąt, z bardziej nasyconymi i żywymi barwami.
Jednak gdy reprezentujemy kolor, tak naprawdę reprezentujemy go za pomocą opisu każdego wierzchołka tego trójkąta.
Podczas porównywania gradientów należy odróżnić gamę wyświetlacza od interpolacji gradientów.
Na standardowym wyświetlaczu sRGB lub prawdopodobnie na tym projektorze ekran nie może fizycznie renderować kolorów spoza tej gamy sRGB.
Trudno mi to teraz zademonstrować na scenie.
Musisz mi więc uwierzyć na słowo, że kolory wyglądają lepiej na Androidzie z obsługą szerokiej gamy kolorów.
W tym przykładzie widzisz efekt interpolacji punktów gradientu w szerszej przestrzeni barw.
Mieszanie kolorów w szerokiej lub liniowej przestrzeni barw zmienia rzeczywistą matematyczną ścieżkę tego gradientu, zapobiegając powstawaniu szarości i ostrych pasów, które mogą się pojawić, gdy nie używa się szerokiej gamy kolorów.
W Compose 1.12 możesz korzystać z płynniejszego mieszania gradientów na dowolnym wyświetlaczu, a także z obsługi szerokiej gamy kolorów na obsługiwanym sprzęcie.
Aby zapewnić prawidłową obsługę na urządzeniu, upewnij się, że aktywność lub okno są ustawione tak, aby włączyć tryb szerokiej gamy kolorów.
Można to zrobić w pliku AndroidManifest.
<!-- AndroidManifest.xml -->
<activity
android:name=".NavActivity"
android:colorMode="wideColorGamut"
android:exported="true">
</activity>Możesz też zrobić to automatycznie w funkcji kompozycyjnej.
// Dynamic toggle in code
DisposableEffect(window) {
window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
onDispose {
window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
}
}Jeśli nie ustawisz colorMode, okno pozostanie w standardowym 8-bitowym sRGB, a wszystkie kolory o szerokiej gamie renderowane przez GPU zostaną przycięte.
Dlatego ważne jest, aby unikać używania kolorów z szerokiej gamy, jeśli wyświetlacz nie może ich obsłużyć.
Aby oszczędzać baterię i zwiększyć wydajność, szeroką gamę kolorów należy włączać tylko w przypadku aktywności lub ścieżek kodu, które faktycznie wyświetlają multimedia, zdjęcia lub rozbudowaną grafikę.
Oznacza to, że możesz tworzyć kolory w różnych przestrzeniach barw w Compose i są one teraz respektowane.
// 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
)Możesz przekazać typ colorSpace, w którym chcesz utworzyć kolor. Jeśli nie przekażesz żadnego colorSpace, zostanie użyty standardowy sRGB.
W przypadku bitmap możesz sprawdzić źródło bitmapy za pomocą metody getColorSpace, a popularne biblioteki do wczytywania obrazów automatycznie obsłużą prawidłowe przestrzenie barw bitmapy.
Aby zachować informacje o kolorach, użyj Bitmap.Config.HARDWARE, ponieważ konfiguracja taka jak 565 spowoduje utratę precyzji i widoczne dla użytkowników pasy.
Aby dowiedzieć się więcej o kolorach, możesz cofnąć się w czasie do 2017 roku i obejrzeć wystąpienie Romaina Guya, który opowiada o kolorach w Compose i na Androidzie.
W Compose 1.13 wprowadzamy natywną obsługę kształtu wielokąta.
Dzięki temu tworzenie niestandardowych kształtów i naturalnie wyglądających elementów interfejsu będzie znacznie łatwiejsze.
Wcześniej, aby narysować złożoną geometrię, trzeba było użyć RoundedPolygon do opisania geometrii, która wygeneruje ścieżkę, a następnie zmapować tę ścieżkę na interfejs API kształtu 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)
)
}Najpierw definiujesz proporcje za pomocą RoundedPolygon, a my ustawiamy W tym fragmencie kodu ustawiamy punkty wewnętrzne na 84% od środka w porównaniu z punktami zewnętrznymi.
Możesz też zastosować zaokrąglenie rogów, ale opisuje ono tylko krzywe matematyczne.
Aby użyć tego wielokąta do przycinania lub tła w Compose, musisz napisać niestandardowy kod Shape, taki jak ten.
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)
}
}W tym fragmencie kodu createOutline widać, że musisz utworzyć ścieżkę, a następnie ręcznie przeskalować i przetłumaczyć elementy.
Dzięki nowemu interfejsowi API PolygonShape nie potrzebujesz już niestandardowego kodu Shape.
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)Ponieważ jest to specjalny kreator kształtów Compose, od razu otrzymuje gęstość i rozmiar.
Oznacza to, że możesz dynamicznie obliczać wierzchołki i określać zaokrąglenie rogów bezpośrednio w dp lub nawet jako wartość procentową.
Po zdefiniowaniu możesz go przekazać do dowolnego interfejsu API, który przyjmuje kształt.
Wprowadzamy też interfejs API MorphPolygonShape, który umożliwia płynne przechodzenie między różnymi kształtami wielokątów.
val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}W tym przykładzie mamy przycisk nagrywania, który po interakcji zmienia się z okręgu w gwiazdę.
Wystarczy podać kształt początkowy, kształt końcowy i wartość postępu.
Interfejs API MorphPolygonShape odczytuje wartość postępu podczas fazy rozwiązywania konturu, czyli nie podczas rekom- nie podczas tworzenia kompozycji.
Oznacza to, że gdy kształt zmienia się między klatkami, po prostu przekształca się w nowy kontur bez wywoływania rekompozycji.
Teraz porozmawiajmy o rozmyciu.
W ostatnich kilku wersjach staramy się ułatwić implementację rozmyć i zwiększyć ich ekspresyjność.
Gdy mówimy o rozmyciu, zwykle mamy na myśli ten modyfikator.
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)Compose ma ten modyfikator od Androida 12.
Sprawdza się świetnie, gdy chcesz uzyskać jednolite rozmycie, w którym ten sam promień rozmycia jest stosowany w całym elemencie kompozycyjnym.
Jednak nowoczesne projekty interfejsu często wymagają płynnego przejścia od ostrych do rozmytych obszarów, a nie ostrego odcięcia.
I tak dochodzimy do progresywnego rozmycia, które jest teraz oficjalnym interfejsem API, począwszy od Compose 1.13.
Zamiast jednego stałego promienia w całym elemencie kompozycyjnym możesz teraz zmieniać intensywność rozmycia w przestrzeni.
Oto przykład z ekranu profilu użytkownika w naszej aplikacji JetChat.
Nowy modyfikator rozmycia wymaga podania promienia rozmycia.
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}W tym przypadku używamy gradientu pionowego z 2 punktami rozmycia, dzięki czemu górna połowa zdjęcia jest całkowicie ostra (0 dp), a dolna krawędź jest rozmyta (32 dp).
Zauważ, że ustawiliśmy też edgeTreatment jako nieograniczony, co w zasadzie oznacza, że rozmycie ma próbkować przezroczysty piksel również poza granicami elementu kompozycyjnego.
Oprócz pionowych zaników ta specyfikacja promienia rozmycia zapewnia kilka wbudowanych sposobów na uzyskanie progresywnego rozmycia.
Na przykład po lewej stronie widać gradient poziomy, w którym promień początkowy wynosi 0 dp, a promień końcowy jest określany przez wartość suwaka.
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)Po prawej stronie mamy gradient promieniowy, w którego środku promień rozmycia wynosi 0 dp, a w miarę przesuwania się w kierunku fallOffRadius, który w tym przypadku wynosi 300 dp, rozmycie płynnie wzrasta do 24 dp.
BlurRadiusSpec.radialGradient(
center = center,
fallOffRadius = 300.dp,
startRadius = 0.dp,
endRadius = 24.dp,
)Jeśli wszystkie te gradienty pionowe, promieniowe i poziome nie są wystarczające, zawsze możesz sterować rozmyciem progresywnym za pomocą niestandardowych cieniowania.
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
},Tutaj piszemy mały fragment kodu cieniowania, który oblicza animowaną diagonalną falę sinusoidalną i generuje tę intensywność w kanale alfa.
Następnie specyfikacja cieniowania mapuje tę maskę od 0 do 1 bezpośrednio na maskę 24 dp maxRadius.
Zanim omówimy rozmycie tła, przyjrzyjmy się różnicom między tymi funkcjami.
Po lewej stronie mamy górny pasek aplikacji, na którym zastosowaliśmy rozmycie warstwy.
Oznacza to, że rozmywa sam element kompozycyjny.
Jak widać, tytuł i ikony działań są rozmyte, ale komponent czatu za nimi nie jest rozmyty.
Po prawej stronie mamy rozmycie tła.
W tym przypadku próbkowane i rozmywane są tylko wiadomości na czacie przewijane za górnym paskiem.
Jednak tekst i ikony są całkowicie ostre i czytelne.
Od Androida 37.2 możesz rozmywać tło za pomocą nowego interfejsu API o nazwie RenderNode.setBackdropRenderEffect.
Zanim jednak przejdziemy do tego, jak ulepszamy rozmycie, dowiedzmy się, jak działa Android, aby zapewnić wydajność rozmycia.
Android używa RenderNodes, czyli kontenerów list wyświetlania z akceleracją sprzętową, co oznacza, że mogą one kompresować i przechowywać wiele różnych poleceń rysowania za pomocą funkcji rozpoczęcia i zakończenia nagrywania.
RenderNode zawiera informacje takie jak przesunięcia 3D, obroty, kanał alfa, skale i granice pozycji.
Compose używa RenderNode.
Aby używać efektów renderowania tła w Compose, możemy utworzyć niestandardowy element Modifier.Node, który pozwoli nam ponownie używać tego modyfikatora.
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()
}
}Zachowujemy odwołanie do RenderNode, a RenderNode jest skonfigurowany z efektami, które chcemy uzyskać w innej funkcji.
Na przykład tutaj przekazujemy efekt rozmycia.
Następnie wywołujemy drawIntoCanvas, wywołując drawRenderNode na naszym natywnym obszarze roboczym.
Na koniec wywołujemy funkcję drawContent, aby narysować rzeczywistą, niewyostrzoną treść na górze.
Aby użyć utworzonego właśnie modyfikatora, wywołujemy Modifier.backdropBlur i przekazujemy specyfikację promienia rozmycia.
JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.uniform(5.dp)
)
)Pamiętaj, że w tym przypadku nie musimy niczego umieszczać na warstwie bazowej.
RenderNode.setBackdropRenderEffect automatycznie pobiera próbki treści, które znajdują się pod powierzchnią paska aplikacji JetChat.
W efekcie otrzymujemy pięknie rozmyte tło.
Możemy też ustawić specyfikację promienia rozmycia progresywnego zamiast samego rozmycia progresywnego, aby uzyskać rozmycie pionowe treści.
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),
),
)
)
)W przypadku gradientu pionowego treść pod spodem jest rozmyta z różnymi promieniami w różnych częściach kontenera.
Pracujemy nad wbudowanym modyfikatorem rozmycia tła w Compose.
To tylko przykład technologii, która działa w tle na Androidzie z Compose.
Pokazany przykład działałby tylko na poziomie interfejsu API 37.2, ale pracujemy też nad backportem dla Compose, więc bądźcie czujni.
Innym ciekawym przykładem efektów tła RenderNode jest to, że możemy je łączyć, aby tworzyć naprawdę ładne interfejsy, które dają wiele różnych efektów.
Aby połączyć efekty, tworzymy te 2 efekty.
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)W tym przykładzie tworzymy efekt rozmycia i nasycenie na poziomie 30%.
Wywołujemy createChainEffect, najpierw stosując efekt rozmycia, a potem nasycenia.
Oto przykład w JetChat.
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)Przycisk „Przejdź do dołu”, który może wyglądać jak mała lupa, jeśli połączymy kilka różnych efektów, aby zaokrąglić krawędzie tego elementu kompozycyjnego.
Możesz nie tylko stosować efekty powiększenia, ale też łączyć ze sobą dowolne rodzaje cieniowania i nakładać je na tło.
Oto kilka ciekawych przykładów, które wdrożyliśmy, pokazujących, co można osiągnąć, łącząc te technologie.
Teraz można uzyskać tradycyjny efekt oszronionego szkła.
Możemy też dodać inne ciekawe efekty, np. kalejdoskop.
Jeśli chcemy przekształcić maskotkę Androida w element interfejsu, który zmienia zawartość pod spodem, możemy zastosować efekty renderowania tła, aby wyglądało to tak, jakbyśmy patrzyli przez tego małego droida.
Możemy to zrobić również za pomocą efektów renderowania tła.
Dzięki temu nowemu interfejsowi API nie tylko odblokowaliśmy rozmycie tła i efekt matowego szkła, ale teraz ogranicza Cię tylko wyobraźnia.
Kolejnym nowym interfejsem API, który wprowadziliśmy na platformie, jest SurfaceView.setBlurRegions.
Obecnie można rozmywać widoki tekstur i dowolne elementy kompozycyjne za pomocą efektów tła Modifier.blur i RenderNode, ale rozmywanie części widoku SurfaceView jest trudne i nie jest częścią drzewa RenderNode widoku aplikacji, ponieważ w rzeczywistości tworzy otwór w oknie.
Compose nie może więc próbkować bufora pomocniczego SurfaceView, co było niemożliwe do wprowadzenia interfejsu API 37.2.
Dlatego setBlurRegions SurfaceView umożliwia aplikacji bezpośrednie deklarowanie obszarów rozmycia.
Na przykład w tym odtwarzaczu wideo w JetChat dodaliśmy rozmycie SurfaceView, gdy elementy sterujące są wyświetlane i ukrywane.
Aby użyć tego nowego interfejsu API, możesz uzyskać dostęp do metody setBlurRegions w 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()) // ClearWymaga listy obszarów granic, promienia rozmycia, wartości alfa i listy promieni narożników.
Podczas przekazywania tych granic muszą one być względne do SurfaceView na ekranie.
Dlatego musisz śledzić współrzędne widoku SurfaceView i obszar, który chcesz rozmyć.
Kolejną nowością w Androidzie 16 i nowszych jest RuntimeColorFilter.
Wcześniej w przypadku obrazów można było ustawić tylko statyczny filtr kolorów matrycy i efekty takie jak nasycenie.
Jednak w Androidzie 16 i nowszych możesz teraz przekazać cieniowanie, które pobiera kolor wejściowy, czyli piksel wejściowy dla danej treści, i tworzy nowy kolor.
Jeśli na przykład chcemy zastosować do zdjęcia filtr cyberpunkowy, możemy zdefiniować mały obiekt cieniujący, który będzie używany jako dane wejściowe w 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);
}
"""Ten cieniujący tworzy motyw cyberpunku, ale możesz uzyskać dowolny inny efekt.
Aby użyć cieniowania, stosujemy RuntimeColorFilter i konwertujemy go na filtr kolorów Compose.
AsyncImage(
model = imageUri,
contentDescription = filter.displayName,
colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
.asComposeColorFilter(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)Jak widać w tym przykładzie, możemy teraz tworzyć bardziej dynamiczne efekty niż wcześniej, bez konieczności zagłębiania się w OpenGL, konfiguracje SurfaceView czy inne tego typu elementy.
Można to też zastosować do dowolnego elementu kompozycyjnego, nie tylko do obrazów.
Dziękujemy.
Udało nam się stworzyć naprawdę fajny efekt, ale upewnijmy się, że nasza aplikacja nadal działa szybko i płynnie.
Gdy przewijamy rozmowę, wszystkie efekty są odtwarzane jednocześnie.
Zacznijmy od zmierzenia tego przewijania.
@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()
}
//...Używamy makrotestu porównawczego, który uruchamia aplikację w nowym procesie i przewija rozmowę.
Następnie wysyłamy zapytanie do logu czasu, aby policzyć klatki, które nie zostały wyrenderowane na czas.
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%'To daje nam 6 opóźnionych klatek.
Otwórzmy jedną z późnych klatek.
Dlatego tuż przed tą klatką Compose pobiera wstępnie następną wiadomość, zanim zostanie ona przewinięta do widoku.
Pobieranie wstępne powinno zmieścić się w krótkiej przerwie między klatkami, ale przygotowanie tej wiadomości powoduje też dekodowanie obrazu bezpośrednio w głównym wątku, co trwa długo i nie przynosi żadnych korzyści.
Główny wątek utknął na dekodowaniu tego obrazu, więc nie można rozpocząć nowych klatek.
Gdy ta wiadomość pojawi się na ekranie, RenderThread musi przydzielić pamięć i przesłać wszystkie te piksele do GPU, więc ten sam obraz opóźnia drugą klatkę.
Teraz widzimy, na co poświęciliśmy czas, ale czy naprawdę musimy wykonać całą tę pracę, aby narysować tę klatkę?
Jak możemy to sprawdzić?
Ślad nam to powie.
Za każdym razem, gdy duża bitmapa jest rysowana na znacznie mniejszym obszarze ekranu, Android rejestruje znacznik bezpośrednio w śladzie.
Pokazuje 2 liczby w pikselach: pełny wymiar bitmapy i obszar, który faktycznie zajmuje na ekranie.
Podziel tę wartość przez gęstość ekranu, aby uzyskać wartość w DP.
Następnie wyszukujemy w kodzie tę wartość DP, aby znaleźć obraz.
Oto co znaleźliśmy.
Obraz jest wczytywany za pomocą painterResource, który nie wie, jak duży jest kontener, więc dekoduje cały obraz w pełnej rozdzielczości.
Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)Zastępujemy go więc AsyncImage z cewki.
AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)Najpierw mierzy kontener, więc dekoduje tylko potrzebny piksel i robi to w innym wątku.
Po ponownym uruchomieniu tego samego testu porównawczego dekodowanie obrazu w głównym wątku spadło do 0, a liczba opóźnionych klatek – do 2.
Nie będziemy tutaj debugować tych 2 klatek, ale wniosek jest prosty: staraj się nie obliczać pikseli, które nigdy nie dotrą do klatki.
Dekodowanie obrazu było więc na tyle duże, że powodowało opóźnienia klatek, ale nie każdy koszt jest widoczny jako pominięta klatka. Nadal istnieją mniejsze koszty, których możemy uniknąć podczas pisania kodu.
Przyjrzyjmy się 2 z nich.
Za każdym razem, gdy używamy RuntimeShader, powinniśmy utworzyć go raz, ponieważ chociaż jest on tylko kompilowany i buforowany, to i tak analizuje ciąg źródłowy za każdym razem, gdy go tworzymy.
Jeśli efekt animuje każdą klatkę, możemy go opakować w graphicsLayer, aby ponownie nagrywany był tylko animowany element, a nie cały interfejs wokół niego.
Przycisk Przejdź na dół pojawia się, gdy przewijasz.
Do uzyskania tego efektu używane są 2 RuntimeShader. Za każdym razem, gdy pojawia się przycisk, odtwarzamy oba te shadery z ich ciągu źródłowego.
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 {Analiza tych 2 programów do cieniowania i tworzenie efektu zajmuje setki mikrosekund w głównym wątku za każdym razem, gdy przycisk się pojawia.
Zamiast tego tworzymy obiekt cieniujący raz dla całego procesu lub możemy go zapamiętać, a następnie zaktualizować jego zmienną uniform.
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) }Dzięki tej zmianie tworzenie tego efektu jest prawie 40 razy szybsze, a koszt pojawienia się przycisku jest 20 razy mniejszy.
W przypadku efektu świecenia gradientu siatki, który widzieliśmy wcześniej, efekt ten jest animowany w każdej klatce w 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)
}Odczytywanie stanu w fazie rysowania zachowuje kompozycję i układ, ale lista wyświetlania nie może zaktualizować pojedynczego polecenia rysowania na miejscu.
Compose przechodzi więc w górę drzewa, aby znaleźć najbliższy węzeł z warstwą graficzną, którym w tym przypadku jest czerwone obramowanie obejmujące cały ekran, i ponownie rejestruje całą tę warstwę w każdej klatce.
Po umieszczeniu graphicsLayer bezpośrednio na polu podróż zatrzymuje się na zielonym konturze, więc tylko pole świecenia jest ponownie nagrywane, a wycinek rysowania głównego wątku zmniejsza się o 2/10 milisekundy.
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)
}Analizowanie błędów w logach czasu za pomocą Perfetto może być trudne, dlatego możesz skorzystać z umiejętności analizy logów czasu, aby zebrać pewne informacje.
Jeśli chcesz uzyskać dodatkową pomoc w pisaniu wydajnego kodu Compose, zainstaluj interfejs wiersza poleceń Androida i użyj umiejętności wydajności Compose.
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