Spec-Zone.ru › Kotlin 2

Liquid Glass в приложении Compose Multiplatform

Liquid Glass — это система визуального дизайна Apple, представленная в iOS 26. Она привносит полупрозрачность и плавность, напоминающие стекло, в элементы интерфейса. Чтобы использовать её в приложении Compose Multiplatform, вам понадобится оболочка на нативном SwiftUI, поскольку эффекты Liquid Glass отображаются системой с помощью нативных API TabView, NavigationStack и панелей инструментов. Если SwiftUI не подходит для вашего проекта, см. раздел Альтернативные подходы.

В этом руководстве показано, как перенести приложение iOS с навигации, полностью управляемой Compose, на нативную навигацию SwiftUI со стилем Liquid Glass из iOS 26, сохранив за Compose отрисовку содержимого каждого экрана. Система автоматически применяет эффекты Liquid Glass, когда приложение использует нативные представления TabView и NavigationStack, поэтому писать специальный код для Liquid Glass не нужно.

В качестве примера мы воспользуемся официальным приложением KotlinConf.

Вам понадобится Xcode 26 или более поздней версии с SDK для iOS 26.

  • ветка main — исходное состояние с полностью реализованной пользовательской темой на Compose.

  • ветка lg-nav — конечное состояние с дизайном Liquid Glass.

Shared UI Native iOS UI with Liquid Glass

Клонируйте репозиторий и переключитесь на любую из веток, чтобы следовать инструкциям, или сравните их: main...lg-nav.

Для простоты мы перенесём приложение с двумя вкладками (Расписание и Информация), но этот подход подходит для любого количества вкладок.

План миграции

В проекте Compose Multiplatform с полностью общим кодом пользовательского интерфейса единственный ComposeUIViewController отвечает за весь интерфейс iOS: вкладки, стек навигации, жесты возврата и содержимое экранов. Переходы навигации Compose Multiplatform на iOS разработаны так, чтобы выглядеть нативно, однако некоторые возможности платформы, например стиль панели вкладок Liquid Glass в iOS 26, доступны только через нативные компоненты iOS.

Решение состоит в том, чтобы передать нативной iOS управление панелью вкладок и стеком навигации, оставив Compose отрисовку содержимого каждого экрана.

До:

ContentView
  └── ComposeView (Compose Multiplatform)

После:

ContentView
  └── TabView  (Liquid Glass, iOS 26)
        ├── Tab: Schedule
        │     └── NavigationStack
        │           ├── NativeNavComposeView  ← Compose tab root
        │           └── DetailComposeView     ← Compose detail screen, one per destination
        └── Tab: Info
              └── NavigationStack
                    ├── NativeNavComposeView  ← Compose tab root
                    └── DetailComposeView     ← Compose detail screen, one per destination

В новой конфигурации навигация работает следующим образом:

  • SwiftUI создаёт TabView, содержащий NavigationStack для каждой вкладки.

  • Compose по-прежнему отображает содержимое каждого экрана, но больше не управляет стеком переходов.

  • Когда пользователь инициирует переход с экрана Compose (например, нажимает на строку списка), событие передаётся в Swift через onNavigate.

  • Координатор Swift добавляет маршрут в свой NavigationStack, который создаёт новый UIViewController с одним экраном Compose.

Миграция затрагивает как общий код Compose Multiplatform, так и нативный код iOS. В общем коде Kotlin:

  • Добавьте метаданные заголовка в маршруты, чтобы SwiftUI мог отображать заголовки панели навигации и элементы стека переходов без обращения к Kotlin.

  • Добавьте обратные вызовы навигации в точку входа iOS, чтобы слой iOS мог управлять активной вкладкой и реагировать на события навигации.

  • Перехватывайте навигацию на уровне Compose, чтобы передавать маршруты подробных экранов в Swift, а не обрабатывать их в Compose. В этом руководстве показана реализация с Navigation 3 — адаптируйте этот шаг, если используете другую библиотеку навигации.

  • Создайте автономный механизм отрисовки экранов для iOS, чтобы SwiftUI мог самостоятельно отображать любой маршрут подробного экрана вне полной App().

  • Скройте встроенный интерфейс навигации Compose, когда управление переходит к SwiftUI, чтобы пользователи не видели дублирующиеся панели заголовков и кнопки возврата.

  • Предоставьте новые точки входа iOS для создания корневого контроллера представления и отдельных контроллеров представлений экранов.

В нативном коде iOS (Swift):

  • Создайте слой навигации SwiftUI с нативными представлениями TabView и NavigationStack, а также мостами для встраивания экранов Compose.

Добавьте метаданные заголовка в маршруты

В iOS у каждого пункта назначения есть заголовок, который отображается на панели навигации, а также в стеке переходов, открываемом долгим нажатием кнопки возврата. Мы будем хранить заголовки непосредственно в объектах маршрутов, чтобы каждый маршрут содержал всю необходимую информацию, а Swift мог считывать заголовок без дополнительных запросов к Kotlin.

  1. В файле navigation/Routes.kt добавьте свойства title и subtitle в AppRoute:

    @Serializable
    sealed interface AppRoute {
        val title: String? get() = null
        val subtitle: String? get() = null
    }
    
  2. Переопределите title (и subtitle, если это полезно) для маршрутов, отображаемых как подробные экраны. Для маршрутов, которые уже содержат данные, добавьте это свойство как необязательный параметр:

    @Serializable
    data class SessionScreen(
        val sessionId: SessionId,
        override val title: String? = null,
    ) : AppRoute
    
  3. Маршрутам, которые были data object, также нужен заголовок, но data object не может хранить состояние заголовка для каждого экземпляра. Преобразуйте их в data class:

             data object SettingsScreen : AppRoute
         
             data class SettingsScreen(override val title: String = "") : AppRoute
         

    Полный список обновлённых определений маршрутов см. в Routes.kt.

  4. Передайте локализованный заголовок в месте вызова в файле NavHost.kt. Поскольку stringResource — это функция @Composable, получите её значение в области видимости элемента и сохраните его в обратном вызове нажатия, а не внутри самого обратного вызова:

    entry<InfoScreen> {
        val settingsTitle = stringResource(Res.string.settings_title)
        InfoScreen(
            onSettings = { navigator.add(SettingsScreen(settingsTitle)) },
            // ...
        )
    }
    

Добавьте обратные вызовы навигации в точку входа iOS

App() — это точка входа Kotlin, которую вызывает iOS. Чтобы Swift мог управлять навигацией, нужно предусмотреть три возможности:

  • Выбирать начальную вкладку при запуске приложения с помощью нового параметра topLevelRoute.

  • Реагировать на переходы, инициированные в Compose (например, при нажатии на элемент списка), с помощью обратного вызова onNavigate.

  • Реагировать на переключение вкладок, инициированное в Compose, с помощью обратного вызова onActivate.

Новые обратные вызовы необязательны, а их значение по умолчанию — null, поэтому они не влияют на целевые платформы Android, desktop и web.

Соответствующим образом обновите сигнатуру App() в файле App.kt:

@Composable
fun App(
    appGraph: AppGraph,
    topLevelRoute: TopLevelRoute,
    onThemeChange: ((isDarkTheme: Boolean) -> Unit)? = null,
    onNavigate: ((AppRoute) -> Unit)? = null,
    onActivate: ((TopLevelRoute) -> Unit)? = null,
) {
    // ...
    val startRoute: AppRoute = remember {
        if (isOnboardingComplete) topLevelRoute else StartPrivacyNoticeScreen
    }
    NavHost(startRoute, isDarkTheme, onThemeChange, onNavigate, onActivate)
}

Полную реализацию см. в App.kt.

Перехватывайте навигацию на уровне Compose

Теперь, когда App() предоставляет обратные вызовы навигации, NavHost должен их использовать. Каждый раз, когда подробный маршрут появляется в стеке переходов Compose, передавайте его в Swift и сразу же удаляйте из Compose. Таким образом, Compose будет отображать подробные экраны только по запросу Swift.

Нужно настроить два потока:

  • Переходы на подробные экраны → Swift. Каждый раз, когда в стек переходов попадает маршрут, отличный от корневого, передавайте его через onNavigate и удаляйте из стека Compose, чтобы NavigationStack в SwiftUI оставался единственным источником истины.

  • Переключение вкладок → Swift. Когда маршрут верхнего уровня меняется внутри Compose, уведомляйте Swift через onActivate, чтобы выбранная вкладка TabView в SwiftUI оставалась синхронизированной.

Этот шаг относится только к библиотеке Navigation 3. Такой же подход к перехвату можно применить с любой библиотекой навигации Compose, но конкретный API (доступ к стеку переходов, отслеживание текущего пункта назначения) будет отличаться.

Добавьте новые параметры и два эффекта перехвата в функцию NavHost() в файле navigation/NavHost.kt:

import androidx.compose.runtime.snapshotFlow

@Composable
internal fun NavHost(
    startRoute: AppRoute,
    isDarkTheme: Boolean,
    onThemeChange: ((Boolean) -> Unit)?,
    onNavigate: ((AppRoute) -> Unit)? = null,
    onActivate: ((TopLevelRoute) -> Unit)? = null,
) {
    // Forwards detail routes to Swift and removes them from Compose's stack
    if (onNavigate != null) {
        LaunchedEffect(navState) {
            snapshotFlow { navState.currentBackstack.toList() }.collect { backstack ->
                val detailRoutes = backstack.drop(1)
                if (detailRoutes.isNotEmpty()) {
                    detailRoutes.forEach { onNavigate(it) }
                    navState.currentBackstack.removeRange(1, navState.currentBackstack.size)
                }
            }
        }
    }
    // Notifies Swift when the user switches tabs from within Compose
    if (onActivate != null) {
        LaunchedEffect(navState) {
            snapshotFlow { navState.topLevelRoute }.collect { route ->
                if (route != null) onActivate(route)
            }
        }
    }
    // ...
}

Полный файл см. в NavHost.kt.

Создайте автономный механизм отрисовки экранов для iOS

Когда SwiftUI управляет NavigationStack, Compose нужно только отображать содержимое каждого экрана. NavHost предназначен для управления стеком переходов, переходами и жизненным циклом, поэтому нам нужна более простая точка входа для отображения одного маршрута.

Добавьте простой механизм отрисовки экранов

ScreenContent — это более простая точка входа: простое выражение when, которое сопоставляет один маршрут подробного экрана с его компонуемой функцией, не имея собственного состояния навигации. Корневые экраны вкладок по-прежнему обрабатываются полноценным App()/NavHost. SwiftUI создаёт отдельный контроллер представления для каждого пункта назначения, каждый из которых содержит единственный вызов ScreenContent.

Добавьте следующий код в navigation/NavHost.kt:

@Composable
fun ScreenContent(
    route: AppRoute,
    onNavigate: (AppRoute) -> Unit,
    onBack: () -> Unit,
    onSet: (AppRoute) -> Unit,
    onActivate: (TopLevelRoute) -> Unit,
) {
    val uriHandler = LocalUriHandler.current
    when (route) {
        is SessionScreen -> SessionScreen(
            sessionId = route.sessionId,
            onBack = onBack,
            onSpeaker = { speakerId -> onNavigate(SpeakerDetailScreen(speakerId)) },
            // ...
        )
        is SpeakerDetailScreen -> SpeakerDetailScreen(
            speakerId = route.speakerId,
            onBack = onBack,
            onSession = { sessionId -> onNavigate(SessionScreen(sessionId)) },
        )
        is SettingsScreen -> SettingsScreen(onBack = onBack)
        is AboutAppScreen -> AboutAppScreen(
            onBack = onBack,
            onLicenses = { onNavigate(LicensesScreen) },
            // ...
        )
        // All other detail routes
        else -> {}
    }
}

В этой функции заголовки не используются: они были добавлены в объекты маршрутов на шаге Добавьте метаданные заголовка в маршруты, поэтому сторона Swift может считывать их непосредственно из каждого маршрута при настройке панели навигации.

Сообщите Compose, что навигацией управляет SwiftUI

ScreenContent выполняется в контексте, где SwiftUI отображает панель навигации и кнопку возврата. Экраны Compose, которые самостоятельно отрисовывают панели заголовков или кнопки возврата, должны их скрывать.

Чтобы избежать дублирования в дереве композиции, используйте CompositionLocal, которое может считываться каждым экраном без зависимости от кода, специфичного для iOS.

Объявите LocalUseNativeNavigation как CompositionLocal в файле NavHost.kt перед функцией NavHost():

val LocalUseNativeNavigation = staticCompositionLocalOf { false }

Оберните механизм отрисовки для iOS

ScreenContent отображает маршрут, но ему нужна обёртка, которая задаёт ту же тему, внедрение зависимостей и общие для приложения значения CompositionLocal, которые обычно настраивает App().

Добавьте обёртку SingleScreenApp. Она повторяет настройку из App() и дополнительно задаёт LocalUseNativeNavigation значение true, благодаря чему на каждом экране автоматически скрываются заголовок и кнопка возврата, отображаемые Compose.

Создайте файл SingleScreenApp.kt в исходном наборе iosMain:

@Composable
internal fun SingleScreenApp(
    appGraph: AppGraph,
    route: AppRoute,
    onNavigate: (AppRoute) -> Unit,
    onGoBack: () -> Unit,
    onSet: (AppRoute) -> Unit,
    onActivate: (TopLevelRoute) -> Unit,
) {
    // Sets theme and flags
    CompositionLocalProvider(
        LocalUseNativeNavigation provides true,
        LocalFlags provides flags,
        LocalAppGraph provides appGraph,
        // Other providers
    ) {
        KotlinConfTheme(colors = colors) {
            Box(Modifier.fillMaxSize().background(KotlinConfTheme.colors.mainBackground)) {
                ScreenContent(route, onNavigate, onGoBack, onSet, onActivate)
            }
        }
    }
}

Примените флаг к корневым экранам вкладок

Корневые экраны вкладок по-прежнему проходят через обычный NavHost, поэтому они также должны учитывать значение LocalUseNativeNavigation. Передавайте его в зависимости от того, активны ли нативные обратные вызовы навигации. Если они активны, отображайте содержимое навигации напрямую и пропускайте NavScaffold (нижнюю панель Compose):

val useNativeNavigation = onNavigate != null

CompositionLocalProvider(LocalUseNativeNavigation provides useNativeNavigation) {
    Box(
        // ...
    ) {
        val content = @Composable {
            NavDisplay(
                entries = navState.toDecoratedEntries(entryProvider),
                onBack = navigator::goBack,
            )
        }
        if (useNativeNavigation) {
            content()
        } else {
            NavScaffold(
                navState = navState,
                navigator = navigator,
                showGoldenKodee = showGoldenKodee,
                content = content,
            )
        }
    }
}

Полные реализации см. в NavHost.kt и SingleScreenApp.kt.

Скройте встроенный интерфейс навигации Compose

После установки LocalUseNativeNavigation везде, где SwiftUI отображает интерфейс навигации, отдельные экраны должны считывать его и скрывать собственные панели заголовков и кнопки возврата. Иначе пользователь увидит две расположенные друг над другом панели заголовков и две конкурирующие кнопки возврата.

В файле BaseScreens.kt обновите функцию ScreenWithTitle() так, чтобы она считывала LocalUseNativeNavigation и скрывала панель заголовка и её разделитель, когда значение равно true:

val useNativeNavigation = LocalUseNativeNavigation.current

if (!useNativeNavigation) {
    MainHeaderTitleBar(...)
    HorizontalDivider(...)
}

Примените тот же подход к любым другим экранам, которые самостоятельно отображают кнопки возврата или заголовки.

Полную реализацию см. в BaseScreens.kt.

Предоставьте новые точки входа iOS

Чтобы создать новую структуру навигации в SwiftUI, предоставьте три точки входа Kotlin: две перегруженные версии MainViewController и одну ScreenViewController. Добавьте три функции в iosMain/main.ios.kt:

  • MainViewController без обратных вызовов, используемая как резервный вариант для версий до iOS 26. API Liquid Glass требуют iOS 26, поэтому в более старых версиях SwiftUI должен переключаться на исходную конфигурацию, полностью основанную на Compose. Без этой перегрузки ветка #available в Swift не скомпилируется.

    // Pre-iOS 26 fallback: full Compose navigation, no native callbacks
    @Suppress("unused")
    fun MainViewController(topLevelRoute: TopLevelRoute): UIViewController = ComposeUIViewController(
        configure = { onFocusBehavior = OnFocusBehavior.DoNothing },
    ) {
        App(appGraph, topLevelRoute)
    }
    
  • MainViewController с обратными вызовами, вызываемая SwiftUI для корневого экрана каждой вкладки. Compose запускает полноценные App() и NavHost, но события навигации передаются в SwiftUI, а не обрабатываются внутри Compose. Сигнатура включает onGoBack и onSet для симметрии API с ScreenViewController, хотя в этой перегрузке они не используются.

    // Tab root: Compose runs NavHost but forwards navigation events to SwiftUI
    @Suppress("unused")
    fun MainViewController(
        topLevelRoute: TopLevelRoute,
        onNavigate: (AppRoute) -> Unit,
        onGoBack: () -> Unit,
        onSet: (AppRoute) -> Unit,
        onActivate: (TopLevelRoute) -> Unit,
    ): UIViewController = ComposeUIViewController(
        configure = { onFocusBehavior = OnFocusBehavior.DoNothing }
    ) {
        App(appGraph, topLevelRoute, onNavigate = onNavigate, onActivate = onActivate)
    }
    
  • ScreenViewController, вызываемая SwiftUI для каждого подробного экрана. Отображает один маршрут через SingleScreenApp, который задаёт LocalUseNativeNavigation значение true, скрывая встроенные панели заголовков и кнопки возврата Compose.

    // Detail screen: renders a single screen with LocalUseNativeNavigation = true
    @Suppress("unused")
    fun ScreenViewController(
        route: AppRoute,
        onNavigate: (AppRoute) -> Unit,
        onGoBack: () -> Unit,
        onSet: (AppRoute) -> Unit,
        onActivate: (TopLevelRoute) -> Unit,
    ): UIViewController = ComposeUIViewController(
        configure = { onFocusBehavior = OnFocusBehavior.DoNothing }
    ) {
        SingleScreenApp(appGraph, route, onNavigate, onGoBack, onSet, onActivate)
    }
    

Полную реализацию см. в main.ios.kt.

Альтернатива: откажитесь от SwiftUI и управляйте UIKit из Kotlin

Приведённые выше точки входа предназначены для TabView и NavigationStack в SwiftUI. Внутри SwiftUI использует UITabBarController и UINavigationController для реализации этих представлений, а Liquid Glass в iOS 26 применяется к нативным панелям вкладок и навигации независимо от того, объявлены ли они в SwiftUI или настроены в UIKit.

Если вы создаёте точку входа iOS с нуля или у вас ещё нет приложения SwiftUI App и ContentView, которые нужно сохранить, можно полностью отказаться от слоя SwiftUI и управлять UIKit напрямую из Kotlin. Такой подход позволяет избежать обходных решений для идентичности RouteWrapper, состояния пути @Observable, синхронизируемого с помощью обратных вызовов Kotlin, и обёрток UIViewControllerRepresentable, необходимых для оболочки SwiftUI в этом руководстве. Состояние навигации хранится в одном месте, а не дублируется в Swift и Kotlin.

Чтобы получить нативные панели навигации и общее содержимое экранов Compose, объявите контракты навигации в commonMain как координаторы expect class с реализациями actual для каждой платформы.

Оболочка SwiftUI в этом руководстве декларативна: вы описываете TabView и NavigationStack, а SwiftUI управляет стеком. Подход с координаторами UIKit императивен: вы управляете UINavigationController и самостоятельно вызываете push и pop.

SceneDelegate
  └── UIWindow.rootViewController = UITabBarController
        ├── UINavigationController (Schedule)
        │     ├── ComposeUIViewController  ← Tab root
        │     └── ComposeUIViewController  ← Detail, pushed in Kotlin
        └── UINavigationController (Info)
              └── ...

Элементы SwiftUI из этого руководства сопоставляются с координаторами UIKit следующим образом:

  • SwiftUI TabView и NavigationStack → UITabBarController и UINavigationController

  • TabNavigationCoordinator и RouteWrapper → координатор Kotlin actual: push и pop

  • NativeNavComposeView и DetailComposeView → ComposeUIViewController { Screen(...) } внутри координатора

  • Оболочка SwiftUI ContentView → SceneDelegate задаёт window.rootViewController

// commonMain
expect class ScheduleCoordinator() {
    fun navigateToDetail(route: AppRoute)
    fun navigateBack()
    @Composable fun Content()
}

Реализация actual в iosMain вызывает push и pop для UINavigationController и задаёт showTopBar значение false. В iOS создайте координатор из SceneDelegate и задайте контроллер вкладок в качестве корневого контроллера представления окна. Вам не понадобятся обёртки SwiftUI App или ContentView.

Разделить работу между Kotlin и Swift можно двумя способами:

  • Всё в Kotlin iosMain: соедините UITabBarController, UINavigationController и ComposeUIViewController в координаторах actual с помощью взаимодействия с UIKit.

  • Фабрики экранов в Kotlin, сборка в Swift: предоставьте MainViewController и ScreenViewController из iosMain, возвращающие экземпляры ComposeUIViewController с замыканиями навигации, и соберите панель вкладок и стеки навигации в нативном Swift. Этот вариант хорошо подходит, если код навигации iOS должен оставаться в Swift, а содержимое экранов — общим для Compose.

Оба варианта позволяют получить одинаковую плоскую иерархию UIKit без SwiftUI или UIViewControllerRepresentable. Выберите тот, который лучше соответствует предпочтениям команды относительно места размещения логики координатора.

Подробнее об использовании Compose внутри UITabBarController см. в разделе Интеграция с фреймворком UIKit.

Создание слоя навигации SwiftUI

Это часть миграции, относящаяся к iOS. Все изменения в Kotlin, внесённые на предыдущих этапах, готовят приложение к тому, что происходит здесь: SwiftUI TabView с отдельными NavigationStacks для каждой вкладки, в которых представлены Compose-представления, служащие пунктами назначения. Чтобы реализовать это, выполните следующие действия:

  1. Подготовьте маршруты Kotlin для использования в NavigationStack

  2. Отслеживайте состояние вкладок и навигации

  3. Встраивайте экраны Compose в представления SwiftUI

  4. Настройте навигацию в каждой вкладке

  5. Создайте панель вкладок

  6. Добавьте поддержку старых версий iOS

Обратите внимание: ни один фрагмент кода в этом разделе не применяет эффекты Liquid Glass напрямую. В iOS 26 эффекты Liquid Glass применяются автоматически к нативным представлениям TabView и NavigationStack, поэтому достаточно использовать их.

Подготовьте маршруты Kotlin для использования в NavigationStack

Для NavigationStack требуется, чтобы элементы пути соответствовали протоколам Hashable и Identifiable. Чтобы выполнить это требование для запечатанного интерфейса Kotlin, оберните AppRoute в Swift struct. Добавьте следующий код в файл ContentView.swift:

@available(iOS 26.0, *)
struct RouteWrapper: Hashable, Identifiable {
    let id = UUID()
    let route: AppRoute

    static func ==(lhs: RouteWrapper, rhs: RouteWrapper) -> Bool {
        lhs.id == rhs.id
    }

    func hash(into hasher: inout Hasher) {
        hasher.combine(id)
    }
}

Повторное добавление одного и того же маршрута должно создавать две отдельные записи в стеке, как и предполагает ожидаемое поведение навигации. Для этого в качестве идентификатора используется UUID, а не значение маршрута.

Отслеживайте состояние вкладок и навигации

У каждой вкладки свой стек навигации, а приложение отслеживает, какая вкладка выбрана в данный момент. Добавьте два класса @Observable для управления этим состоянием:

@available(iOS 26.0, *) @Observable class TabNavigationCoordinator { var path: [RouteWrapper] = [] func push(_ route: AppRoute) { path.append(RouteWrapper(route: route)) } func pop() { if !path.isEmpty { path.removeLast() } } func popToRoot() { path.removeAll() } }
@available(iOS 26.0, *) @Observable class AppNavigationCoordinator { enum AppTab { case schedule, info } var selectedTab: AppTab = .schedule let scheduleCoordinator = TabNavigationCoordinator() let infoCoordinator = TabNavigationCoordinator() func activateTab(for route: TopLevelRoute) { if route is ScheduleScreen { selectedTab = .schedule } else if route is InfoScreen { selectedTab = .info } } }

В этом руководстве AppNavigationCoordinator упрощён для приложения с двумя вкладками. Полную версию см. в файле ContentView.swift.

Встраивайте экраны Compose в представления SwiftUI

Два типа UIViewControllerRepresentable связывают точки входа Kotlin из шага Предоставление новых точек входа для iOS со SwiftUI: один для корневых экранов вкладок, другой — для экранов с подробной информацией.

NativeNavComposeView размещает корневой экран вкладки (NavHost в Compose) и передаёт события навигации:

@available(iOS 26.0, *)
struct NativeNavComposeView: UIViewControllerRepresentable {
    let topLevelRoute: TopLevelRoute
    let coordinator: TabNavigationCoordinator
    let appCoordinator: AppNavigationCoordinator

    func makeUIViewController(context: Context) -> UIViewController {
        return Main_iosKt.MainViewController(
            topLevelRoute: topLevelRoute,
            onNavigate: { route in self.coordinator.push(route) },
            onGoBack: { self.coordinator.pop() },
            onSet: { route in
                self.coordinator.popToRoot()
                if let topLevel = route as? TopLevelRoute {
                    self.appCoordinator.activateTab(for: topLevel)
                } else {
                    self.coordinator.push(route)
                }
            },
            onActivate: { route in self.appCoordinator.activateTab(for: route) }
        )
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    }
}

DetailComposeView размещает один экран с подробной информацией — по одному экземпляру для каждого пункта назначения NavigationStack:

@available(iOS 26.0, *)
struct DetailComposeView: UIViewControllerRepresentable {
    let route: AppRoute
    let coordinator: TabNavigationCoordinator
    let appCoordinator: AppNavigationCoordinator

    func makeUIViewController(context: Context) -> UIViewController {
        return Main_iosKt.ScreenViewController(
            route: route,
            onNavigate: { newRoute in self.coordinator.push(newRoute) },
            onGoBack: { self.coordinator.pop() },
            onSet: { route in
                self.coordinator.popToRoot()
                if let topLevel = route as? TopLevelRoute {
                    self.appCoordinator.activateTab(for: topLevel)
                } else {
                    self.coordinator.push(route)
                }
            },
            onActivate: { route in self.appCoordinator.activateTab(for: route) }
        )
    }

    func updateUIViewController(_ uiViewController: UIViewController, context: Context) {
    }
}

Настройте навигацию в каждой вкладке

На уровне вкладки NavigationStack использует содержимое вкладки Compose в качестве корневого экрана и отображает экраны с подробной информацией как пункты назначения.

Обратите внимание: .navigationTitle(title) необходимо задать для корневого экрана вкладки, даже если также применяется .navigationBarHidden(true). В iOS 26 это значение используется для отображения названия вкладки на плавающей панели вкладок. Если значение не задано, название будет пустым.

@available(iOS 26.0, *)
struct TabContentView: View {
    let topLevelRoute: TopLevelRoute
    let coordinator: TabNavigationCoordinator
    let appCoordinator: AppNavigationCoordinator
    let title: String

    var body: some View {
        NavigationStack(path: Binding(
            get: { coordinator.path },
            set: { coordinator.path = $0 }
        )) {
            NativeNavComposeView(
                topLevelRoute: topLevelRoute,
                coordinator: coordinator,
                appCoordinator: appCoordinator
            )
                .ignoresSafeArea(.all)
                .navigationTitle(title)
                .navigationBarHidden(true)
                .navigationDestination(for: RouteWrapper.self) { wrapper in
                    DetailComposeView(
                        route: wrapper.route,
                        coordinator: coordinator,
                        appCoordinator: appCoordinator
                    )
                        .ignoresSafeArea(.all)
                        .navigationTitle(wrapper.route.title ?? "")
                        .navigationSubtitle(wrapper.route.subtitle ?? "")
                        .toolbarTitleDisplayMode(.inline)
                }
        }
    }
}

Создайте панель вкладок

Контейнер верхнего уровня — это TabView с одним Tab для каждого маршрута верхнего уровня. Модификатор .tabBarMinimizeBehavior(.automatic) делает панель вкладок плавающей и сворачивает её при прокрутке. Без него панель вкладок остаётся закреплённой внизу. Модификатор .tint(Color(.accent)) применяет цветовой акцент приложения к выбранным вкладкам.

@available(iOS 26.0, *)
struct NativeNavContentView: View {
    @State private var appCoordinator = AppNavigationCoordinator()

    var body: some View {
        TabView(selection: Binding(
            get: { appCoordinator.selectedTab },
            set: { appCoordinator.selectedTab = $0 }
        )) {
            Tab(String(localized: "Schedule"), systemImage: "clock",
                value: AppNavigationCoordinator.AppTab.schedule) {
                TabContentView(topLevelRoute: ScheduleScreen(),
                               coordinator: appCoordinator.scheduleCoordinator,
                               appCoordinator: appCoordinator, title: String(localized: "Schedule"))
            }
            Tab(String(localized: "Info"), systemImage: "info.circle",
                value: AppNavigationCoordinator.AppTab.info) {
                TabContentView(topLevelRoute: InfoScreen(),
                               coordinator: appCoordinator.infoCoordinator,
                               appCoordinator: appCoordinator, title: String(localized: "Info"))
            }
        }
        .tabBarMinimizeBehavior(.automatic) 
        .tint(Color(.accent))
    }
}

Color(.accent) соответствует ресурсу AccentColor в каталоге ресурсов проекта Xcode. Определить его можно в редакторе каталога ресурсов Xcode (см. раздел Задание цветовой схемы приложения) или создав Assets.xcassets/AccentColor.colorset/Contents.json. Если вы выбрали вариант с JSON, возьмите за основу файл Contents.json из примера проекта и замените значения компонентов своими цветами.

Приложение с двумя вкладками выглядит следующим образом:

Two tabs

Полупрозрачность, глубину и плавающую панель вкладок обеспечивает iOS 26 — дополнительный код для оформления не требуется.

Добавьте поддержку старых версий iOS

Liquid Glass и новые API TabView доступны только в iOS 26. В более старых версиях приложение возвращается к прежней конфигурации, управляемой Compose. ComposeView — это обёртка SwiftUI для перегрузки MainViewController без обратного вызова:

struct ContentView: View {
    var body: some View {
        if #available(iOS 26.0, *) {
            NativeNavContentView()
        } else {
            ComposeView(topLevelRoute: ScheduleScreen())
                .ignoresSafeArea(.all)
        }
    }
}

Полный файл см. здесь: ContentView.swift.

Альтернативные подходы

В этом руководстве для миграции используется нативная навигация SwiftUI, благодаря которой Liquid Glass и другие системные возможности доступны сразу. Если этот подход не подходит вашему проекту, рассмотрите следующие альтернативы:

  • Навигация UIKit под управлением Kotlin. Объявите контракты навигации в commonMain как координаторы expect class, а для каждой платформы реализуйте actual, которые императивно управляют UITabBarController и UINavigationController. Так вы получите Liquid Glass и единый источник состояния навигации без оболочки SwiftUI, но вам придётся написать код взаимодействия с UIKit. Подробности см. в разделе Откажитесь от SwiftUI и управляйте UIKit из Kotlin.

  • Навигация под управлением Compose с нативными элементами управления. Оставьте навигацию в Compose, но встраивайте нативные элементы интерфейса, например UITabBar и UINavigationBar, в том числе со стилем Liquid Glass. Недостаток этого подхода — некоторые ограничения взаимодействия между нативными наложениями и содержимым Compose.

  • Навигация под управлением Compose со сторонними решениями для адаптивного интерфейса. Используйте библиотеки, например Calf, чтобы отображать адаптивные компоненты интерфейса, нативные для платформы, на которой работает приложение. Такой подход снижает сложность самостоятельной обработки различий между платформами и сразу предоставляет нативные возможности, например Liquid Glass в iOS.

  • Навигация только в Compose с имитацией эффектов Liquid Glass. Создавайте весь интерфейс в Compose и визуально имитируйте Liquid Glass, например с помощью библиотек AndroidLiquidGlass или Liquid. При таком подходе весь интерфейс остаётся на стороне Compose, а эффект визуально похож на системный Liquid Glass, хотя и не идентичен ему.

Что дальше

  • Посмотрите официальное приложение KotlinConf с применённым эффектом Liquid Glass.

  • Ознакомьтесь с разделом Внедрение Liquid Glass — обзором Apple нового материала и контрольным списком по его внедрению.

  • Официальные рекомендации по использованию Compose Multiplatform внутри SwiftUI и встраиванию SwiftUI в приложение Compose Multiplatform см. в разделе Интеграция с фреймворком SwiftUI.

21 июля 2026 г.
Интеграция с фреймворком UIKitОбработка событий касания с помощью взаимодействия на iOS

© 2010–2026 JetBrains s.r.o. and Kotlin Programming Language contributors
Licensed under the Apache License, Version 2.0.
https://kotlinlang.org/docs/multiplatform/ios-liquid-glass.html

Spec-Zone.ru

Настройки Оффлайн Что нового Помощь О нас
Spec-Zone .ru
спецификации, руководства, описания, API