Liquid Glass в iOS 26: как адаптировать SwiftUI-приложение к новому дизайн-языку Apple#
Когда на WWDC 2025 показали Liquid Glass, первая реакция большинства iOS-разработчиков была не «как это работает», а «сколько экранов теперь придётся переделать». Это честный вопрос. Liquid Glass — не косметический рестайлинг вроде плоского дизайна iOS 7, а материал с собственной физикой: он преломляет свет, реагирует на движение устройства и на касание, умеет сливаться с соседними элементами и морфировать между состояниями. SwiftUI получил под это отдельный набор API, и если натянуть их на старый UI формально — приложение будет выглядеть чужеродно рядом с системными экранами.
Я адаптировал под Liquid Glass несколько своих приложений, включая MeteoHealth, и в этой статье собрал то, что реально пригодилось: актуальный API на 2026 год (с поправками iOS 26.1 и 26.2), типичные ошибки при переносе кастомного UI и то, что происходит с приложением, если пользователь включает Reduce Transparency.
Что такое Liquid Glass и почему это не просто ещё один blur#
До iOS 26 у нас были материалы — .ultraThinMaterial, .regularMaterial и другие Material-варианты — статичный блюр с настраиваемой прозрачностью. Liquid Glass — это не эволюция материалов, а отдельный слой системы, который:
- преломляет и отражает содержимое под собой, а не просто размывает его;
- реагирует на touch и указатель — при нажатии элемент немного «пружинит» и подсвечивается;
- сливается с соседними glass-элементами, когда они оказываются рядом, и морфирует между формами при изменении иерархии view;
- автоматически подстраивается под контент под собой — светлый фон даёт более контрастную обводку, тёмный — более прозрачную.
Ключевое архитектурное правило Apple: Liquid Glass — материал функционального слоя (навигация, тулбары, контролы, временные оверлеи), а не слоя контента. Карточка со статьёй, фото в галерее, список сообщений — это контент, и превращать его в стекло не нужно даже там, где технически возможно. Второе правило — не стеклить стекло: если элемент уже лежит на системной navigation bar или sheet, дополнительный glassEffect поверх создаёт мутную каша вместо ожидаемой глубины.
Новый API: glassEffect, Glass и порядок модификаторов#
Базовый вход в тему — модификатор .glassEffect(). Без параметров он оборачивает view в форму Capsule с вариантом .regular:
Text("Hello, World!")
.font(.title)
.padding()
.glassEffect()Форму можно задать явно (.rect(cornerRadius:), .circle, .capsule), а поведение материала настраивается через структуру Glass: вариант .regular — базовое стекло, .tint(Color) — добавляет цветовой акцент для более важных элементов, .interactive() — включает реакцию на нажатие с лёгким пружинящим эффектом:
struct WeatherSummaryCard: View {
let temperature: String
let condition: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(condition)
.font(.headline)
Text(temperature)
.font(.system(size: 34, weight: .semibold, design: .rounded))
}
.padding(20)
.frame(maxWidth: .infinity, alignment: .leading)
.modifier(AdaptiveGlassBackground(cornerRadius: 24))
}
}
/// Applies Liquid Glass on iOS 26+, falls back to Material on older systems.
/// Note the order: layout modifiers (padding, frame) come first,
/// the glass effect is applied last, on top of the finished layout.
struct AdaptiveGlassBackground: ViewModifier {
let cornerRadius: CGFloat
func body(content: Content) -> some View {
if #available(iOS 26.0, *) {
content.glassEffect(
.regular.interactive(),
in: .rect(cornerRadius: cornerRadius)
)
} else {
content.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
)
}
}
}Порядок модификаторов здесь принципиален. glassEffect должен идти после модификаторов, задающих layout и появление (padding, frame, font), а не до них — материал считает финальные границы view и должен видеть уже готовую компоновку. Поставить .glassEffect() перед .padding() — частая причина, почему у разработчиков «стекло обрезается не по той границе».
GlassEffectContainer, morphing и glassEffectID: динамические состояния#
Как только glass-элементов больше одного и они расположены рядом — например, панель быстрых действий — их нужно оборачивать в GlassEffectContainer. Контейнер задаёт общую область семплинга и позволяет соседним элементам визуально сливаться, а не просто накладываться:
enum QuickAction: String, CaseIterable, Identifiable {
case refresh, share, favorite
var id: String { rawValue }
var symbolName: String {
switch self {
case .refresh: return "arrow.clockwise"
case .share: return "square.and.arrow.up"
case .favorite: return "heart"
}
}
}
struct QuickActionsBar: View {
@State private var isExpanded = false
@Namespace private var glassNamespace
var body: some View {
GlassEffectContainer(spacing: 24) {
HStack(spacing: 24) {
Button {
withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) {
isExpanded.toggle()
}
} label: {
Image(systemName: isExpanded ? "xmark" : "plus")
.frame(width: 56, height: 56)
}
.buttonStyle(.glass)
.glassEffectID("toggle", in: glassNamespace)
if isExpanded {
ForEach(QuickAction.allCases) { action in
Button {
// handle action
} label: {
Image(systemName: action.symbolName)
.frame(width: 56, height: 56)
}
.buttonStyle(.glass)
.glassEffectID(action.id, in: glassNamespace)
.glassEffectUnion(id: "expanded", namespace: glassNamespace)
}
}
}
}
}
}Здесь важны две детали. Во-первых, spacing в GlassEffectContainer определяет, на каком расстоянии соседние элементы начинают визуально сливаться — меньше значение, ближе должны быть view. Во-вторых, glassEffectID в паре с @Namespace — это то, что превращает появление и исчезновение кнопок в плавный morphing, а не в резкий crossfade: без withAnimation вокруг изменения isExpanded эффект просто не запустится. glassEffectUnion дополнительно группирует элементы в единую glass-поверхность там, где они логически создаются вне общего HStack — например, в динамическом ForEach.
Fallback для iOS младше 26: два дизайн-языка в одном приложении#
Если у приложения есть пользователи на iOS младше 26 (а у большинства реальных продуктов они есть ещё год-два), фолбэк — не опция, а обязательная часть адаптации. Правильный паттерн — не создавать два параллельных дерева view, а обернуть решение в один модификатор с #available, как в примере с AdaptiveGlassBackground выше: .glassEffect(...) на iOS 26+ и .background(.ultraThinMaterial, in:) на более старых системах, с одной и той же формой (RoundedRectangle vs .rect(cornerRadius:)), чтобы геометрия карточки не «прыгала» между версиями iOS.
Частая ошибка на этом шаге — забыть, что у .glassEffect() есть параметр isEnabled, который удобнее, чем условный if #available внутри самого view: он позволяет держать один и тот же код view и переключать материал флагом, вычисленным один раз выше по дереву.
Типичные ошибки при адаптации кастомного UI#
По опыту переноса нескольких приложений, ошибки повторяются:
- Стекло на стекле. Кастомная карточка с
glassEffectвнутри системногоsheetилиNavigationStackс уже стеклянным тулбаром превращается в непрозрачное серое пятно — контраст и читаемость текста падают. - Glass на слое контента. Обложка статьи, фото профиля, фон плеера — это контент, а не функциональный элемент; попытка сделать «красиво» через стекло на контенте обычно делает интерфейс менее читаемым, а не более премиальным.
- Множество независимых glass-элементов без контейнера. Каждая иконка получает свой
glassEffect()безGlassEffectContainer— рендер дороже, а визуального слияния, ради которого всё затевалось, не происходит. - Забытый порядок модификаторов.
glassEffectдоpaddingилиframe— материал считает границы по недоделанному layout. - Игнорирование Reduce Transparency. Код, который жёстко проверяет
#available(iOS 26, *)и ничего больше, не учитывает, что часть пользователей сознательно снижает прозрачность интерфейса.
Доступность: Reduce Transparency, Tinted-режим и что нельзя игнорировать#
С iOS 26.1 Apple добавила пользователям возможность управлять Liquid Glass напрямую: тумблер Reduce Transparency в Accessibility → Display & Text Size усиливает непрозрачность материала, а в Settings → Display & Brightness → Liquid Glass появился переключатель между режимами «Clear» и «Tinted» — второй повышает контраст за счёт лёгкого затемнения. iOS 26.2 добавила ещё и слайдер прозрачности для часов на экране блокировки.
Для разработчика это значит, что не нужно вручную отключать glassEffect при accessibilityReduceTransparency — система сама увеличивает степень непрозрачности материала. Что действительно стоит сделать самостоятельно — учесть это значение окружения там, где вы включаете .interactive(), потому что пружинящая анимация — отдельный вопрос accessibility, не связанный напрямую с прозрачностью:
struct AccessibleGlassSurface<Content: View>: View {
@Environment(\.accessibilityReduceTransparency) private var reduceTransparency
let cornerRadius: CGFloat
@ViewBuilder var content: Content
var body: some View {
if #available(iOS 26.0, *) {
content
// Don't disable glassEffect manually when Reduce Transparency
// is on — iOS already increases frosting for the .regular
// variant. We only drop the extra .interactive() bounce,
// since motion is a separate accessibility concern.
.glassEffect(
reduceTransparency ? .regular : .regular.interactive(),
in: .rect(cornerRadius: cornerRadius)
)
} else {
content.background(
.ultraThinMaterial,
in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
)
}
}
}Тестируйте это не только вручную на устройстве, но и в превью: .environment(\.accessibilityReduceTransparency, true) в #Preview экономит массу времени по сравнению с постоянным переключением системных настроек.
API-матрица: что менять при адаптации#
| UI-элемент | До iOS 26 | iOS 26 (Liquid Glass) |
|---|---|---|
| Карточка / панель | .background(.ultraThinMaterial, in: RoundedRectangle(...)) | .glassEffect(.regular, in: .rect(cornerRadius:)) |
| Кнопка действия | Кастомный ZStack с ручным блюром и тенью | .buttonStyle(.glass) / .buttonStyle(.glassProminent) |
| Группа иконок рядом | Индивидуальный материал на каждой | GlassEffectContainer + glassEffectUnion |
| Появление/исчезновение элемента | .transition без учёта соседей | glassEffectID + @Namespace для морфинга |
| Toolbar / tab bar accessory | Кастомный фон, ручное разделение групп | Автоматическая glass-поверхность + ToolbarSpacer |
Как это применялось на практике: MeteoHealth и чек-лист адаптации#
В MeteoHealth стеклянными стали именно функциональные элементы: панель быстрых действий на главном экране, плавающие контролы графиков самочувствия и tab bar accessory с текущей погодной сводкой — всё, что находится «над» контентом, а не является им. Карточки с прогнозами и историей показателей остались на обычном фоне: тест на реальных пользователях быстро показал, что стекло поверх плотных данных снижает читаемость чисел, а не делает интерфейс приятнее.
Итоговый чек-лист перед релизом:
- Glass применяется только к функциональному слою — навигации, тулбарам, транзиентным контролам
- Нет случаев «стекло на стекле» (проверено на реальных sheet/NavigationStack)
- Соседние glass-элементы обёрнуты в
GlassEffectContainer -
glassEffectидёт после layout-модификаторов, а не до них - Есть
#available(iOS 26, *)fallback наMaterialс той же геометрией формы - Проверено поведение с
accessibilityReduceTransparencyв превью и на устройстве - Проверен режим Tinted в Settings → Display & Brightness → Liquid Glass
- Морфинг между состояниями обёрнут в
withAnimation
Liquid Glass — редкий случай, когда Apple выпустила не косметику, а материал с полноценной физикой и собственными правилами композиции. Формальный перенос старых Material-блюров один в один на glassEffect работает ровно до первого экрана, где рядом оказываются два стеклянных элемента или системный компонент — дальше придётся разбираться, какой слой у вас функциональный, а какой контентный, и именно это разделение, а не синтаксис API, определяет, будет ли адаптация выглядеть нативно.
Полезные ссылки:



