Инженер по связям с разработчиками
Разработчик ПО
Инженер по связям с разработчиками
Хотите, чтобы интерфейс вашего приложения для Android был не просто удобным, а по-настоящему красивым?
Присоединяйтесь к нам, чтобы узнать о последних графических возможностях в Jetpack Compose. Мы покажем, как создавать новые визуальные эффекты, от восхитительных градиентов сетки до прогрессивного размытия, и использовать новые мощные графические функции в Android. Мы также расскажем, как улучшить графику, не жертвуя временем работы от батареи и частотой кадров.
PolygonShape и MorphPolygonShape для пользовательских геометрических фигур. MorphPolygonShape считывает прогресс на этапе разрешения контура, обновляя фигуры кадр за кадром без запуска рекомпозиции.RenderNode.setBackdropRenderEffect для выборки фонового контента. Разработчики могут объединять эффекты фона, чтобы создавать эффекты матового стекла или увеличительного стекла.graphicsLayer.Расшифровка
Приветствую всех на нашем семинаре "Улучшение приложений с помощью новейших графических функций в Compose и Android".
Меня зовут Ребекка Фрэнкс, и сегодня со мной на сцене…
Меня зовут Ааюш Чаудхари.
Привет, я Рахул Рэй.
Мы видим, что вы все активнее используете ИИ, чтобы расширять границы возможного в Android и Compose и воплощать в жизнь свои невероятные идеи.
Мы любим приложения, которые выглядят премиально, нативно и восхитительно.
Мы знаем, что приложения, которые нравятся пользователям и получают высокие оценки, обычно используют последние изменения API на уровне системы, имеют преемственность между поверхностями с общими элементами и размытием, прекрасно адаптируются к различным форм-факторам, работают быстро, не заставляют пользователей гадать и расширяют границы возможного на вашем устройстве.
Сегодня мы расскажем о некоторых новых функциях, которые помогут вам использовать все преимущества Android. Вы узнаете, как порадовать пользователей сетчатыми градиентами, включить поддержку широкой цветовой гаммы, добавить красивые переходы с многоугольниками и размытие. Наконец, мы обсудим, как учитывать производительность при добавлении новых графических функций в приложения.
Но сегодня мы поговорим о некоторых новых API на разных уровнях стека.
Хотя устаревший набор инструментов View UI сейчас находится в режиме обслуживания, мы продолжаем инвестировать в базовые механизмы аппаратного рендеринга графики Android, на которых работает Jetpack Compose.
Графику в Android можно разделить на три уровня. Первый уровень – это Compose API, где находятся все новые высокоуровневые API для графики пользовательского интерфейса, например API для рисования градиентных сеток, многоугольников и прогрессивного размытия.
И, наконец, промежуточный уровень, где DrawScope, узлы модификаторов рисования и AGSL позволяют создавать собственные эффекты.
И наконец, у нас есть платформа Android, и Compose всегда может получить доступ к нативному Canvas и использовать совершенно новые примитивы рендеринга ОС прямо в модификаторах Compose.
Сегодня мы покажем вам несколько примеров.
Давайте начнем!
Сначала рассмотрим сетчатые градиенты – новый API, представленный в Compose 1.12.
Раньше у нас были линейные, конические и радиальные градиенты, которые отлично подходят для простых переходов. Но если вы хотите получить насыщенные многоцветные смеси, такие как фоны или эффекты заливающего света, вам нужна двумерная сетка, в которой позиции и цвета могут смешиваться в пространстве.
Чтобы создать такой сетчатый градиент, сначала укажите, сколько строк и столбцов фрагментов вам нужно.
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)
}
}А затем, чтобы анимировать эти вершины, добавьте анимированные смещения для восьми вершин.
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)
)После того как вы определили объект Painter, просто вызовите его с любой composable-функцией через модификатор paint.
Теперь в приложении JetChat мы добавили функцию, позволяющую поставить лайк сообщению, дважды нажав на него.
Для этого мы создаем сетку 2 на 3 и размещаем контрольные точки цвета по всему компоненту.
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",
)Здесь мы анимируем внутренние вершины и закрепляем внешние кольца, внешнее кольцо вершин с ребрами с нулевой альфой.
Поскольку внешняя граница неподвижна и прозрачна, свечение естественным образом затухает.
И, наконец, мы передаем эти анимированные смещения в наш 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])
}
}
}Сетчатый градиент, который вы только что видели, нарисован с помощью API drawMesh на Android 14 и более поздних версиях.
Давайте рассмотрим этот 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,
)
//...
}Затем мы рисуем каждую линию со своим градиентом.
Это работает, но для каждой линии требуется отдельный вызов отрисовки и объект рисования, а весь контур по-прежнему имеет одну ширину.
Другой вариант – шейдер времени выполнения, но он получает только положение пикселей в пределах контейнера.
Она не говорит нам, что нам нужно, чтобы нарисовать эту ленту.
И мы не можем легко рассчитать это по положению пикселей.
Для этого в DrawMesh есть вершинный шейдер.
Что такое сетка?
Сетка состоит из треугольников, в каждом углу которых есть данные. Каждый угол называется вершиной.
Каждая вершина содержит небольшую запись данных.
Обычно эта запись содержит позицию, но это не обязательно.
Вершинный шейдер считывает эту запись и вычисляет на ее основе позицию.
Вот путь, который проходит один треугольник: вершинный шейдер запускается для каждого угла, вычисляет положение углов и любое значение, которое мы хотим передать для каждого пикселя.
Затем растеризатор находит каждый пиксель, покрытый этим треугольником, и интерполирует значения из этих трех углов для каждого пикселя.
Наконец, для этих пикселей запускается фрагментный шейдер, который использует интерполированные значения и вычисляет цвет пикселей.
Давайте посмотрим, как можно написать вершинный и фрагментный шейдеры на более простом примере – прямой линии с движущимся по ней импульсом.
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;
}Абсолютное значение поперечной координаты дает нам расстояние в пикселях от центральной линии.
Мы сравниваем это расстояние с радиусом, чтобы получить покрытие, а для цвета смешиваем вдоль пути линии и слегка изгибаем градиент к краю.
Умножьте это покрытие на умножьте это на покрытие, это наш окончательный цвет.
В 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.
Затем мы объявляем переменные stroke как три числа с плавающей запятой и передаем вершинный и фрагментный шейдеры, которые только что написали.
Мы создаем буфер вершин.
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>или программно в composable-функции.
// Dynamic toggle in code
DisposableEffect(window) {
window?.colorMode = ActivityInfo.COLOR_MODE_WIDE_COLOR_GAMUT
onDispose {
window?.colorMode = ActivityInfo.COLOR_MODE_DEFAULT
}
}Если не задать colorMode, окно останется в стандартном 8-битном sRGB, а все цвета широкой цветовой гаммы, отображаемые графическим процессором, будут обрезаны.
Поэтому важно не использовать широкую цветовую гамму, если дисплей ее не поддерживает.
Чтобы экономить заряд батареи и повысить производительность, включайте широкую цветовую гамму только для действий или путей выполнения, которые действительно отображают медиаконтент, фотографии или насыщенную графику.
Это означает, что вы можете создавать цвета в разных цветовых пространствах в 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. Популярные библиотеки загрузки изображений автоматически обрабатывают правильные цветовые пространства битовых карт.
Чтобы сохранить информацию о цвете, используйте Bitmap.Config.HARDWARE. При использовании конфигурации 565 точность будет потеряна, и пользователи увидят полосы.
Чтобы узнать больше о цветах, вы можете вернуться в 2017 год и посмотреть, как Ромен Гай рассказывает о цветах в Compose или о цветах в Android.
В Compose 1.13 мы представляем встроенную поддержку многоугольников.
Это значительно упростит создание элементов интерфейса произвольной формы и органичного вида.
Раньше, чтобы нарисовать сложную фигуру, нужно было использовать RoundedPolygon для описания геометрии, которая выведет путь, а затем сопоставить этот путь с API фигур 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)
)
}Например, сначала вы задаете пропорции с помощью 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 вы можете увидеть, что вам нужно создать путь, а затем вручную масштабировать и перевести его.
Теперь, с новым API PolygonShape, вам больше не нужна эта пользовательская оболочка 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 или даже в процентах.
После определения вы просто передаете его любому API, который принимает Shape.
Мы также представляем MorphPolygonShape API, который позволяет плавно переходить между различными формами многоугольников.
val recordShape = remember {
MorphPolygonShape(
start = PolygonShape.circle(),
end = PolygonShape.star(
numPoints = 9,
innerRadiusRatio = 0.8f,
outerRounding = CornerRounding(percent = 50),
),
) { shapeProgress.value }
}В этом примере у нас есть кнопка записи, которая превращается из круга в звезду, когда вы взаимодействуете с ней.
Вы просто указываете начальную форму, конечную форму и значение прогресса.
API MorphPolygonShape считывает значение прогресса на этапе разрешения контура, то есть не во время рекомпозиции, а во время композиции.
Это означает, что по мере того, как ваша фигура трансформируется между кадрами, она просто превращается в новый контур, не вызывая рекомпозиции.
Теперь поговорим о размытии.
В последних нескольких выпусках мы пытаемся сделать реализацию размытия намного проще и выразительнее.
Когда мы говорили о размытии, это был наиболее часто используемый модификатор.
Modifier
.blur(
radiusX = 10.dp,
radiusY = 10.dp,
edgeTreatment = BlurredEdgeTreatment(
shape = RoundedCornerShape(8.dp),
),
)В Compose этот модификатор доступен с Android 12.
Он отлично подходит, когда вам нужно равномерное размытие, при котором ко всей composable-функции применяется один и тот же радиус размытия.
Однако в современных интерфейсах часто требуется плавный переход от резких областей к размытым, а не резкая граница.
И наконец, Progressive Blur, который теперь является официальным API, начиная с Compose 1.13.
Вместо одного постоянного радиуса для всей composable-функции теперь можно изменять интенсивность размытия в пространстве.
Вот пример из нашего приложения JetChat с экрана профиля пользователя.
Новый модификатор размытия принимает спецификацию радиуса размытия.
Modifier.blur {
blurRadiusSpec = BlurRadiusSpec.verticalGradient(
listOf(
BlurStop(0.5f, 0.dp),
BlurStop(1.0f, 32.dp),
)
)
edgeTreatment = BlurredEdgeTreatment.Unbounded
}Здесь мы используем вертикальный градиент с двумя контрольными точками размытия, сохраняя верхнюю половину фотографии полностью резкой (0 dp) и увеличивая размытие до 32 dp у нижнего края.
Обратите внимание, что мы также задали для edgeTreatment значение unbounded. Это указывает на то, что размытие должно также захватывать прозрачные пиксели за пределами границ composable-функции.
Помимо вертикального затухания, эта спецификация радиуса размытия предоставляет вам несколько встроенных способов управления прогрессивным размытием.
Например, слева вы видите горизонтальный градиент, где начальный радиус равен 0 dp, а конечный радиус определяется значением ползунка.
BlurRadiusSpec.horizontalGradient(
startRadius = 0.dp,
endRadius = sliderValue.dp,
)А справа у нас радиальный градиент, где в центре радиус размытия равен 0 dp, а по мере продвижения к fallOffRadius, который в данном случае равен 300 dp, размытие плавно увеличивается до 24 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
},Ниже приведен небольшой фрагментный шейдер, который вычисляет анимированную диагональную синусоиду и выводит ее интенсивность в альфа-канал.
Затем спецификация шейдера сопоставляет эту маску от 0 до 1 непосредственно с маской 24 dp maxRadius.
Прежде чем мы поговорим о размытии фона, давайте посмотрим на разницу между ними.
Слева у нас есть верхняя панель приложения, к которой мы применили размытие слоя.
То есть он размывает саму компонуемую функцию.
Как видите, заголовок и значки действий размыты, а компонент чата за ними – нет.
Справа – размытие фона.
В этом случае размываются только сообщения чата, которые прокручиваются за верхней панелью.
Однако текст и значки полностью четкие и читаемые.
Начиная с Android 37.2, вы можете размывать фон с помощью нового API RenderNode.setBackdropRenderEffect.
Но прежде чем мы перейдем к улучшениям, давайте разберемся, как работает размытие в Android.
Android использует RenderNodes, которые представляют собой контейнеры списка отображения с аппаратным ускорением. Это означает, что они могут компактно хранить множество различных команд рисования между началом и концом записи.
В 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.
И это просто пример базовой технологии на Android с Compose.
Пример, который я вам показал, будет работать только на уровне API 37.2, но мы также работаем над бэкпортом для Compose, так что следите за обновлениями.
Ещё один интересный пример с фоновыми эффектами RenderNode заключается в том, что мы можем объединять их в цепочки, чтобы создавать действительно красивые пользовательские интерфейсы и выполнять множество различных эффектов поверх них.
Чтобы связать эффекты, мы создаем эти два эффекта.
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)Кнопка перехода вниз. Мы можем сделать ее похожей на маленькую лупу, объединив несколько разных эффектов, чтобы закруглить края этой composable-функции.
Но вы можете не только применять эффекты увеличения, но и объединять любые шейдеры в цепочки и применять их к фону.
Вот несколько интересных примеров того, что можно сделать, если объединить эти функции.
Теперь можно использовать традиционный эффект матового стекла.
Но мы также можем сделать другие забавные эффекты, например калейдоскоп.
Или, если мы хотим превратить этого талисмана Android в своего рода трансформирующийся элемент пользовательского интерфейса, который преобразует контент под ним, мы также можем применить эти эффекты рендеринга фона, чтобы создать впечатление, будто мы смотрим сквозь этого маленького дроида.
И мы также можем сделать это с помощью эффектов рендеринга фона.
Так что теперь вы можете не только размывать фон и создавать эффект матового стекла, но и делать все, что подскажет воображение.
Ещё один новый API, который мы добавили на платформу, – SurfaceView.setBlurRegions.
Сейчас вы можете размывать текстуры и любые composable-функции с помощью эффектов Modifier.blur и RenderNode Backdrop, но размыть части SurfaceView сложно, и это не часть дерева RenderNode представления приложения, поскольку на самом деле оно пробивает дыру в окне.
Поэтому Compose не может взять образец из резервного буфера SurfaceView, что было невозможно до API 37.2.
Поэтому setBlurRegions SurfaceView позволяет приложению напрямую объявлять области размытия.
Например, в этом видеопроигрывателе в JetChat мы добавили размытие SurfaceView, когда элементы управления появляются и скрываются.
Чтобы использовать новый API, вы можете получить доступ к методу setBlurRegions в 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()) // 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 или что-то в этом роде.
И это также можно применить к любой composable-функции, а не только к изображениям.
Спасибо!
Мы создали отличный эффект, но нужно убедиться, что приложение работает быстро и плавно.
Когда мы прокручиваем чат, все эффекты работают одновременно.
Давайте начнем с измерения этой прокрутки.
@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%'Получается шесть кадров с задержкой.
Давайте откроем один из поздних кадров.
Поэтому прямо перед этим кадром Compose предварительно выбирает следующее сообщение, прежде чем оно прокрутится вверх в представлении.
Предварительная выборка должна укладываться в короткий промежуток между кадрами, но подготовка этого сообщения также декодирует его изображение прямо в основной поток, который долго работает без уступки.
Поскольку основной поток занят декодированием этого изображения, новые кадры не могут быть запущены.
Затем, когда это сообщение фактически прокручивается в представлении, RenderThread должен выделить память и загрузить все эти пиксели в графический процессор, поэтому то же самое изображение задерживает второй кадр.
Теперь мы видим, на что ушло время, но нужно ли нам вообще выполнять всю эту работу, чтобы нарисовать этот кадр?
И как мы можем это определить?
Это можно узнать из трассировки.
Когда большая битовая карта отрисовывается в гораздо меньшей области на экране, Android регистрирует маркер прямо в трассировке.
Вы увидите два числа в пикселях: полный размер растрового изображения и область, которую оно занимает на экране.
Разделите это значение на плотность экрана, и вы получите значение в DP.
Затем мы ищем в коде это значение DP, чтобы найти изображение.
Вот что мы обнаружили.
Изображение загружается с помощью painterResource, который не знает, насколько велик контейнер, поэтому он декодирует все изображение в полном разрешении.
Image(
painter = painterResource(it),
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)Поэтому мы заменяем его на AsyncImage из катушки.
AsyncImage(
model = it,
contentScale = ContentScale.Fit,
modifier = Modifier.size(160.dp),
contentDescription = stringResource(id = R.string.attached_image),
)Сначала измеряется контейнер, поэтому декодируются только нужные пиксели, и эта работа выполняется в отдельном потоке.
При повторном запуске того же теста декодирование изображений в основном потоке упало до 0, а задержка кадра упала до 2.
Мы не будем отлаживать эти два кадра, но вывод прост: старайтесь не вычислять пиксели, которые не попадут в кадр.
Итак, декодирование изображений было достаточно большим, чтобы задерживать кадры, но не все затраты отображаются как пропущенные кадры, и все же есть меньшие затраты, которых мы можем избежать при написании кода.
Давайте рассмотрим два из них.
Каждый раз, когда мы используем RuntimeShader, мы должны создавать его один раз, потому что, хотя он только компилируется и кэшируется, он все равно анализирует свою исходную строку каждый раз, когда мы его создаем.
А когда эффект анимирует каждый кадр, мы можем обернуть его в graphicsLayer, чтобы перезаписывался только анимированный элемент, а не перерисовывался весь интерфейс вокруг него.
Кнопка "Перейти вниз" появляется при прокрутке.
Для этого эффекта используются два шейдера 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 поднимается по дереву, чтобы найти ближайший узел со слоем графики. В данном случае это красная рамка, которая представляет собой весь экран. Compose перезаписывает весь этот слой в каждом кадре.
Если поместить graphicsLayer прямо в поле, то перемещение остановится на зеленом контуре, поэтому будет перезаписано только поле свечения, а фрагмент отрисовки основного потока уменьшится на две десятых миллисекунды.
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 performance.
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