大多数浏览器和
Developer App 均支持流媒体播放。
-
使用 SwiftUI 突破窗口界限
准备好进入空间 — 一种全新的 SwiftUI 场景类型,可以帮助你为 visionOS 打造出色的沉浸式体验。我们将向你展示如何使用 ImmersiveSpace 创建新场景、放置 3D 内容以及整合 RealityView。探索如何使用 immersionStyle 场景修饰符来增强 App 的沉浸程度,并学习管理空间、使用 ARKit 添加虚拟手、添加对同播共享的支持以及构建“世界外”体验等最佳实践!
章节
- 0:00 - Introduction
- 3:27 - Get Started
- 5:51 - Display content
- 12:00 - Managing your Space
- 19:12 - Customization
资源
相关视频
WWDC23
-
下载
♪ 悦耳的器乐嘻哈 ♪ ♪ Raffael Hannemann: 大家好 欢迎来到 “使用 SwiftUI 突破窗口的界限” 我是 Raffa 一名 Apple 的工程师 之后我的同事 Mark 会加入 今天 我们会向你展示 如何简单地使用 你已熟悉的工具和框架 借助 xrOS 的全部功能 来创建真正的沉浸式体验
你也许为 iOS 开发过 AR App 已经很熟悉增强现实 在过去的几年中 我们引入并扩展了 一系列工具和框架 包括 ARKit 和 RealityKit 来为 iPhone 和 iPad 创建丰富的 AR App 这些 App 通过使用 可交互用户界面和虚拟对象 来增强用户周围的空间 让现实世界和想象不再界限分明 今年 通过推出 xrOS 我们将 AR 提升到一个全新高度 首先是沉浸式体验 在这些体验中 你的 App 可以在你周围的任何地方 显示 UI 包括窗口和 3D 内容 用户周围的空间保持可见状态 事实上 它甚至成了体验的一部分 你可以将 App 的元素锚定到表面上 并通过虚拟对象和效果 增强并丰富现实世界 另外 还有全沉浸式体验 进一步覆盖你的整个空间 你的 App 完全控制 你能看到的内容 请想想 这会解锁多少可能性 最棒的一点是 你能用已熟悉的 工具、框架和模式来实现 其核心是 SwiftUI 的沉浸式空间 让我们开始吧 在其他的讲座中 你已经了解到 我们今年为 SwiftUI 添加了 3D 你可以在 xrOS 上 呈现窗口和空间容器 以及使用 SwiftUI 中 简单易用的声明式模式 来显示 3D 用户界面元素 无论是窗口还是空间容器 都可以让你在其范围内显示内容 如果你想充分利用 xrOS 提供的无限空间 来创建真正的沉浸式体验 该怎么做呢? 你也许希望将 App 中的物体 放置在窗口范围以外 分布在你头顶的四周 这样 你就置身其中了 而这就是我们设计空间的目的 在窗口和空间容器旁边 空间是一种在 xrOS 上 呈现你的用户界面的容器 在此讲座中 我们会重点讲解空间 以及你可以如何使用空间 来创建沉浸式体验 让我们从空间开始讲起 然后了解如何显示内容 之后 Mark 会向你展示 如何管理你的空间 直接进入空间 以及解释空间允许的 所有自定义 让我们开始吧 先看看一些代码 我非常期待空间探索 为了继续开发 我们在其他讲座中 使用的 World App 我们会使用空间逐步扩展 App 来探索我们的太阳系 空间是 SwiftUI 中 称为沉浸式空间的新场景类型 和使用其他场景类型时的做法一样 你在 App 中定义一个沉浸式空间 可以随时打开或关闭 你可以让整个 App 只由单个空间构成 但你也可以通过 在窗口和空间容器旁边 添加一个或多个空间 来扩展现有的 App 你的 App 一次只可以打开一个空间 在打开另一个空间前 你需要先关闭当前空间 和其他场景类型相似 你将视图层次放在场景的 body 中 将我们的 SolarSystem 放在 ImmersiveSpace 中 SolarSystem 会以 没有任何裁剪边界的方式渲染出来 让我们看看这有多简单 只用了三行代码 我们就将太阳系视图 放入了有趣的沉浸式体验中 让我们深入地了解一些细节 使用空间使我们获得了 一些特别行为 让此场景 从其他场景类型中脱颖而出 当多个 App 并排运行时 他们在同一个空间中一起显示 这就是为什么我们称之为共享空间 一旦你的 App 显示一个沉浸式空间场景 你的 App 就会进入 Full Space 那时 用户唯一可见的 就是你的 App 所有其他 App 都会消失 为你的内容腾出空间 使其不受干扰地显示 之后 一旦你关闭空间 其他 App 会重新出现 由于沉浸式空间是一个场景 它隐式定义了自己的坐标系 因此 你放置在空间中的所有内容 都是相对于空间的原点位置 空间的原点在用户下方 在初次打开空间时 靠近用户的脚部 现在你已了解基本要点 让我们继续 谈谈你如何显示空间的内容 ImmersiveSpace 是一个场景类型 所以你可以直接 将视图层次放在其中 ImmersiveSpace 可以接受任何 Swift UI 视图 尽管空间没有任何裁剪边界 但它还是在其布局范围内 布局内容 你放置在空间中的任何内容 使用的都是你已熟悉的 相同布局系统 但由于空间的原点靠近用户的脚部 你可能不希望 将内容放在那么低的位置 让我们谈谈 RealityView 如果你想充分利用 SwiftUI、ARKit 和 RealityKit 我们鼓励你将 ImmersiveSpace 和全新 RealityView 的强大功能 结合使用 ImmersiveSpace 和 RealityView 配合紧密 两者一起专门用于 提供构建出色的沉浸式体验 所需的全部功能 例如 RealityView 内置对资源的异步加载支持 如图所示 用于加载和显示 starfield 但除了异步加载外 在沉浸式空间场景中 放置 RealityView 还能让我们做更多事情 将 RealityView 中的元素 放置在 ARKit 的锚点上 由于你的 App 在空间打开时 有权限访问手部和头部姿势数据 你可以使用这些数据 在 RealityView 中 确定实体的位置 Mark 之后会展示很酷的功能 关于坐标空间 有一点要说明 RealityView 用 RealityKit 显示内容 因此 在 RealityView 中 确定实体的位置时 要记得其坐标空间方向 和 SwiftUI 的布局系统不同 在 SwiftUI 中 Y 轴朝下 Z 轴朝向用户 这适用于窗口、Volumes 以及沉浸式空间 但在 RealityKit 中 Y 轴是朝上的 你还需了解 更多有关 RealityView 的内容 一定要将 “使用 RealityKit 构建空间体验” 添加到你的观看列表 以获取所有详细信息 现在让我们写点代码 我们将使用 WorldApp 至少是它的简化版 一步一步添加沉浸式的太阳系 我们开始先定义一个 ImmersiveSpace 和窗口组类似 你可以指定 一个标识符和一个值类型 在这个示例中 我们指定 solar 标识符 我们之后会用该标识符打开空间 然后我们将 SolarSystem 视图 放入空间中 让我们也为 App 定义一个简单的标准窗口 当 App 启动时 我们希望此窗口出现 并提供一个查看太阳系的控件 这和 World App 的功能类似 所以我们用窗口组 定义一个新的启动窗口 然后我们添加一些信息 以及一个能让我们打开空间的控件 此控件只是一个按钮 点击它时 我们希望更改它的标题并打开空间 为了控制窗口 SwiftUI 提供了 openWindow 和 dismissWindow 环境操作 而对于沉浸式空间 我们添加了新的 openImmersiveSpace 和 dismissImmersiveSpace 操作 我们从环境中获得这两个操作 然后我们可以 在按钮被调用时使用这些操作 在打开空间时 我们传入之前定义的标识符 由于一次只能打开一个空间 dismissImmersiveSpace 操作 不需要任何参数 系统会以一定的持续时间 来动画淡入和淡出你的空间 这些环境操作是异步的 因此你可以对动画的完成做出反应 打开沉浸式空间可能会失败 openImmersiveSpace 会通过其结果 告诉你调用是否成功 请确保正确处理错误 我们回到开头定义的 App 我们可以就在这儿 添加 LaunchWindow 请留意两个场景的顺序 LaunchWindow 是场景列表中的第一个 所以 SwiftUI 在 App 启动时 会显示启动窗口 沉浸式空间不会在启动时显示 只会在用户点击按钮时才出现 让我们在模拟器上运行一下 App 启动时 我们能看到启动窗口 我们点击一下按钮 太阳系就在客厅出现了
到现在 我们定义了一个多场景 App 包含一个标准窗口 以及显示太阳系的空间 你已经见过 World App 使用的模型 在构建沉浸式 App 时 你肯定希望在空间中显示一些 有很多细节的 3D 资源 要记得 资源完全加载 并准备好被 App 渲染出来 需要耗费一些时间 为了让用户获得最佳体验 确保充分利用新的 Model3D 和 RealityView API 这些 API 能异步加载 你的 3D 资源 在这里的代码中 当模型还在加载时 我们显示一个文本 并在出现问题时显示错误信息 现在 Mark 将告诉你 如何管理你的空间 更棒的是 如何进入空间 Mark Ma:谢谢 Raffa 正如我们刚演示的一样 将沉浸式空间整合到 我们的 World App 非常简单 只需要几行代码即可完成 将你的 App 转变为沉浸式体验 还包括使用场景阶段 与系统一起管理空间 协调你的空间 和其他场景之间的融合 以及用不同的样式来呈现 和其他 SwiftUI 场景类型相似 沉浸式空间支持同样的场景阶段 这些场景阶段由系统控制 这也意味着 你的空间总是在 SwiftUI 的一个场景阶段中 打开空间 它就移动到活跃阶段 在任何时刻 空间都有可能变为非活跃阶段 例如 如果我们走出系统定义的边界 或出现系统警报 空间和窗口会暂时隐藏 移动到非活跃阶段 一旦用户重新进入体验 空间和窗口会再次可见 并将场景阶段更新为活跃阶段 我们可以对 World App 很快地添加几行代码 来处理非活跃场景阶段 我们把地球模型大小缩小至一半 来帮助我们表示空间的阶段已改变 让我们也确保处理活跃阶段 来再次显示内容 要记得 空间可以通过硬件或软件 随时关闭 让我们在模拟器中看看 我们会打开我们的空间 并演示 App 如何处理不活跃阶段 例如 当警报出现时 不活跃阶段就会触发 请注意 当警报弹出时 空间内容缩小了 这是之前示例代码的结果 当我们关闭警报时 空间回到活跃阶段 并恢复了原本的大小 SwiftUI 让处理过程和转场动画 变得非常轻松和便捷 另一个管理空间的理想办法是 将其他窗口内容与空间进行整合 例如 如果你想将地球模型 重置于主窗口旁边 我们需要知道 沉浸式空间坐标系中窗口的位置 但这两个对象都定义自己的坐标系 为了解决这个问题 SwiftUI 提供了 一个新的坐标空间 称为 immersiveSpace 它代表的是沉浸式空间的坐标系 为了访问此坐标系 我们将窗口封装在 GeometryReader3D 上下文中 然后通过使用现有的 能接受坐标空间 (如 transform) 的 API 并传入 immersiveSpace 类型 我们能在新坐标系中 得到 proxy.transform 使用此 transform 我们可以随时更新地球的位置 让我们在模拟器上运行一下 我们会重新打开空间 这样我们就能让地球和主窗口可见 我们已经使窗口偏移了一点 我们希望将地球重置在窗口正前方 当我们轻点地球时 它就放置在我们想要放的位置 通过使用坐标转换 我们能轻松地将内容 放在我们想要放的位置 以及在空间和窗口之间移动资源 其他使用坐标转换的情况包括 同播共享中的沉浸式空间 我们可以在私人沉浸式空间中 甚至在群组沉浸式空间中 管理内容的位置 如果你的 App 支持同播共享 并在其他参与者加入时 支持群组沉浸式空间 系统可能将空间原点移动到 空间模板定义的共享位置上 要了解更多信息 请观看讲座 “构建空间同播共享体验” 我们的 World App 现在可以处理场景阶段 并整合其他窗口的内容 但我们仍然需要 使用空间提供的全部功能 接下来 我们会探索沉浸样式 来让空间更令人惊叹 沉浸样式提供不同的呈现方式 来呈现空间内容 如何占据你周围的空间 你可以使用混合式样式、 你前面的渐进式样式 或你四周的完全样式呈现你的内容 让我们更新 App 来充分利用所有这些样式 让我们再次打开 App 回到我们定义沉浸式空间的地方 空间现在是以混合式沉浸样式 呈现太阳系 其为默认样式 改变样式并使其动态变化很简单 首先 让我们添加一个新状态变量 类型为 ImmersionStyle 并赋予一个我们希望空间 以何种样式开始的默认值 我们把混合式样式保留 然后我们使用 immersionStyle 场景修饰符 并定义 我们希望空间支持的样式列表 为了引用当前的样式 我们将状态变量作为绑定传入 如果我们将绑定 传递给 SolarSystem 我们也可以读取当前样式 并控制它使其转换为任何地图样式 在这个示例中 我们会以放大手势来转换样式 随着我们放大太阳系 我们便会转换到不同的样式 到现在 我们一直在模拟器上 运行 World App 来向你展示如何轻松地 将沉浸式空间引入 但为了更好地感受 这些样式如何与周围环境配合 让我们在设备上运行我们的体验 之后 我们会向你展示更多自定义 可以真正增强你在设备上的体验 你以默认样式打开空间 此处即为混合式沉浸样式 这个样式很棒 但你也许希望更沉浸于内容 并且还能看见一些星星 所以你可以使用放大手势 随着内容放大 最终空间会转换到渐进式样式 此样式将穿透 和完全沉浸式体验连接起来 它使你能从你面前的端口内 看到沉浸式空间内容 也能看到周围的空间 此样式感觉足够沉浸 但同时也让你察觉到周围的空间 这也意味着你可以和周围的人聊天 找到能舒服坐下的地方 甚至与周围进行交互 只要你感觉舒适 通过旋转数码旋钮 你可以增强样式的沉浸程度 是不是很酷? 现在你像宇航员一样在银河系漂浮 如果你希望看到更多周围的空间 只需反方向旋转数码旋钮 降低沉浸程度即可 这让你能快速、简单地控制 渐进式样式中的 内容沉浸程度 但你可能希望 一直处于完全沉浸状态 这很适用于环绕你的那种体验 或在一瞬间进入 完全不同的世界的情况 到现在 你已了解如何轻松地 使用手势转换到不同的样式 进入完全沉浸也是如此 你会体验到 随着你放大地球 样式绑定也随之更新 请注意 在不同的样式之间转换 是很轻松、无缝的 现在 空间变得完全沉浸了 借助 SwiftUI 只用了几行代码就实现了 当你想退出沉浸式体验时 按下数码旋钮 就能回到穿透
我们演示了 通过对场景阶段改变作出反应 和控制样式 来管理空间的不同方式 现在 让我们添加一些最后的增强 将我们的空间再提升一个高度 设备的空间计算能力 让你的空间能轻松地增强 以获得更令人兴奋的体验 因此 让我们看看一些选项 比如直接进入空间、 在周围添加效果 以及虚拟手 到现在 在我们的 App 中 我们点击按钮就能打开空间 但如果你想在 App 启动时 直接进入沉浸式体验 就像在一个完全沉浸的游戏中那样 该怎么做呢? 为了直接进入沉浸式空间 你需要为 App 配置场景清单 只需设置 ImmersiveSpace App 角色 和沉浸样式 像往常一样附加你的空间内容 空间就会立即打开 如果用户选择关闭空间 你也可以让 App 回到窗口 另外 周围空间的效果偏好让我能 调暗穿透 以更突出空间内容 当空间转换到渐进式样式时 我们希望调暗周围 我们将 preferredSurroundingEffect 修饰符 设置为 dark 因此当太阳系出现时 我们的周围会自动调暗 upperLimbVisibility 修饰符 让我们能在完全沉浸的空间中 隐藏我们的手 因为没有穿透可用 为了 World App 的体验 在打开空间时 我们设置为不显示手 就像这样 我们可以更改 upperLimbVisibility 偏好 在完全沉浸样式中隐藏你的手 意味着我们可以显示虚拟手 我们会在 World App 中 展示一些航天手套 让我们先创建一个新的视图 称为 SpaceGloves 接下来 我们添加 一个 RealityView 这样我们可以在空间中渲染手套 然后在 RealityView 中 我们会创建一个根实体 来添加实体 这样实体也能被渲染 然后我们加载一个资源到实体上 并将其添加到根的子项 要正确放置实体 我们需要使用 ARKit 及其手势跟踪 API 还需要启动手势跟踪系统 下一步是要确保 资源正确地锚定在我们的手上 我们需要检查手势跟踪锚点更新 接着 检查手性 然后 我们确保手部资源的变换 和锚点的相同 在这个例子中 我们也确保了我们的资源 与 ARKit 提供的资源 具有相同的关节名称 这样 我们就能正确地 映射锚点骨架关节名称 手套实体也会 自动锚定 让我们回到定义空间的地方 并确保要包含 SpaceGloves 视图 这就是为了显示虚拟手所需的一切 关于 ARKit 自定义 以及更多深入的详细信息 请观看“发展你的 ARKit App 以获得空间体验” 现在 让我们在设备上 尝试这些自定义 世界体验重新开始 空间会以默认沉浸样式重新打开 通过在地球上使用放大手势 App 会转换到渐进式样式 当空间打开时 代码会将周围调暗 通过使用 Surrounding Effects API 调暗穿透 你能获得更沉浸的体验 API 应用起来很简单 也是专注体验的一个很棒的方式 现在已经足够沉浸了 但你还能通过接下来的自定义 更进一步 正如我们之前的代码所演示的 当你转换为完全沉浸时 你的手会消失 得益于手势跟踪 虚拟航天手套 会出现在你手部所在的位置 通过使用 RealityView 和 ARKit 并启用手势跟踪 你可以像虚拟宇航员一样进入太空 这感觉太棒了
通过几项增强和自定义 我们就能将 World App 打造成一个完全沉浸式体验 这突破了共享空间的限制 现在 你可以选择使用新的 Immersive Space API 来轻松创建体验 使用不同样式呈现内容 并使用可用的自定义发挥创造力 这个 API 功能强大且使用方便 可以为你提供所有必需的工具 转换你的周围环境 并创建新沉浸式体验 感谢你的观看 ♪
-
-
4:18 - Defining an ImmersiveSpace
@main struct WorldApp: App { var body: some Scene { ImmersiveSpace { SolarSystem() } } }
-
6:53 - RealityView in an ImmersiveSpace
ImmersiveSpace { RealityView { content in let starfield = await loadStarfield() content.add(starfield) } }
-
8:17 - ImmersiveSpace with a SolarSystem view
@main struct WorldApp: App { var body: some Scene { ImmersiveSpace(id: "solar") { SolarSystem() } } }
-
9:00 - LaunchWindow
struct LaunchWindow: Scene { var body: some Scene { WindowGroup { VStack { Text("The Solar System") .font(.largeTitle) Text("Every 365.25 days, the planet and its satellites [...]") SpaceControl() } } } }
-
9:11 - SpaceControl button using Environment actions for opening and dismissing an ImmersiveSpace scene
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 { // Handle result } } else { await dismissImmersiveSpace() isSpaceHidden = true } } } } }
-
10:44 - WorldApp using LaunchWindow and ImmersiveSpace
@main struct WorldApp: App { var body: some Scene { LaunchWindow() ImmersiveSpace(id: "solar") { SolarSystem() } } }
-
11:32 - Model3D with phase handling
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() } }
-
13:04 - Scene Phases
@main struct WorldApp: App { @EnvironmentObject private var model: ViewModel @Environment(\.scenePhase) private var scenePhase ImmersiveSpace(id: "solar") { SolarSystem() .onChange(of: scenePhase) { switch scenePhase { case .inactive, .background: model.solarEarth.scale = 0.5 case .active: model.solarEarth.scale = 1 } } } }
-
14:21 - Coordinate Conversions
var body: some View { GeometryReader3D { proxy in ZStack { Earth( earthConfiguration: model.solarEarth, satelliteConfiguration: [model.solarSatellite], moonConfiguration: model.solarMoon, showSun: true, sunAngle: model.solarSunAngle, animateUpdates: animateUpdates ) .onTapGesture { if let translation = proxy.transform(in: .immersiveSpace)?.translation { model.solarEarth.position = Point3D(translation) } } } } }
-
16:34 - Immersion Styles
@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 > 5 { currentStyle = .progressive } else if scale > 10 { currentStyle = .full } else { currentStyle = .mixed } } ) } .immersionStyle(selection:$currentStyle, in: .mixed, .progressive, .full) } }
-
20:08 - Surrounding Effects
@main struct WorldApp: App { @State private var currentStyle: ImmersionStyle = .progressive var body: some Scene { ImmersiveSpace(id: "solar") { SolarSystem() .preferredSurroundingsEffect( .systemDark) } .immersionStyle(selection: $currentStyle, in: .progressive) } }
-
20:30 - Upper Limbs Visibility
@main struct WorldApp: App { @State private var currentStyle: ImmersionStyle = .full var body: some Scene { ImmersiveSpace(id: "solar") { SolarSystem() } .immersionStyle(selection: $currentStyle, in: .full) .upperLimbVisibility(.hidden) } }
-
20:52 - Hand Anchoring
struct SpaceGloves2: View { let arSession = ARKitSession() let handTracking = HandTrackingProvider() var body: some View { RealityView { content in let root = Entity() content.add(root) // Load Left glove let leftGlove = try! Entity.loadModel(named: "assets/gloves/LeftGlove_v001.usdz") root.addChild(leftGlove) // Load Right glove let rightGlove = try! Entity.loadModel(named: "assets/gloves/RightGlove_v001.usdz") root.addChild(rightGlove) // Start ARKit session and fetch anchorUpdates Task { do { try await arSession.run([handTracking]) } catch let error as ProviderError { print("Encountered an error while running providers: \(error.localizedDescription)") } catch let error { print("Encountered an unexpected error: \(error.localizedDescription)") } for await anchorUpdate in handTracking.anchorUpdates { let anchor = anchorUpdate.anchor switch anchor.chirality { case .left: if let leftGlove = Entity.leftHand { 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: if let rightGlove = Entity.rightHand { 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) } } } } } } } }
-
-
正在查找特定内容?在上方输入一个主题,就能直接跳转到相应的精彩内容。
提交你查询的内容时出现错误。请检查互联网连接,然后再试一次。