WWDC Quick Look 💓 By SwiftGGTeam
Go beyond the window with SwiftUI

Go beyond the window with SwiftUI

观看原视频

Highlight

SwiftUI 在 visionOS 上引入了 ImmersiveSpace 场景类型,与 WindowGroup 和 Volume 并列,让应用内容突破窗口边界,通过 openImmersiveSpace/dismissImmersiveSpace 环境动作控制进入和退出,配合 RealityView 和 ARKit 实现从混合现实到完全沉浸的多种体验层级。

核心内容

三种容器,三种体验层级

visionOS 上 SwiftUI 提供了三种场景容器:WindowGroup 用于传统窗口,Volume 用于有边界的 3D 内容,ImmersiveSpace 用于无边界的空间体验。前两者将内容限制在容器内,而 ImmersiveSpace 让你的内容可以出现在用户周围的任何地方。

当应用打开 ImmersiveSpace 时,进入 Full Space 模式。其他应用全部隐藏,为你的内容让出整个空间。关闭后,其他应用自动恢复。应用同时只能打开一个 Space。

03:00

定义和打开 ImmersiveSpace

定义一个 ImmersiveSpace 只需要几行代码:

@main
struct WorldApp: App {
    var body: some Scene {
        ImmersiveSpace(id: "solar") {
            SolarSystem()
        }
    }
}

关键点:

  • ImmersiveSpace 是新的场景类型,与 WindowGroup 用法类似
  • 通过 id 参数为 Space 命名,后续通过该标识符打开
  • Space 的原点位于用户脚下,内容放置需要相对这个位置考虑

从窗口中打开 Space 需要使用环境动作:

struct SpaceControl: View {
    @Environment(\.openImmersiveSpace) private var openImmersiveSpace
    @Environment(\.dismissImmersiveSpace) private var dismissImmersiveSpace
    @State private var isSpaceHidden: Bool = true

    var body: some View {
        Button(isSpaceHidden ? "View Outer Space" : "Exit the solar system") {
            Task {
                if isSpaceHidden {
                    let result = await openImmersiveSpace(id: "solar")
                    switch result {
                    case .opened:
                        isSpaceHidden = false
                    case .error:
                        // 处理打开失败
                        break
                    default:
                        break
                    }
                } else {
                    await dismissImmersiveSpace()
                    isSpaceHidden = true
                }
            }
        }
    }
}

关键点:

  • openImmersiveSpacedismissImmersiveSpace 是环境值,通过 @Environment 获取
  • openImmersiveSpace(id:) 返回异步结果,需要处理成功和失败两种情况
  • dismissImmersiveSpace() 不需要参数,因为同时只能有一个 Space 打开
  • 这些动作是 async 的,系统会在 Space 进入/退出动画完成后返回

09:11

RealityView 与 ImmersiveSpace 的配合

ImmersiveSpace 和 RealityView 是配套设计的。RealityView 提供异步加载、ARKit 锚点放置、手部和头部姿态数据访问等能力:

ImmersiveSpace {
    RealityView { content in
        let starfield = await loadStarfield()
        content.add(starfield)
    }
}

关键点:

  • RealityView 的闭包支持 async,可以异步加载 3D 资源
  • 加载完成后通过 content.add() 将实体添加到场景中
  • RealityView 使用 RealityKit 渲染,坐标系与 SwiftUI 不同

坐标系差异需要注意:

  • SwiftUI 中 y 轴向下,z 轴朝向用户
  • RealityKit 中 y 轴向上
  • 在 RealityView 中定位实体时遵循 RealityKit 的坐标约定

06:53

详细内容

多场景应用结构

一个典型的 visionOS 应用同时包含窗口和 Space:

@main
struct WorldApp: App {
    var body: some Scene {
        // 第一个场景是启动时显示的窗口
        WindowGroup {
            VStack {
                Text("The Solar System")
                    .font(.largeTitle)
                SpaceControl()
            }
        }

        // ImmersiveSpace 默认不显示,需要手动打开
        ImmersiveSpace(id: "solar") {
            SolarSystem()
        }
    }
}

关键点:

  • 场景列表中第一个场景在应用启动时自动显示
  • ImmersiveSpace 默认隐藏,通过按钮等交互触发打开
  • 也可以配置应用直接启动进入 Space(见下文)

10:44

异步加载 3D 模型

3D 资源加载需要时间,Model3D 提供了加载状态管理:

Model3D(named: "Earth") { phase in
    switch phase {
    case .empty:
        Text("Waiting")
    case .failure(let error):
        Text("Error \(error.localizedDescription)")
    case .success(let model):
        model.resizable()
    }
}

关键点:

  • phase 参数反映加载状态:empty、failure、success
  • 在 empty 状态显示加载指示,failure 显示错误信息
  • success 时获得加载完成的模型,可应用 .resizable() 等修饰符

11:32

Scene Phase 管理

ImmersiveSpace 支持 SwiftUI 的 scene phase,可以响应活跃状态变化:

@main
struct WorldApp: App {
    @EnvironmentObject private var model: ViewModel
    @Environment(\.scenePhase) private var scenePhase

    var body: some Scene {
        ImmersiveSpace(id: "solar") {
            SolarSystem()
                .onChange(of: scenePhase) {
                    switch scenePhase {
                    case .inactive, .background:
                        model.solarEarth.scale = 0.5
                    case .active:
                        model.solarEarth.scale = 1.0
                    default:
                        break
                    }
                }
        }
    }
}

关键点:

  • Space 会在用户走出边界或系统警报弹出时进入 inactive 状态
  • 用户回到体验区域后恢复 active 状态
  • 利用 scene phase 变化可以给出视觉反馈,如缩小内容提示状态变化

13:04

坐标转换

窗口和 Space 各自有独立的坐标系。SwiftUI 提供了 ImmersiveSpace 坐标空间用于转换:

GeometryReader3D { proxy in
    Earth()
        .onTapGesture {
            if let translation = proxy.transform(in: .immersiveSpace)?.translation {
                model.solarEarth.position = Point3D(translation)
            }
        }
}

关键点:

  • GeometryReader3D 提供 3D 几何信息
  • proxy.transform(in: .immersiveSpace) 获取当前视图在 ImmersiveSpace 坐标系中的变换
  • 通过转换可以将窗口中的点击位置映射到 Space 中的实体位置

14:21

三种 Immersion Style

visionOS 提供三种沉浸样式,控制内容如何接管周围环境:

@main
struct WorldApp: App {
    @State private var currentStyle: ImmersionStyle = .mixed

    var body: some Scene {
        ImmersiveSpace(id: "solar") {
            SolarSystem()
                .simultaneousGesture(
                    MagnifyGesture()
                        .onChanged { value in
                            let scale = value.magnification
                            if scale > 10 {
                                currentStyle = .full
                            } else if scale > 5 {
                                currentStyle = .progressive
                            } else {
                                currentStyle = .mixed
                            }
                        }
                )
        }
        .immersionStyle(selection: $currentStyle, in: .mixed, .progressive, .full)
    }
}

关键点:

  • .mixed(默认):内容与现实环境共存,可以看到周围的一切
  • .progressive:内容在一个前方 portal 中显示,可通过 Digital Crown 调节沉浸度
  • .full:完全接管视野,用户完全沉浸在虚拟内容中
  • .immersionStyle(selection:in:) 修饰符声明支持的样式列表
  • 用户可以通过 Digital Crown 在 progressive 样式中调节 passthrough 比例

16:34

环境效果与手部隐藏

在 progressive 和 full 样式中,可以进一步调整体验:

ImmersiveSpace(id: "solar") {
    SolarSystem()
        .preferredSurroundingsEffect(.systemDark)
}
.immersionStyle(selection: $currentStyle, in: .progressive)

关键点:

  • .preferredSurroundingsEffect(.systemDark) 让周围环境变暗,突出 Space 内容
  • 仅在 progressive 和 full 样式中有效

在 full 样式中隐藏真实手部,显示虚拟手部:

ImmersiveSpace(id: "solar") {
    SolarSystem()
}
.immersionStyle(selection: $currentStyle, in: .full)
.upperLimbVisibility(.hidden)

关键点:

  • .upperLimbVisibility(.hidden) 在 full immersion 下隐藏真实手部
  • 配合 ARKit 手部追踪可以显示虚拟手部模型

20:08

ARKit 手部追踪与虚拟手套

通过 ARKit 获取手部骨骼数据,驱动虚拟手套模型:

struct SpaceGloves: View {
    let arSession = ARKitSession()
    let handTracking = HandTrackingProvider()

    var body: some View {
        RealityView { content in
            let root = Entity()
            content.add(root)

            let leftGlove = try! Entity.loadModel(named: "assets/gloves/LeftGlove_v001.usdz")
            root.addChild(leftGlove)

            let rightGlove = try! Entity.loadModel(named: "assets/gloves/RightGlove_v001.usdz")
            root.addChild(rightGlove)

            Task {
                do {
                    try await arSession.run([handTracking])
                } catch let error as ProviderError {
                    print("Provider error: \(error.localizedDescription)")
                } catch {
                    print("Unexpected error: \(error.localizedDescription)")
                }

                for await anchorUpdate in handTracking.anchorUpdates {
                    let anchor = anchorUpdate.anchor
                    switch anchor.chirality {
                    case .left:
                        leftGlove.transform = Transform(matrix: anchor.transform)
                        for (index, jointName) in anchor.skeleton.definition.jointNames.enumerated() {
                            leftGlove.jointTransforms[index].rotation = simd_quatf(
                                anchor.skeleton.joint(named: jointName).localTransform
                            )
                        }
                    case .right:
                        rightGlove.transform = Transform(matrix: anchor.transform)
                        for (index, jointName) in anchor.skeleton.definition.jointNames.enumerated() {
                            rightGlove.jointTransforms[index].rotation = simd_quatf(
                                anchor.skeleton.joint(named: jointName).localTransform
                            )
                        }
                    }
                }
            }
        }
    }
}

关键点:

  • ARKitSessionHandTrackingProvider 是 ARKit 的核心类型
  • arSession.run([handTracking]) 启动手部追踪
  • handTracking.anchorUpdates 提供异步的手部锚点更新流
  • anchor.chirality 区分左右手
  • anchor.skeleton.jointNamesjoint(named:).localTransform 获取每根手指关节的变换
  • 将关节旋转同步到虚拟模型的 jointTransforms 实现手套跟随真实手部运动

20:52

直接启动进入 Space

对于完全沉浸式的应用(如游戏),可以配置应用启动时直接进入 Space:

@main
struct WorldApp: App {
    var body: some Scene {
        ImmersiveSpace(id: "solar") {
            SolarSystem()
        }
        .immersionStyle(selection: .constant(.full), in: .full)
    }
}

需要在 Info.plist 中配置 UIApplicationSceneManifest,将 ImmersiveSpace 设为启动场景。

19:33

核心启发

  • 做什么:创建一个从窗口进入沉浸式太空体验的观星应用

    • 为什么值得做:ImmersiveSpace 让应用可以从普通的 2D 窗口平滑过渡到 360 度星空环境。用户先浏览天文信息,再点击进入沉浸式模式探索太阳系
    • 怎么开始:定义 WindowGroup 作为入口,定义 ImmersiveSpace 存放 3D 内容,用 openImmersiveSpace(id:) 触发切换
  • 做什么:为健身应用添加一个 progressive 沉浸模式的虚拟训练场

    • 为什么值得做:progressive 样式让用户可以在保持环境感知的同时享受沉浸式训练。通过 Digital Crown 调节沉浸度,从”看到客厅里的虚拟教练”到”完全进入虚拟健身房”
    • 怎么开始:使用 .immersionStyle(selection:in:) 支持 mixed 和 progressive,用 MagnifyGesture 或按钮切换样式
  • 做什么:开发一个支持手部追踪的虚拟乐器演奏应用

    • 为什么值得做:ARKit 手部追踪提供每根手指关节的精确数据,可以检测弹奏动作。配合 .upperLimbVisibility(.hidden) 和虚拟手部模型,营造真实演奏感
    • 怎么开始:使用 HandTrackingProvider 获取手部锚点,将关节变换映射到虚拟乐器模型的交互区域
  • 做什么:构建一个多人协作的 3D 白板应用

    • 为什么值得做:ImmersiveSpace 支持 SharePlay,多个用户可以在同一个空间中看到和操作相同的内容。坐标转换 API 让窗口中的 2D 内容可以精确定位到 3D 空间中
    • 怎么开始:集成 GroupActivities 框架,使用 GeometryReader3D.immersiveSpace 坐标空间进行位置转换
  • 做什么:制作一个直接启动的冥想/放松应用

    • 为什么值得做:full immersion 样式可以完全屏蔽外界干扰,配合 .preferredSurroundingsEffect(.systemDark) 营造专注氛围。应用启动即进入体验,无需用户额外操作
    • 怎么开始:在 App 声明中只保留 ImmersiveSpace,设置 immersionStyle 为 full,配置 scene manifest 直接启动

关联 Session

评论

GitHub Issues · utterances