Для тех, кто в танке
Apple презентовали свой новый фирменный стиль. Liquid Glass - это новый материал... Красиво ли это? Спорно, конечно, а спорить я сейчас не хочу.

Всё новое - это забытое старое? Как бы да... но нет. Я прочёл десятки комментариев о том, что подобное уже было в прошивках китайских смартфонов, таких как Сяоми или чё там ещё у Китая. На самом деле то, что показали в бета версии iOS - не только не встречалось в Android нигде ранее, но и не появится в Android ближайшее время.
Если вы приглядитесь внимательно - этот стеклянный материал вообще не так прост, как это может показаться на первый взгляд. Это - не размытие. Мы видим искажения изображения под "стеклом", а на самом "стекле" видим отражения того, что находится рядом. Более того, вокруг "стекла" есть небольшой контур отражение света, который меняется в зависимости от положения устройства.

Ну ладно. Челлендж - повторить что-то подобное в Android. Не берусь говорить, что это получится так же хорошо. Да и что получится вообще хоть что-нибудь...
Анализ
Итак, попробуем проанализировать всё, что мы увидели.
Изображение... искажения... размытие... о чём вы подумали? Первое, что приходит в голову – ✨ ШЕЙДЕРЫ ✨
Окей, шейдеры. Ладно. А к чему их применять? Ну, очевидно же, к тому, что находится на экране? А нет, картинка же не статичная, на экране - не изображение. На экране - куча всего: контент, кнопки, текст, всё это движется и пользователь с этим всем взаимодействует...
А вообще на экране, обычно, находятся вьюхи. Ну и напишем какую-то свою вьюху.
К слову, я ни разу не писал шейдеров, не знаком с тем, как это работает. Так что если вам кажется, что я несу чушь - возможно я несу чушь.
Попробуем
Что она (вьюха) будет делать? Ну пускай она будет захватывать картинку под собой, применять какие-то искажения (то есть, применять шейдеры к изображению).
Так как у меня устройство с Android 15 - можно использовать AGSL шейдеры. Почитали документацию, пойдем дальше.
А как захватить то, что находится под вью? Ну я подумал, и решил - пускай у нашей вьюхи будет targetView - цель, к которой она будет применять эффекты. Так даже лучше - мы сможем не обновлять нашу вью постоянно, а будем использовать onPreDrawListener и обновлять нашу вью тогда, когда цель претерпевает изменения.
Ну давайте напишем сначала какую-то вью, которая будет рендерить targetView в bitmap и показывать его на себе.
Окей, targetView... значит пускай будет так:
fun setTargetView(view: View) {
targetView?.viewTreeObserver?.removeOnPreDrawListener(targetLayoutListener)
targetView = view
view.viewTreeObserver.addOnPreDrawListener(targetLayoutListener)
}
targetLayoutListener будет заниматься рендерингом всей той жести, которую мы придумаем. Но пока он будет заниматься просто отображением того, что происходит в targetView.
private val targetLayoutListener = ViewTreeObserver.OnPreDrawListener {
updateBitmap()
true
}
private fun updateBitmap() {
val view = targetView ?: return
val bmp = view.drawToBitmap(Bitmap.Config.ARGB_8888)
targetBitmap = bmp
val shader = runtimeShader ?: return
val bitmapShader = BitmapShader(
bmp,
Shader.TileMode.CLAMP,
Shader.TileMode.CLAMP
)
val targetPos = IntArray(2)
val selfPos = IntArray(2)
view.getLocationOnScreen(targetPos)
getLocationOnScreen(selfPos)
shader.setInputShader("iImage1", bitmapShader)
shader.setFloatUniform("iImageResolution", bmp.width.toFloat(), bmp.height.toFloat())
shader.setFloatUniform("iTargetViewPos", targetPos[0].toFloat(), targetPos[1].toFloat())
shader.setFloatUniform("iShaderViewPos", selfPos[0].toFloat(), selfPos[1].toFloat())
shaderPaint?.shader = shader
invalidate()
}
Ну и напишем простой шейдер, который будет отрисовывать то, что мы получили в bitmap:
private val shaderCode = """
uniform shader iImage1;
uniform float2 iTargetViewPos; // позиция targetView на экране
uniform float2 iShaderViewPos; // позиция ShaderView на экране
uniform float2 iImageResolution; // размер targetBitmap
half4 main(float2 fragCoord) {
float2 globalCoord = fragCoord + iShaderViewPos - iTargetViewPos;
float2 uv = globalCoord / iImageResolution;
return iImage1.eval(uv * iImageResolution); // либо просто iImage1.eval(globalCoord);
}
""".trimIndent()
Для наглядности расположим на экране ScrollView, а в нём - кучу разноцветных кнопок. Это и будет наш targetView. Нашу View расположим внутри cardview с elevation, чтобы тень отличала её от targetView.

Итак... вроде всё работает. На нашей вьюхе видно то, что находится под ней. Уже неплохо.
Стекло, стекло, стекло
Изображение, которое находится под вьюшкой, должны пройти ряд каких-то операций над ними. В итоге должно быть похоже (хотя бы отдалённо) на то, что бы вы увидели через линзу, смотря на него.
Думая насчёт линзы я пришёл к чему-то такому:

Мне кажется, что это должно быть похоже на то, что показала Apple. Свет проходит через линзу. Чем ближе луч к краю линзы, тем больше будет эффект искажения.
Итак... у нашей искусственной линзы должна быть толщина, а также степень кривизны.
Попробуем модифицировать шейдер... добавим функцию, чтобы применить эффект этой линзы:
float2 applyLensDistortion(float2 fragCoord, float2 center, float2 size, float cornerRadius, float curvature, float thickness) {
float2 delta = fragCoord - center;
float2 local = abs(delta) - size + cornerRadius;
float distToEdge = length(max(local, 0.0));
float inFactor = smoothstep(cornerRadius, cornerRadius * 0.01, distToEdge);
float2 normDelta = delta / size;
float len = length(normDelta);
float distortion = curvature * (1.0 - len * len*len);
float2 offset = normalize(delta) * distortion * thickness * (1.0 - inFactor);
return fragCoord + offset;
}
Я не силён в шейдерах. И вообще это мой первый шейдер. Поэтому - и так сойдёт.
Ну и можно добавить размытие, наверное. Я сделал его очень топорно. Можете посмеяться, я разрешаю:
half4 gaussianBlur(float2 uv, float2 resolution, float radius) {
if (radius <= 0.0) {
return iImage1.eval(uv * resolution);
}
half4 color = half4(0.0);
float totalWeight = 0.0;
float2 texelSize = radius / resolution;
float2 offset = float2(-2.0, -2.0) * texelSize;
float weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-1.0, -2.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(0.0, -2.0) * texelSize;
weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(1.0, -2.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(2.0, -2.0) * texelSize;
weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-2.0, -1.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-1.0, -1.0) * texelSize;
weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(0.0, -1.0) * texelSize;
weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(1.0, -1.0) * texelSize;
weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(2.0, -1.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-2.0, 0.0) * texelSize;
weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-1.0, 0.0) * texelSize;
weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
weight = 1.0;
color += iImage1.eval(uv * resolution) * weight;
totalWeight += weight;
offset = float2(1.0, 0.0) * texelSize;
weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(2.0, 0.0) * texelSize;
weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-2.0, 1.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-1.0, 1.0) * texelSize;
weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(0.0, 1.0) * texelSize;
weight = exp(-1.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(1.0, 1.0) * texelSize;
weight = exp(-2.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(2.0, 1.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-2.0, 2.0) * texelSize;
weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(-1.0, 2.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(0.0, 2.0) * texelSize;
weight = exp(-4.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(1.0, 2.0) * texelSize;
weight = exp(-5.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
offset = float2(2.0, 2.0) * texelSize;
weight = exp(-8.0 / (2.0 * radius * radius * 0.25));
color += iImage1.eval((uv + offset) * resolution) * weight;
totalWeight += weight;
return color / totalWeight;
}
Добавим юниформы, чтобы контролировать параметры линзы и размытия, а также обновим main функцию чтобы она не обиделась:
uniform shader iImage1;
uniform float2 iImageResolution;
uniform float2 iTargetViewPos;
uniform float2 iShaderViewPos;
uniform float2 iShaderResolution;
uniform float iCurvature;
uniform float iThickness;
uniform float iCornerRadius;
uniform float iBlurRadius;
half4 main(float2 fragCoord) {
float2 center = iShaderResolution * 0.5;
float2 lensSize = iShaderResolution * 0.48;
float2 distortedCoord = applyLensDistortion(
fragCoord, center, lensSize, iCornerRadius, iCurvature, iThickness
);
float2 uv = getUV(distortedCoord);
return gaussianBlur(uv, iImageResolution, iBlurRadius);
}
Мне лень показывать дальше. Да и нет смысла - там ничего интересного. Я ещё чуть-чуть модифицировал код View. Если коротко - эти параметры теперь можно задать в атрибутах в коде разметки XML.
Магия ✨
Ну и тут ниже покажу примеры того, что получилось в итоге. Играясь с параметрами линзы можно получить разные эффекты.



Всё хорошо? Ну... нет
Эти шейдеры влияют на производительность. Очень. Очень. Очень. Может быть косяк в моей реализации.
Этот материал нельзя применить ко всему подряд. Например, к AppBarLayout - точно нет. Он же LinearLayout. А если он не LinearLayout - прощай liftOnScroll.
Чтобы добавить к такому шейдеру поведение, зависящее от положение устройства в пространстве - нужно проделать много работы.
Итог и выводы
Я не знаю, как Эпл это сделали.
После проделанной мной работы я стал уважать Liquid Glass. Я не знаю, как это устроено у них, могу только предположить, что Liquid Glass - тоже шейдеры, только более продуманные. Система, которую Эпл выстраивали так долго, позволяет им это делать.
Android же пока в стороне. Большая надежда на китайцев, может быть у Xiaomi получится сделать что-то подобное, они как раз любят "заимствовать" всякие шутки у Эпл (но это и не плохо). Но в любом случае, пока не появится открытого опен-сурц решения для подобных стеклянных плюшек - ловить в стекломорфизме нечего.
Комментарии (22)
Metotron0
12.06.2025 20:35Большая надежда на китайцев, может быть у Xiaomi получится сделать что-то подобное
А какова цель? Предположим, что Apple это сделали, чтобы отличаться, а зачем за ними повторять, если отличаться уже не получится? Это как каверы играть.
Dertefter Автор
12.06.2025 20:35Я так понимаю, это риторический вопрос?)
За Apple так или иначе все повторяют. Фишки из iOS кочуют в MIUI. (Хотя иногда они кочуют и в обратном направлении)
Torvald3d
12.06.2025 20:35По сути всё делается проще:
блур уменьшенной текстурки фона ui элемента
-
нормал мапа бокс элемента - единственная, заранее заготовленная маленькая текстура, которая применяется ко всем ui элементам
типа такой
итоговый пиксель формируется смещением блура по нормал мапе
Такое должно работать не сильно медленнее старого размытия элементов интерфейса
Dertefter Автор
12.06.2025 20:35Очень интересно. Надо попробовать! Спасибо
ermouth
12.06.2025 20:35Тут уже CSS-ом и SVG-шным displacement map-ом этот эффект отсимулировали. Довольно грубо из-за зума и особенностей дисплейсмента (смещает только на целое значение), но вполне рабочий вариант. Просто копипаст html кода из гиста перед тегом </body> на любую страницу – и вуаля.
https://gist.github.com/rebane2001/8ba35ad6e1b17c4cb5b2b2431d9e992c
Может, чем-то вам пригодится.
SadOcean
12.06.2025 20:35Технически это не normal map, это displacement map. Используются только 2 канала и в целом может отличаться, просто для вот таких форм похожа
Or_Ganica
12.06.2025 20:35Зелёный, синий, красный. Почему два? И причем тут дисплейсмент, если он для прямого изменения геометрии нужен?
SadOcean
12.06.2025 20:35Моделировать честное поведение стекла в реальном мире нет нужды, для таких вещей используется displacement map текстура, с предрасчитанными искажениями. Она же неплохо тянется и масштабируется, что даёт возможность использовать уменьшенные текстуры.
Таким образом основной эффект - комбинация размытия для задника и сдвигов пикселей для элементов.
Блики могут быть сделаны немного по разному, надо смотреть на разные примеры, но возможно они сделаны через тот же дисплей момент, для вашего примера это вероятно.
Аберрации могут немного усложнить этот эффект.
Самое тяжёлое тут на самом деле блюр, потому что дисплейсмент это одна выборка, а размытие - чем больше выборок, тем качественнее, может быть и 3*3 и 15*15
Самая большая проблема тут скорее организация - нет проблемы сделать такой эффект для 2х слоев, задник и элементы, но может быть проблемы с множественными перекрытиями. Впрочем современное железо кушает и не такое.
По сути ничего технически сложного тут нет со времён win vista, когда MS поддержали лёгкий бобр для всех поверхностей и полупрозрачные окна (не факт что они были первые, но тут это было массово)
Что действительно хорошо у Эппл, так это холистический подход - они обновили весь дизайн и для системы, и для приложений, создаёт ощущение целостности. И у них есть много возможностей навязать целостный дизайн сторонним разработчикам. У большинства вендоров android с этим проблемы, да и зоопарк больше.
Поэтому мы конечно скоро увидим такие оболочки на android, но эффект будет не тот, конечно
Jijiki
12.06.2025 20:35блик это спекуляр мап - текстура (и центр вьюхи тоесть центра екрана смотрит внутрь тогда будут блики)) на скоко помню , я думаю в телефоне на рабочем столе нету смысла рендерить PBR), блюр в картинке не так сложен кстати как может казаться если в ios крутой чип то они могли уложиться векторкой и если отходить от случая счета векторкой на чипе в случае если он мощный, там могли быть какието статические переходные посчитанные данные, но именно посчитать блюр не накладно вроде, а ну вот спекуляр мап да это статик числа наверно
итого имеем 4 точки надо найти текущий центр(а не тот который уехал вниз/вверх) и ситуация что то что в вьюхе плывёт вниз вверх а центр остаётся, операция не нагрузная вроде, потомучто размытие будет по замапленым сдвигам чтоли
SadOcean
12.06.2025 20:35Спекулар мап описывает интенсивность и цвет блика на разных частях материала, когда это уместно, например, когда текстура содержит много материалов - одежда не блестит, пуговицы блестят золотым, а металл ремней - синеватым. Или например ржавое железо на технике - некоторые части бликуют, а некоторые - нет.
Если материал у вас одинаковый (в данном случае - гладкое стекло), то смысла в экстра текстуре нет, мощность блика описывается целиком для всей поверхности.
При этом сами блики такого рода должны быть ориентированы от какого то источника, который обычно расположен со стороны наблюдателя.
Здесь же больше похоже, как будто это вторичный цвет от иконок под ними, отраженный краем.
Для такого технологии классических бликов не подойдут, нужны множественные источники света, нужно что-то типа radial cascades.
Но так как эффект простой, с большой вероятностью он сделан на тех же displacement map - с ними можно сделать эффект, когда искажение захватывает объекты за пределами иконки, это не проблема.
Конечно возможно там более сложный эффект, но на примерах этого не видно.
Я бы сказал, что всего что видно можно достичь только комбинацией этих двух эффектов - смещение и размытие.
Остальное - лишь аккуратный и правильный дизайн.
Еще крутая фишка там - плавное перетекание из одной формы в другую.
Такое можно сделать через distance field или маски, и эти эффекты довольно сильно замороченные.
Если Apple сделала их действительно легкими для любых элементов, то это действительно интересно.
Но возможно они просто заморочились в нескольких местах стандартными методами и чуда там нет - ваши элементы сделать с такой анимацией легко не получится.
ArkadiyMak
Я свечку не держал, но думаю, что там всё сделано проще - предварительный гауссблюр скринкопии в мипчейн, а у стекляшек текстура, у которой в двух каналах DuDv и в третьем - степень размытия - мип скринкопии. И каждый канал семплится с разным скейлом оффсета для дисперсии. В GLSL это выглядело бы как-то так:
Metotron0
Почему вы не пишете просто по-английски?
Torvald3d
Потому что это общепринятый сленг в комп графике
Metotron0
Не рационально ли будет этому набору действий дать своё название? Всё равно это понятно только своим.
space2pacman
Одно дело "резервное копирование" заменить как "бэкап"
а другое замена "смещение" как "оффсет"
ArkadiyMak
Мне прилетел минус в карму за "плохое оформление, ошибки". Напишите хоть, что за ошибки.
ImagineTables
Плюс вам в карму за содержательный комментарий.
Dertefter Автор
А мне кто-то поставил минус на эту статью с причиной "Личная неприязнь к автору..." ^^
Забавно, но такого я не ожидал