绝了!Compose Multiplatform 也能实现 iOS26 液态玻璃的效果了

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") 本身,而是 navigationTitletoolbar 由 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 的 NavigationStackTabView 时,这个 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液态玻璃的效果吗?

#Android #ComposeMultiplatform #Kotlin #SwiftUI

相关推荐
2601_961767282 小时前
【分享】云视听快TV 快手电视版 手机电视都可以用
android·智能手机
数智工坊11 小时前
机器人运动控制:采样、优化与学习三大流派深度对比与实战
android·学习·机器人
故渊at13 小时前
第二板块:Android 四大组件标准化学理 | 第八篇:Service 后台执行实体与优先级
android·gitee·service·前台服务·后台服务
会Tk矩阵群控的小木13 小时前
安卓群控系统对于游戏工作室实战教程
android·运维·游戏·adb·开源软件·个人开发
qeen8714 小时前
【C++】类与对象之类的默认成员函数(二)
android·c语言·开发语言·c++·笔记·学习
故渊at14 小时前
第二板块:Android 四大组件标准化学理 | 第九篇:BroadcastReceiver 事件分发与有序广播
android·gitee·broadcast·广播·动态注册·静态注册
JohnnyDeng9414 小时前
【Android】Room 数据库高级用法与性能调优:从查询瓶颈到毫秒级响应
android·性能优化·kotlin·room
zeqinjie14 小时前
Flutter 折叠屏 iPad / 宽屏适配实践
android·前端·flutter