소프트웨어 엔지니어
개발자 관계팀 엔지니어
Android UI를 '작동하는 수준'에서 '예술 작품'으로 끌어올릴 준비가 되셨나요?
Jetpack Compose의 최신 그래픽 기능을 살펴보는 시간을 마련했습니다. Android의 강력한 새 그래픽 기능을 사용하여 멋진 메시 그라데이션부터 점진적 블러에 이르기까지 새로운 시각 효과를 빌드하는 방법을 보여드리겠습니다. 또한 배터리 수명이나 프레임 속도를 희생하지 않고 그래픽 기능의 성능을 개선하는 방법도 보여드리겠습니다.
PolygonShape 및 MorphPolygonShape API가 도입되었습니다. MorphPolygonShape는 윤곽선 해상도 단계에서 진행 상황을 읽고 재구성을 트리거하지 않고 프레임별로 도형을 업데이트합니다.RenderNode.setBackdropRenderEffect가 도입되었습니다. 개발자는 배경 효과를 연결하여 불투명 유리 효과나 확대 효과를 만들 수 있습니다.graphicsLayer로 래핑합니다.스크립트
안녕하세요. 'Compose 및 Android의 최신 그래픽 기능으로 앱 개선하기' 세션에 오신 것을 환영합니다.
저는 레베카 프랭크스입니다. 오늘 함께 무대에 선 분은…
저는 아유시 차우다리입니다.
안녕하세요, 저는 라훌 레이입니다.
AI를 사용하여 놀라운 아이디어를 실제로 구현하면서 Android와 Compose에서 가능한 것의 한계를 뛰어넘는 새로운 트렌드가 나타나고 있습니다.
또한 프리미엄, 네이티브, 쾌적한 앱을 선호합니다.
사용자에게 만족감을 주고 지속적으로 높은 평점을 받는 앱은 일반적으로 최신 시스템 수준 API 변경사항을 활용하고, 공유 요소와 블러를 사용하여 표시 경로 간에 연속성을 유지하며, 폼 팩터 전반에서 원활하게 적응하고, 성능이 우수하며, 사용자가 추측할 필요가 없고, 기기로 가능한 작업의 한계를 뛰어넘는 앱입니다.
오늘 이 강연에서는 메시 그라데이션으로 사용자에게 즐거움을 선사하고, 넓은 색 영역 지원을 사용 설정하고, 다각형 모양으로 즐거운 전환을 구현하고, 블러 처리의 새로운 기능을 활용하고, 마지막으로 앱에 새로운 그래픽 기능을 추가할 때 성능을 고려하는 방법 등 Android를 활용하는 데 도움이 되는 몇 가지 최신 기능을 보여드리겠습니다.
하지만 오늘 강연에서는 스택의 다양한 수준에서 새로운 API를 다룰 예정입니다.
기존 뷰 UI 툴킷은 현재 유지보수 모드에 있지만, Google은 Jetpack Compose를 구동하는 기본 Android 그래픽 하드웨어 렌더링 엔진에 계속 투자하고 있습니다.
Android의 그래픽은 세 가지 레이어로 생각할 수 있습니다. 첫 번째 레이어에는 Compose API가 있으며, 여기에는 메시 그라데이션 페인터, 다각형 도형, 점진적 블러 API와 같은 모든 상위 수준의 새로운 UI 그래픽 API가 있습니다.
중간 레이어에서는 Compose의 DrawScope, 그리기 수정자 노드, AGSL을 사용하여 맞춤 효과를 빌드할 수 있습니다.
마지막으로 Android 플랫폼이 있습니다. Compose는 항상 네이티브 Canvas에 액세스할 수 있으며 Compose 수정자 내에서 바로 새로운 OS 렌더링 프리미티브를 사용할 수 있습니다.
오늘 이와 관련된 몇 가지 예를 보여드리겠습니다.
이제 본격적인 내용으로 들어가 보겠습니다.
먼저 Compose 1.12에 도입된 새로운 API인 메시 그라데이션을 살펴보겠습니다.
이전에는 선형, 스윕, 방사형 그래디언트가 있었는데, 이는 간단한 전환에 적합하지만, 주변 배경이나 플러드라이트 효과와 같은 풍부한 다색 혼합을 원한다면 공간에서 위치와 색상을 혼합할 수 있는 2D 그리드 시스템이 필요합니다.
이와 같은 메시 그라데이션을 만들려면 먼저 원하는 패치의 행과 열 수를 지정합니다.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
}
}그런 다음 후행 람다 내에서 setVertex를 호출하고 각 그리드 교차점의 색상과 함께 0F에서 1F 사이의 정규화된 오프셋을 할당합니다.
val gradientPainter = remember {
MeshGradientPainter(rows = 3, columns = 3) {
setVertex(0, 0, Offset(0.0f, 0.0f), indigo)
}
}그런 다음 꼭짓점에 애니메이션을 적용하려면 꼭짓점 8개에 애니메이션 오프셋을 추가합니다.
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)
)페인터를 정의한 후에는 페인트 수정자를 통해 컴포저블로 호출하면 됩니다.
이제 JetChat 앱에서 메시지를 두 번 탭하면 메시지에 좋아요를 표시하는 기능을 추가했습니다.
이를 빌드하기 위해 2x3 메시를 설정하고 구성요소 전체에 색상 정지를 배치합니다.
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
}여기에서 hasBicubicColor를 true로 설정합니다.
기본적으로 색상은 삼각형 전체에서 선형으로 보간되므로 때때로 눈에 보이는 패치가 남을 수 있습니다.
이 기능을 사용 설정하면 셰이더가 부드러운 바이큐빅 보간으로 전환되어 색상이 서로 매끄럽게 녹아들 수 있습니다.
이제 입력 상자 뒤에 이와 같은 애니메이션 주변광을 만들고 싶다면 어떻게 해야 할까요?
메시 그라데이션으로 이와 같은 효과를 구현하려면 두 가지가 필요합니다.
첫째, 내부 꼭짓점의 부드럽고 자연스러운 움직임과 카드 주변에 딱딱한 직사각형이 생기지 않도록 바깥쪽 가장자리를 부드럽게 만드는 방법입니다.
따라서 먼저 두 개의 위상 각도가 약간 다른 지속 시간으로 실행되는 rememberInfiniteTransition를 설정하여 움직임이 기계적이지 않고 자연스럽게 느껴지도록 합니다.
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",
)여기서는 내부 꼭짓점에 애니메이션을 적용하고 외부 링, 즉 알파가 0인 가장자리가 있는 꼭짓점의 외부 링을 고정합니다.
바깥쪽 경계는 고정되어 있고 투명하므로 빛이 자연스럽게 퍼집니다.
마지막으로 애니메이션 오프셋을 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])
}
}
}방금 본 메시 그라데이션은 Android 14 이상에서 drawMesh API를 사용하여 그려집니다.
그럼 이 API를 자세히 살펴보면서 효과를 처음부터 빌드해 보겠습니다.
JetChat 시작 화면의 경우 리본 모양의 경로를 그리려고 합니다.
여기서 두 가지를 확인할 수 있습니다. 획 너비가 일정하지 않고 색상이 경로의 각 획을 따라 흐릅니다.
이 효과를 빌드하는 데 실제로 필요한 것이 무엇인지 자세히 살펴보겠습니다.
각 픽셀에 대해 그릴지 여부와 사용할 색상을 결정해야 합니다.
이를 파악하려면 두 가지 거리가 필요합니다.
첫 번째 값은 픽셀이 획의 중심에서 얼마나 떨어져 있는지를 나타냅니다.
두 번째 거리는 시작부터 끝까지 경로를 따라 픽셀이 얼마나 멀리 있는지입니다.
이렇게 하면 그라데이션에서 선택할 색상을 알 수 있으며 첫 번째 값을 약간 혼합하면 너비에 걸쳐 색상 곡선이 만들어집니다.
그렇다면 경로와 페인트만 사용하여 경로를 따라 그라데이션을 그릴 수 있을까요?
네, 가능합니다.
이를 위해서는 경로를 짧은 직선 목록으로 평면화해야 하며, 이를 통해 경로를 따라 이동하는 거리도 알 수 있습니다.
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;
}정점 셰이더는 속성을 입력으로 사용하고 가변을 반환합니다.
속성은 단일 코너의 데이터입니다.
여기에는 선을 따라 진행되는 정도와 선의 어느 쪽에 있는지에 관한 두 가지 숫자만 있습니다.
그런 다음 가변 변수가 보간되어 프래그먼트 셰이더에 전달됩니다.
시작점으로부터의 픽셀 거리를 계산하고, 획 너비의 경우 시간을 사용하여 반지름에 펄스를 생성합니다.
그런 다음 이 반경에 모서리 측면을 곱하면 획 전체의 부호 거리가 나옵니다.
GPU가 이러한 부호 사이를 보간하면 중심선이 0이 됩니다.
마지막으로 모서리 위치를 계산하고 이 모든 값을 획 변수에 저장한 후 프래그먼트 셰이더에 전달합니다.
런타임 셰이더와 달리 메시 프래그먼트 셰이더는 매개변수를 통해 색상을 출력하고 페인트를 샘플링할 위치를 반환합니다(설정된 경우).
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;
}가로 좌표의 절댓값을 구하면 중심선으로부터의 픽셀 단위 거리를 얻을 수 있습니다.
이 거리를 반지름과 비교하여 적용 범위를 구하고, 색상의 경우 선의 경로를 따라 혼합하고 그라데이션을 가장자리 쪽으로 약간 구부립니다.
이 적용 범위에 적용 범위를 곱하면 최종 색상이 됩니다.
Compose 측에서는 MeshSpecification 객체를 사용하여 셰이더와 데이터 레이아웃을 함께 연결합니다.
val spec = remember {
MeshSpecification.make(
arrayOf(Attribute(TYPE_FLOAT2, 0, "corner")),
vertexStride = 8,
arrayOf(Varying(TYPE_FLOAT3, "stroke")),
LineShaders.VERTEX, LineShaders.FRAGMENT)
}먼저 모서리 속성을 바이트 0에서 시작하는 두 개의 부동 소수점 수로 선언합니다.
두 개의 부동 소수점은 8바이트를 차지하므로 버퍼의 각 모서리 보폭은 8입니다.
다음으로, 획 변수를 세 개의 부동 소수점으로 선언하고 방금 작성한 정점 및 프래그먼트 셰이더를 전달합니다.
꼭짓점 버퍼를 빌드합니다.
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
}선을 따라 각 단계에서 진행률을 완료하고 0에서 1까지의 진행률을 계산하고 두 모서리를 버퍼에 씁니다.
마지막으로 사양과 버퍼를 메시 객체로 결합합니다.
val mesh = Mesh(
spec,
mode = Mesh.TRIANGLE_STRIP,
vertexBuffer = corners,
vertexCount = (segments + 1) * 2,
bounds = RectF(0f, 0f, size.width, size.height)
)Compose 캔버스는 아직 drawMesh를 노출하지 않으므로 nativeCanvas로 내려가서 메시를 그립니다.
Box(Modifier.fillMaxSize().drawWithCache {
val mesh = Mesh(...)
onDrawBehind {
mesh.setFloatUniform("uTime", time.value)
drawIntoCanvas {
it.nativeCanvas.drawMesh(mesh,android.graphics.BlendMode.DST, paint)
}
}
})도형과 픽셀을 제어하므로 메시 그리기를 사용하여 도형 기반 시각 효과를 그릴 수 있습니다.
이 API에 대한 자세한 내용은 문서를 참고하세요.
Compose 1.12에서는 넓은 색 공간 지원이 도입되었습니다.
이것이 실제로 의미하는 바는 무엇일까요?
화면은 기존 sRGB 색상 공간과 다른 범위의 색상을 재현할 수 있다는 사실을 알고 계셨나요?
예를 들어 이 이미지를 보면 흰색 삼각형은 대부분의 화면에서 표시할 수 있는 표준 sRGB 색상의 범위입니다.
하지만 최신 화면 중 일부는 노란색 삼각형으로 표시된 것처럼 더 넓은 범위의 색상을 지원하며, 일부는 더 채도가 높고 선명합니다.
하지만 색상을 표현할 때는 실제로 삼각형의 각 꼭짓점에 대한 설명으로 표현합니다.
이 그라데이션 비교를 볼 때는 디스플레이 색 영역과 그라데이션 보간을 구분하는 것이 중요합니다.
표준 sRGB 디스플레이나 이 프로젝터에서는 화면이 이 sRGB 색 영역 외부의 색상을 실제로 물리적으로 렌더링할 수 없습니다.
그래서 지금 무대에서 시연하기가 정말 어렵습니다.
따라서 넓은 색 영역을 지원하는 Android에서 색상이 더 잘 보인다는 점을 믿으셔야 합니다.
이 예시에서 실제로 볼 수 있는 것은 더 넓은 색상 공간에서 색상 중지점을 보간하는 효과입니다.
넓은 색 공간 또는 선형 색 공간에서 색상을 혼합하면 이 그라데이션의 실제 수학적 경로가 변경되어 넓은 색 공간 없이 수행할 때 발생할 수 있는 중간 회색화 및 거친 밴딩이 방지됩니다.
Compose 1.12를 사용하면 모든 디스플레이에서 더 부드러운 그라데이션 블렌딩을 활용할 수 있으며, 지원되는 하드웨어에서 실행할 때 진정한 넓은 색 공간을 지원합니다.
기기에서 올바른 지원을 사용 설정하려면 활동 또는 창이 넓은 색 공간 모드를 사용 설정하도록 설정되어 있는지 확인해야 합니다.
이 작업은 AndroidManifest에서 할 수 있습니다.
<!-- 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
}
}colorMode를 설정하지 않으면 창은 표준 8비트 sRGB로 유지되고 GPU에서 렌더링된 모든 넓은 색 영역 색상이 잘립니다.
따라서 디스플레이가 실제로 지원할 수 없는 경우 넓은 색 공간 색상을 사용하지 않는 것이 중요합니다.
배터리와 성능을 절약하려면 실제로 미디어, 사진 또는 리치 그래픽을 표시하는 활동이나 코드 경로에서만 넓은 색 공간을 사용 설정해야 합니다.
즉, Compose에서 다양한 색상 공간의 색상을 만들 수 있으며 이제 이러한 색상이 적용됩니다.
// 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
)색상을 만들려는 colorSpace 유형을 전달할 수 있으며, colorSpace를 전달하지 않으면 표준 sRGB가 사용됩니다.
특히 비트맵의 경우 getColorSpace를 사용하여 비트맵의 소스를 확인할 수 있으며, 인기 있는 이미지 로드 라이브러리가 올바른 비트맵 색상 공간을 자동으로 처리합니다.
565와 같은 구성은 실제로 정밀도가 떨어지고 사용자에게 밴딩이 표시되므로 색상 정보를 보존하려면 Bitmap.Config.HARDWARE를 사용해야 합니다.
색상에 관해 자세히 알아보려면 2017년으로 돌아가 로메인 가이가 Compose의 색상 또는 Android의 색상에 관해 설명하는 동영상을 시청하세요.
Compose 1.13에서는 다각형 도형에 대한 네이티브 지원이 도입됩니다.
이를 통해 맞춤 도형과 유기적인 UI 요소를 훨씬 쉽게 만들 수 있습니다.
이전에는 이와 같은 복잡한 도형을 그리려면 RoundedPolygon를 사용하여 경로를 출력하는 도형을 설명한 다음 해당 경로를 Compose 도형 API에 매핑해야 했습니다.
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)
)
}예를 들어 먼저 RoundedPolygon를 사용하여 비율을 정의합니다. 이 스니펫에서는 내부 점을 외부 점과 비교하여 중심에서 84% 에 위치하도록 설정합니다.
여기에서 모서리 라운딩을 적용할 수도 있지만 이는 수학적 곡선만 설명합니다.
Compose에서 이 다각형을 클리핑이나 배경에 실제로 사용하려면 다음과 같이 맞춤 Shape 래퍼를 작성해야 합니다.
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)
}
}이 createOutline 스니펫 내에서 경로를 가져오고 직접 수동으로 확장하고 변환해야 한다는 것을 알 수 있습니다.
이제 새로운 PolygonShape API를 사용하면 더 이상 맞춤 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)전용 Compose Shape 빌더이므로 밀도와 크기를 바로 수신합니다.
즉, 꼭짓점을 동적으로 계산하고 모서리 반올림을 dp로 직접 지정하거나 백분율로 지정할 수도 있습니다.
정의한 후에는 Shape를 사용하는 API에 전달하기만 하면 됩니다.
또한 다양한 다각형 도형 간에 원활하게 전환할 수 있는 MorphPolygonShape API도 도입합니다.
val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}이 예시에는 상호작용 시 원에서 별로 변형되는 녹화 버튼이 있습니다.
시작 도형, 종료 도형, 진행률 값만 제공하면 됩니다.
MorphPolygonShape API는 컴포지션 중이 아닌 윤곽선 해상도 단계에서 진행률 값을 읽습니다.
즉, 도형이 프레임 간에 변형될 때 리컴포지션을 트리거하지 않고 단순히 새로운 윤곽선으로 변환됩니다.
다음으로 흐리게 처리에 대해 알아보겠습니다.
지난 몇 번의 출시에서 Google은 블러 구현을 훨씬 더 쉽고 표현력 있게 만들기 위해 노력하고 있습니다.
블러 처리에 관해 이야기할 때마다 흔히 사용되는 수정자가 바로 이 수정자입니다.
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)Compose에는 Android 12부터 이 수정자가 있었습니다.
구성 가능한 함수 전체에 정확히 동일한 블러 반경이 적용되는 균일한 블러를 원하는 경우에 적합합니다.
하지만 최신 UI 디자인에서는 선명한 영역에서 흐린 영역으로의 전환이 급격하게 이루어지기보다는 부드럽게 이루어지는 경우가 많습니다.
이제 Compose 1.13부터 공식 API가 된 Progressive Blur에 대해 알아보겠습니다.
이제 전체 컴포저블에서 단일한 일정한 반경을 사용하는 대신 공간 전체에서 블러 강도를 다양하게 조정할 수 있습니다.
다음은 JetChat 앱의 사용자 프로필 화면 예입니다.
새로운 블러 수정자는 블러 반경 사양을 사용합니다.
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}여기서는 두 개의 블러 정지가 있는 세로 그라데이션을 사용하여 사진의 상단 절반은 0dp로 완전히 선명하게 유지하고 하단 가장자리에서는 32dp 블러로 점차 증가시킵니다.
또한 edgeTreatment를 Unbounded로 설정하여 기본적으로 컴포저블 경계 외부의 투명 픽셀도 샘플링하도록 흐리게 처리합니다.
이 블러 반경 사양은 수직 페이드 아웃 외에도 점진적 블러를 적용하는 여러 가지 기본 제공 방법을 제공합니다.
예를 들어 왼쪽에는 가로 그라데이션이 표시되며, 시작 반경은 0dp이고 종료 반경은 슬라이더 값에 의해 결정됩니다.
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)오른쪽에는 방사형 그래디언트가 있습니다. 중심에서 블러 반경은 0dp이며, fallOffRadius(이 경우 300dp)로 이동할수록 블러가 최대 24dp까지 부드럽게 증가합니다.
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
},여기서는 애니메이션 대각선 사인파를 계산하고 이 강도를 알파 채널로 출력하는 작은 프래그먼트 셰이더를 작성합니다.
그러면 셰이더 사양이 0~1 마스크를 24dp 마스크 maxRadius에 직접 매핑합니다.
배경 흐리게 처리에 대해 이야기하기 전에 두 기능의 차이점을 살펴보겠습니다.
이제 왼쪽에는 레이어 블러를 적용한 상단 앱 바가 있습니다.
즉, 컴포저블 자체를 흐리게 처리합니다.
보시다시피 제목과 작업 아이콘은 흐리게 표시되지만 그 뒤에 있는 채팅 구성요소는 전혀 흐리게 표시되지 않습니다.
오른쪽에는 배경 흐림 효과가 있습니다.
여기서는 상단 표시줄 뒤로 스크롤되는 채팅 메시지만 샘플링되고 흐리게 처리됩니다.
하지만 텍스트와 아이콘은 완전히 선명하고 읽기 쉽습니다.
Android 37.2부터는 RenderNode.setBackdropRenderEffect라는 새 API를 사용하여 배경을 흐리게 처리할 수 있습니다.
블러가 어떻게 개선되고 있는지 알아보기 전에, 블러가 원활하게 작동하도록 Android가 내부적으로 어떻게 작동하는지 알아보겠습니다.
Android는 내부적으로 하드웨어 가속 디스플레이 목록 컨테이너인 RenderNode를 사용합니다. 즉, 녹화 시작과 녹화 종료를 통해 다양한 그리기 명령어를 압축하고 저장할 수 있습니다.
RenderNode에는 3D 변환, 회전, 알파, 배율, 위치 경계와 같은 정보가 포함됩니다.
Compose는 실제로 내부적으로 RenderNode를 사용합니다.
따라서 Compose에서 배경 렌더링 효과를 사용하려면 이 수정자를 재사용할 수 있도록 맞춤 Modifier.Node를 만들면 됩니다.
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()
}
}RenderNode에 대한 참조를 유지하고 RenderNode는 다른 함수에서 원하는 효과로 구성됩니다.
예를 들어 여기에서 흐림 효과를 전달합니다.
다음으로 drawIntoCanvas를 호출하여 네이티브 캔버스에서 drawRenderNode를 호출합니다.
마지막으로 drawContent를 호출하여 흐리게 처리되지 않은 실제 콘텐츠도 상단에 그립니다.
방금 만든 이 수정자를 사용하려면 Modifier.backdropBlur를 호출하고 블러 반경 사양을 전달합니다.
JetchatAppBar(
modifier = modifier
.backdropBlur(
tint = MaterialTheme.colorScheme
.surface.copy(alpha = 0.5f),
shape = RectangleShape,
spec = BlurRadiusSpec.uniform(5.dp)
)
)이 접근 방식에서는 기본 레이어에 아무것도 배치할 필요가 없습니다.
RenderNode.setBackdropRenderEffect는 JetChat 앱 바 아래에 있는 콘텐츠를 자동으로 샘플링합니다.
그 결과 이처럼 멋진 배경 흐림 효과가 적용됩니다.
또한 점진적 블러뿐만 아니라 점진적 블러 반경 사양을 설정하여 콘텐츠에 세로 블러를 적용할 수도 있습니다.
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),
),
)
)
)세로 그라데이션을 사용하면 컨테이너의 여러 부분에서 다양한 반지름으로 흐리게 처리된 콘텐츠가 아래에 표시됩니다.
Compose에서 기본 제공되는 배경 흐림 수정자를 개발 중입니다.
이것은 Compose를 사용하는 Android의 기본 기술의 예일 뿐입니다.
이 예시는 API 수준 37.2에서만 작동하지만 Compose용 백포트도 개발 중이니 기대해 주세요.
RenderNode 배경 효과를 사용한 또 다른 재미있는 예는 실제로 여러 효과를 연결하여 멋진 UI를 만들고 그 위에 여러 가지 효과를 적용할 수 있다는 것입니다.
효과를 연결하기 위해 이 두 가지 효과를 만듭니다.
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)이 예시에서는 흐림 효과와 30%의 채도를 만듭니다.
블러 효과를 먼저 적용하고 채도를 나중에 적용하여 createChainEffect를 호출합니다.
다음은 JetChat의 예입니다.
val agslShader = RuntimeShader(CHROMATIC_ABERRATION)
val noiseEffect = RenderEffect.createRuntimeShaderEffect(agslShader, "backdropInput")
val finalEffect = RenderEffect.createChainEffect(noiseEffect, vibrantBlur)
renderNode?.setBackdropRenderEffect(finalEffect)하단으로 이동 버튼은 몇 가지 효과를 결합하여 이 컴포저블의 가장자리를 돋보기처럼 둥글게 만들어 작은 돋보기처럼 보이게 할 수 있습니다.
하지만 확대 효과를 적용할 수 있을 뿐만 아니라 모든 종류의 셰이더를 함께 연결하여 배경에 적용할 수 있습니다.
다음은 이 두 가지를 결합하여 구현한 재미있는 예입니다.
이제 기존의 반투명 유리 유형 효과를 사용할 수 있습니다.
만화경과 같은 다른 재미있는 효과도 적용할 수 있습니다.
또는 이 Android 마스코트를 아래의 콘텐츠를 변환하는 일종의 모핑 UI 요소로 바꾸고 싶다면 이 배경 렌더링 효과를 적용하여 이 작은 드로이드를 통해 보는 것처럼 보이게 할 수도 있습니다.
배경 렌더링 효과를 사용해도 됩니다.
이 새로운 API를 사용하면 배경 흐리게 처리와 반투명 유리 효과를 사용할 수 있을 뿐만 아니라 상상하는 모든 것을 구현할 수 있습니다.
플랫폼에 도입된 또 다른 새로운 API는 SurfaceView.setBlurRegions입니다.
현재는 Modifier.blur 및 RenderNode 배경 효과를 사용하여 텍스처 뷰와 모든 컴포저블을 흐리게 처리할 수 있지만, SurfaceView의 일부를 흐리게 처리하는 것은 까다롭고 앱의 뷰 RenderNode 트리에 포함되지 않습니다. 실제로 창에 구멍을 뚫기 때문입니다.
따라서 Compose는 실제로 SurfaceView의 지원 버퍼를 샘플링할 수 없으므로 API 37.2 이전에는 이 기능이 불가능했습니다.
따라서 SurfaceView의 setBlurRegions를 사용하면 앱에서 블러 영역을 직접 선언할 수 있습니다.
예를 들어 JetChat의 이 동영상 플레이어에서는 컨트롤이 표시되고 숨겨질 때 SurfaceView 블러를 추가했습니다.
이 새로운 API를 사용하려면 SurfaceView 메서드에서 setBlurRegions에 액세스하면 됩니다.
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이렇게 하면 경계 영역 목록, 블러 반경, 알파, 모서리 반경 목록이 표시됩니다.
이러한 경계를 전달할 때는 화면의 SurfaceView를 기준으로 해야 합니다.
따라서 SurfaceView의 좌표와 흐리게 처리하려는 영역을 추적해야 합니다.
Android 16 이상에서 제공되는 또 다른 새로운 기능은 RuntimeColorFilter입니다.
이전에는 이미지의 경우 정적 매트릭스 색상 필터만 설정하고 채도와 같은 효과를 적용할 수 있었습니다.
하지만 Android 16 이상에서는 이제 특정 콘텐츠의 입력 픽셀인 색상을 가져와 새 색상을 생성하는 셰이더를 전달할 수 있습니다.
예를 들어 사진에 사이버펑크 필터를 적용하려면 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);
}
"""이 셰이더는 사이버펑크 테마를 생성하지만 원하는 다른 효과를 적용할 수 있습니다.
따라서 셰이더 자체를 사용하려면 RuntimeColorFilter를 적용하고 Compose 색상 필터로 변환합니다.
AsyncImage(
model = imageUri,
contentDescription = filter.displayName,
colorFilter = RuntimeColorFilter(CYBERPUNK_SHADER)
.asComposeColorFilter(),
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize(),
)이 예에서 볼 수 있듯이 이제 OpenGL, SurfaceView 설정 등을 자세히 살펴보지 않고도 이전보다 더 동적인 효과를 만들 수 있습니다.
이미지뿐만 아니라 모든 컴포저블에 적용할 수 있습니다.
감사합니다.
지금까지 멋진 효과를 만들었지만 앱이 여전히 빠르고 부드럽게 느껴지는지 확인해 보겠습니다.
대화를 스크롤하면 모든 효과가 함께 실행됩니다.
이 스크롤을 측정하는 것부터 시작해 보겠습니다.
@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%'그러면 지연 프레임이 6개가 됩니다.
늦은 프레임 중 하나를 열어 보겠습니다.
따라서 이 프레임 직전에 Compose는 다음 메시지가 위로 스크롤되어 뷰에 표시되기 전에 미리 가져옵니다.
미리 가져오기는 프레임 간의 짧은 간격에 맞아야 하지만 이 메시지를 준비하면 기본 스레드에서 이미지를 디코딩하여 양보 없이 오랫동안 실행됩니다.
기본 스레드가 이 이미지를 디코딩하는 데 멈춰 있으므로 새 프레임을 시작할 수 없습니다.
그런 다음 해당 메시지가 실제로 뷰로 스크롤되면 RenderThread는 메모리를 할당하고 모든 픽셀을 GPU에 업로드해야 하므로 동일한 이미지가 두 번째 프레임을 지연시킵니다.
이제 시간이 어디에 소요되었는지 알 수 있습니다. 하지만 이 프레임을 그리는 데 이 모든 작업이 필요할까요?
어떻게 알 수 있나요?
trace를 보면 알 수 있습니다.
큰 비트맵이 화면의 훨씬 작은 영역에 그려질 때마다 Android는 트레이스에 마커를 기록합니다.
전체 비트맵 크기와 화면에서 실제로 차지하는 영역이라는 두 가지 숫자가 픽셀 단위로 표시됩니다.
이를 화면 밀도로 나누면 DP 단위의 값을 얻을 수 있습니다.
그런 다음 해당 DP 값으로 코드를 검색하여 이미지를 찾습니다.
이것이 우리가 발견한 것입니다.
이미지가 painterResource로 로드되는데, 이 라이브러리는 컨테이너의 크기를 알지 못하므로 전체 이미지를 전체 해상도로 디코딩합니다.
Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)따라서 Coil의 AsyncImage로 대체합니다.
AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)먼저 컨테이너를 측정하므로 필요한 픽셀만 디코딩하고 다른 스레드에서 이 작업을 실행합니다.
동일한 벤치마크를 다시 실행하면 기본 스레드의 이미지 디코딩이 0으로, 지연 프레임이 2로 감소합니다.
여기서는 이 두 프레임을 디버깅하지 않지만, 핵심은 간단합니다. 프레임에 도달하지 않는 픽셀은 계산하지 않도록 하세요.
이미지 디코딩은 프레임을 지연시킬 만큼 큰 작업이었지만, 모든 비용이 프레임 드롭으로 나타나는 것은 아니며, 코드를 작성하는 동안 피할 수 있는 더 작은 비용도 있습니다.
그중 두 가지를 살펴보겠습니다.
RuntimeShader를 사용할 때마다 한 번만 만들어야 합니다. 컴파일되고 캐시되지만 만들 때마다 소스 문자열을 파싱하기 때문입니다.
효과가 모든 프레임에 애니메이션을 적용할 때마다 graphicsLayer로 래핑할 수 있으므로 애니메이션 요소만 다시 기록되고 주변 UI는 다시 그려지지 않습니다.
'하단으로 이동' 버튼은 스크롤할 때마다 표시됩니다.
이 효과를 표시하기 위해 두 개의 RuntimeShader를 사용하며, 버튼이 표시될 때마다 소스 문자열에서 두 셰이더를 모두 다시 만듭니다.
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) }이러한 변경으로 효과를 빌드하는 속도가 거의 40배 빨라졌고 버튼이 표시될 때의 비용은 20배 감소했습니다.
앞서 살펴본 메시 그라데이션 글로우에서는 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)
}따라서 그리기 단계에서 상태를 읽으면 컴포지션과 레이아웃이 유지되지만 디스플레이 목록은 단일 그리기 명령어를 제자리에서 업데이트할 수 없습니다.
따라서 Compose는 트리를 올라가 그래픽 레이어가 있는 가장 가까운 노드를 찾습니다. 여기서는 전체 화면인 빨간색 윤곽선이며, 프레임마다 전체 레이어를 다시 기록합니다.
상자에 graphicsLayer를 바로 배치하면 이동이 녹색 윤곽선에서 중지되므로 글로우 상자만 다시 기록되고 기본 스레드 그리기 슬라이스가 0.2밀리초만큼 줄어듭니다.
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)
}Perfetto를 사용하면 트레이스에서 무엇이 잘못되었는지 분석하기 어려울 수 있으므로 트레이스 분석 기술을 사용하여 유용한 정보를 수집할 수 있습니다.
성능이 우수한 Compose 코드를 작성하는 데 도움이 더 필요하다면 Android CLI를 설치하고 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