iOS 26 的 Liquid Glass 对跨平台 UI 有一个很现实的问题:Compose Multiplatform 能画出页面内容,但系统级的导航栏、Tab、Toolbar 玻璃效果,最好还是交给 iOS 自己渲染。
JetBrains 给了一套接入方式:iOS 侧用 SwiftUI 管导航外壳,Compose 继续渲染每个页面的内容。这样做不需要把 KMP 页面全部改成 SwiftUI,也不用在 Compose 里模拟一套玻璃材质。

核心思路
Compose Multiplatform 的 iOS 页面通常通过 ComposeUIViewController 接到 UIKit 或 SwiftUI 里。以前很多项目会直接把一个完整 Compose 根页面塞进去,导航和页面状态都在 Kotlin 侧处理。这样写在 Android 和 Desktop 上很自然,但到了 iOS 26,如果导航栏和 Tab 都由 Compose 自己画,就拿不到系统渲染的 Liquid Glass 效果。
新的拆法像下面这样:
bash
SwiftUI App
└─ TabView / NavigationStack / toolbar
└─ ComposeUIViewController
└─ @Composable Screen Content
SwiftUI 负责"壳",Compose 负责"内容"。Kotlin 侧不需要知道 Liquid Glass 的具体材质怎么画,iOS 侧也不需要重写业务 UI。这个边界分清楚以后,迁移成本会低很多。

暴露 Compose 页面
Kotlin 侧先把每个页面做成可以被 iOS 调用的入口。核心还是 ComposeUIViewController,只是不要把整个 App 的导航都包进去,而是按页面暴露。
一个最小写法如下:
bash
import androidx.compose.ui.window.ComposeUIViewController
import platform.UIKit.UIViewController
fun HomeViewController(): UIViewController =
ComposeUIViewController {
HomeScreen()
}
fun SettingsViewController(): UIViewController =
ComposeUIViewController {
SettingsScreen()
}
这里的 HomeScreen()、SettingsScreen() 仍然是普通的 Compose 页面。状态、列表、按钮、表单、网络加载逻辑都可以继续放在 KMP 共享层里。变化在入口粒度:原来可能只暴露一个 App(),现在要把 iOS 需要放进导航结构里的页面拆出来。
这个拆法对已有项目有一个要求:页面里的导航动作不能完全依赖 Kotlin 侧的全局导航器。比如 Compose 页面点击"设置"时,如果 iOS 外层已经有 NavigationStack,更合适的做法是把点击事件抛给 SwiftUI,让 SwiftUI 去 push 下一个页面。
bash
@Composable
fun HomeScreen(
onOpenSettings: () -> Unit
) {
Button(onClick = onOpenSettings) {
Text("Settings")
}
}
这样写以后,页面内容还是共享的,但 iOS 的页面栈由 SwiftUI 管。Android 侧可以继续传入自己的导航实现,不需要被 iOS 的结构牵着走。
SwiftUI 容器
iOS 侧要做的事情,是把 Kotlin 暴露出来的 UIViewController 包成 SwiftUI 可以使用的 View。通常会写一个 UIViewControllerRepresentable。
bash
import SwiftUI
import shared
struct ComposeViewControllerHost: UIViewControllerRepresentable {
let makeController: () -> UIViewController
func makeUIViewController(context: Context) -> UIViewController {
makeController()
}
func updateUIViewController(
_ uiViewController: UIViewController,
context: Context
) {
}
}
然后把它放进 NavigationStack:
bash
struct HomeRootView: View {
@State private var path = NavigationPath()
var body: some View {
NavigationStack(path: $path) {
ComposeViewControllerHost {
SharedKt.HomeViewController()
}
.navigationTitle("Home")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Edit") {
}
}
}
}
}
}
这段代码的重点不是 Button("Edit") 本身,而是 navigationTitle 和 toolbar 由 SwiftUI 提供。到了 iOS 26,这些系统组件才有机会渲染成 Liquid Glass 的样子。Compose 页面只占据内容区域,不再自己画一套导航栏背景。
如果页面有 Tab,外层继续使用 TabView:
bash
struct MainView: View {
var body: some View {
TabView {
HomeRootView()
.tabItem {
Label("Home", systemImage: "house")
}
ComposeViewControllerHost {
SharedKt.SettingsViewController()
}
.tabItem {
Label("Settings", systemImage: "gearshape")
}
}
}
}
TabView 也交给系统以后,底部 Tab 的材质、点击反馈、和 iOS 26 的视觉层级能保持一致。Compose 里不要再额外画一个固定底栏,否则会出现两套导航结构叠在一起的问题。

事件怎么传
页面拆开以后,最容易卡住的是事件传递。Compose 页面里有点击、返回、打开详情、弹 sheet 这些动作,SwiftUI 外层也有自己的 navigation path 和 toolbar 状态。两边需要有一个清楚的通信方式。
比较直接的写法,是 Kotlin 页面把事件做成回调参数:
bash
fun HomeViewController(
onOpenDetail: (String) -> Unit
): UIViewController =
ComposeUIViewController {
HomeScreen(
onOpenDetail = onOpenDetail
)
}
Swift 侧传入闭包:
bash
ComposeViewControllerHost {
SharedKt.HomeViewController { id in
path.append(Route.detail(id: id))
}
}
这个写法适合轻量页面。事件数量多了以后,可以把回调收敛成一个 action 类型,或者让 Kotlin 侧暴露 ViewModel 状态,SwiftUI 只处理导航类事件。关键是不要让 Compose 页面同时修改 Kotlin 导航栈和 SwiftUI 导航栈,否则返回行为会变得很难定位。
返回也一样。iOS 顶部返回按钮由 NavigationStack 提供,Compose 里就不要再固定放一个返回箭头。确实需要页面内部的关闭按钮时,让它调用外层传入的 onClose(),由 SwiftUI 执行 dismiss 或修改 path。
布局边界
SwiftUI 外壳接管导航栏以后,Compose 内容区域会受系统安全区、导航栏、Tab 栏影响。以前 Compose 根页面可能自己处理 WindowInsets,现在需要确认这些 padding 有没有重复。
比如一个 Compose 页面原来这样写:
bash
@Composable
fun HomeScreen() {
Column(
modifier = Modifier
.fillMaxSize()
.windowInsetsPadding(WindowInsets.systemBars)
) {
// content
}
}
当外层已经是 SwiftUI 的 NavigationStack 和 TabView 时,这个 systemBars padding 可能不再适合直接套在根节点上。更稳的方式是把页面内容当成普通内容区域,只在真正需要避让键盘、底部操作栏或自定义浮层时处理 Insets。
这里没有一个所有项目通用的固定答案。判断方法很简单:接入后先看三处,顶部标题下方有没有多余空白,底部 Tab 上方有没有被挤出一截,滚动列表最后一项会不会被 Tab 挡住。如果这些地方异常,优先检查 Compose 根节点上的 Insets 和 SwiftUI 容器的安全区处理。

迁移顺序
已经有 Compose Multiplatform iOS 端的项目,不建议一次把所有导航都换掉。先挑一个入口简单、页面层级少的 Tab 试。比如 Profile、Settings、About 这类页面,比复杂首页更适合验证。
第一步,把这个页面从 Kotlin 的根导航里拆出来,单独暴露 UIViewController。第二步,在 SwiftUI 里用 NavigationStack 包住它,并把 title、toolbar 放到 SwiftUI。第三步,把页面里打开下一页的动作改成回调,让 SwiftUI 控制 push。
如果这个页面能正常工作,再处理列表页和详情页。列表页通常会遇到两个问题:滚动内容和导航栏材质的关系,以及点击 item 后 SwiftUI path 的维护。详情页再看返回按钮、标题变化、toolbar 按钮状态。
可以用下面这个结构做第一次验证:
bash
shared
HomeScreen(onOpenDetail)
DetailScreen(id, onClose)
iosApp
MainView: TabView
HomeRootView: NavigationStack
ComposeViewControllerHost
这个结构足够小,也能覆盖 Liquid Glass 需要的几个系统入口:Tab、Navigation title、Toolbar、Back。跑通以后,再决定哪些页面继续留在 Kotlin 导航里,哪些页面交给 SwiftUI 外壳。
限制
这套方式的前提是 iOS 26 系统组件负责导航层。Compose 页面内部自己画的 TopBar、BottomBar、卡片背景,不会自动变成系统 Liquid Glass。它们还是 Compose 自己渲染的内容。
还有一个限制是平台差异会更明显。Android 侧可能仍然使用 Compose Navigation,iOS 侧使用 SwiftUI Navigation。共享页面需要把"内容"和"导航动作"分开,否则同一份 Composable 很容易被平台导航细节污染。
对业务代码来说,最需要改的不是 UI 颜色,而是入口设计。Composable 最好接收回调和状态,不要在页面内部直接绑定某个平台的导航实现。这个习惯本来就适合 KMP,现在只是被 iOS 26 的系统效果放大了。

最后
你喜欢iOS液态玻璃的效果吗?