WWDC Quick Look 💓 By SwiftGGTeam
Build spatial SharePlay experiences

Build spatial SharePlay experiences

观看原视频

Highlight

visionOS 上的 SharePlay 引入共享上下文和 System Coordinator API,让 FaceTime 中的 Spatial Persona 占据真实空间位置,系统通过模板自动管理参与者和应用的空间排列,开发者只需关注视觉一致性即可构建自然的协作体验。

核心内容

空间共享的新概念

visionOS 上的 FaceTime 让 Spatial Persona 不再局限于窗口,而是占据用户房间中的物理空间。这带来了两个核心概念:

共享上下文(Shared Context):所有人看到彼此和共享内容的相同相对位置。Connor 指向 Mia 的 Persona 时,Mia 看到 Connor 指向自己。系统通过模板自动处理这种空间一致性,应用不需要自己计算位移。

视觉一致性(Visual Consistency):所有人看到应用中的相同内容。Connor 指向应用中的方块时,Mia 必须也看到 Connor 指向方块。这需要应用自己同步内容状态(如滚动位置、选中项、3D 对象方向)。

System Coordinator

SystemCoordinator 是 GroupActivities 框架的新 API,负责两件事:接收 SharePlay 会话的系统状态,让应用提供额外配置。

三种空间模板

系统使用模板确定参与者和应用的空间排列:

  • Side-by-Side:参与者在弧线上面向应用,默认模板,适合常规窗口场景。
  • Conversational:参与者坐半圆,应用在前方,适合音乐播放等非内容聚焦场景。
  • Surround:参与者围成圆,应用在圆心,只适用于 Volume(立体)场景。

应用可以通过 spatialTemplatePreference 提供偏好,FaceTime 会尽量应用。

isSpatial 标志

localParticipantState.isSpatial 指示本地参与者是否处于空间模式。这决定了是否需要同步内容位置:

  • 空间模式下,参与者有空间上下文,需要同步滚动位置等操作
  • 非空间模式下(如普通 FaceTime),不同步这些操作,避免对方看到意外的 UI 变化

场景关联(Scene Association)

多窗口应用需要告诉系统哪个窗口承载 SharePlay 活动。单窗口应用自动处理。多窗口应用如果不设置,系统会随机选择一个窗口,可能选错。

场景关联通过两种方式配置:

  1. Scene Activation Conditions:用 handlesExternalEvents(SwiftUI)或 UIScene.activationConditions(UIKit)声明场景能处理哪些活动标识符。
  2. Scene Association Behavior:在 GroupActivity 的 metadata 中设置 .content("identifier"),用内容标识符(如文档 ID)匹配场景。

从 Share 菜单启动 SharePlay

iOS 17 开始,SharePlay 可以通过 Share 菜单启动。应用在窗口的 Share 横幅中暴露相关的 GroupActivity,用户可以直接从菜单发起共享,无需应用内专门的 SharePlay 按钮。

实现方式是通过 UIActivityItemsConfiguration 注册 GroupActivity,系统会沿着 responder chain 查找并展示在 Share 菜单中。

沉浸式应用的 SharePlay

沉浸式应用使用 ImmersiveSpace 创建无边界体验。在 SharePlay 中,需要配置 supportsGroupImmersiveSpace 标志,让系统知道该 Immersive Space 是共享的。

当参与者加入启用了此标志的组会话时,他们的 Immersive Space 会变成 Group Immersive Space。系统会将空间原点移动到模板定义的共享位置,建立共享坐标系。

Group Immersion StylegroupImmersionStyle 异步序列提供其他参与者进入的沉浸样式(如 .full.mixed),或 nil(离开)。应用可以据此自动打开或关闭匹配的 Immersive Space,确保所有人始终在一起。

System Experience DisplacementGeometryReader3DimmersiveSpaceDisplacement(in:) 方法返回系统放置空间原点相对于本地用户的位移。取反后可以得到本地用户相对于空间原点的位置,用于放置私人 UI(如控制菜单)。

Content Extent:模板中参与者与应用的距离基于应用大小动态调整。对于只有 Group Immersive Space 的应用,可以通过 contentExtent 修饰符指定内容从中心到最远边缘的距离(以 points 为单位),帮助系统计算合适的距离。

详细内容

观察本地参与者的空间状态

04:08

for await session in ExploreActivity.sessions() {
    guard let systemCoordinator = await session.systemCoordinator else { continue }

    let isLocalParticipantSpatial = systemCoordinator.localParticipantState.isSpatial

    Task.detached {
        for await localParticipantState in systemCoordinator.localParticipantStates {
            if localParticipantState.isSpatial {
                // Start syncing scroll position
            } else {
                // Stop syncing scroll position
            }
        }
    }
}

关键点:

  • session.systemCoordinator 获取系统协调器,可能为 nil
  • localParticipantState.isSpatial 判断本地参与者是否处于空间模式
  • localParticipantStates 是异步序列,状态变化时产生新值
  • 空间模式下同步滚动位置等操作,非空间模式下停止同步
  • 使用 Task.detached 避免阻塞主线程

配置空间模板偏好

06:10

for await session in ExploreActivity.sessions() {
    guard let systemCoordinator = await session.systemCoordinator else { continue }

    var configuration = SystemCoordinator.Configuration()
    configuration.spatialTemplatePreference = .sideBySide
    systemCoordinator.configuration = configuration

    session.join()
}

关键点:

  • SystemCoordinator.Configuration() 创建配置对象
  • spatialTemplatePreference 设置模板偏好,可选 .none.sideBySide.conversational
  • 配置必须在调用 session.join() 之前设置
  • .none 遵循系统默认:普通窗口用 Side-by-Side,Volume 场景用 Surround

配置场景激活条件(SwiftUI)

09:10

@main
struct ExploreTogetherApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .handlesExternalEvents(
                    preferring: ["com.example.explore-together.activity"],
                    allowing: ["com.example.explore-together.activity"]
                )
        }
    }
}

关键点:

  • handlesExternalEvents(preferring:allowing:) 声明场景能处理的外部事件
  • preferring 表示场景更适合处理这些标识符
  • allowing 表示场景可以处理这些标识符
  • 使用 GroupActivity 的标识符作为匹配条件

配置场景激活条件(UIKit)

09:30

class SceneDelegate: NSObject, UISceneDelegate {

    func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // ...

        scene.activationConditions.canActivateForTargetContentIdentifierPredicate =
                NSPredicate(format: "self == %@", "com.example.explore-together.activity")

        scene.activationConditions.prefersToActivateForTargetContentIdentifierPredicate =
                NSPredicate(format: "self == %@", "com.example.explore-together.activity")
    }
}

关键点:

  • canActivateForTargetContentIdentifierPredicate 声明场景能处理的活动标识符
  • prefersToActivateForTargetContentIdentifierPredicate 声明场景更适合处理的活动标识符
  • 系统按 can → prefers 的顺序评估所有场景,选择最合适的关联
  • 如果没有场景匹配,系统会启动新窗口场景

设置场景关联行为

10:40

struct ExploreActivity: GroupActivity {
    var metadata: GroupActivityMetadata {
        var metadata = GroupActivityMetadata()
        // ...
        metadata.sceneAssociationBehavior = .content("document-1")
        return metadata
    }
}

关键点:

  • sceneAssociationBehavior 控制场景关联方式
  • .default 使用 GroupActivity 标识符匹配场景(默认行为)
  • .content("identifier") 使用自定义内容标识符匹配,适合文档类应用
  • .none 禁用场景关联,适合沉浸式应用或内容各不相同的场景
  • 所有参与者使用相同的标识符匹配场景

从 Share 菜单启动 SharePlay

13:44

// Create the activity
let activity = ExploreActivity()

// Register the activity on the item provider
let itemProvider = NSItemProvider()
itemProvider.registerGroupActivity(activity)

// Create the activity items configuration
let configuration = UIActivityItemsConfiguration(itemProviders: [itemProvider])

// Provide the metadata for the group activity
configuration.metadataProvider = { key in
    guard key == .linkPresentationMetadata else { return nil }
    let metadata = LPLinkMetadata()
    metadata.title = "Explore Together"
    metadata.imageProvider = NSItemProvider(object: UIImage(named: "explore-activity")!)
    return metadata
}
self.activityItemsConfiguration = configuration

关键点:

  • NSItemProvider.registerGroupActivity(activity) 将活动注册到 item provider
  • UIActivityItemsConfiguration 包装 item providers
  • metadataProvider 提供 Share 菜单中显示的标题和图标
  • 系统沿 responder chain 查找 activityItemsConfiguration
  • 设置在有 SharePlay 内容的 view controller 上即可自动被系统发现

配置 Group Immersive Space

16:03

for await session in ExploreActivity.sessions() {
    guard let systemCoordinator = await session.systemCoordinator else { continue }

    var configuration = SystemCoordinator.Configuration()
    configuration.supportsGroupImmersiveSpace = true
    systemCoordinator.configuration = configuration
}

关键点:

  • supportsGroupImmersiveSpace = true 声明应用支持共享沉浸式空间
  • 参与者加入后,其 Immersive Space 变为 Group Immersive Space
  • 系统自动将空间原点移动到共享位置
  • 参与者在空间中可以看到彼此的 Persona

System Experience Displacement

17:51

var body: some Scene {
    ImmersiveSpace(id: "earth") {
        GeometryReader3D { proxy in
            let displacement = proxy.immersiveSpaceDisplacement(in: .global).inverse

            Control()
                .offset(displacement.position)
                .rotation3DEffect(displacement.rotation)
        }
    }
}

关键点:

  • GeometryReader3D 提供 3D 几何信息
  • immersiveSpaceDisplacement(in: .global) 获取空间原点相对于本地用户的位移
  • .inverse 取反得到本地用户相对于空间原点的位置
  • 用位移的 positionrotation 放置私人 UI
  • 位移只在空间初始放置时计算,不随用户移动更新
  • 适合放置不需要精确追踪用户的相对位置内容

带 Content Extent 的模板配置

20:46

for await session in ExploreSolarActivity.sessions() {
    guard let systemCoordinator = await session.systemCoordinator else { continue }

    var configuration = SystemCoordinator.Configuration()
    configuration.supportsGroupImmersiveSpace = true
    configuration.spatialTemplatePreference = .sideBySide.contentExtent(200)
    systemCoordinator.configuration = configuration
}

关键点:

  • .sideBySide.contentExtent(200) 为 Side-by-Side 模板设置内容范围
  • contentExtent 是从内容中心到最远边缘的距离(points)
  • 系统用此值计算参与者与内容的合适距离
  • 对只有 Group Immersive Space 的应用尤其有用
  • 也适用于基于窗口的 SharePlay 活动

接收 Group Immersion Style

22:32

for await session in ExploreSolarActivity.sessions() {
    guard let systemCoordinator = await session.systemCoordinator else { continue }

    Task.detached {
        for await immersionStyle in systemCoordinator.groupImmersionStyle {
            if let immersionStyle {
                // Open an immersive space with the same immersion style
            } else {
                // Dismiss the immersive space
            }
        }
    }
}

关键点:

  • groupImmersionStyle 是异步序列,提供其他参与者的沉浸样式
  • 收到具体样式(如 .full.mixed)时,打开匹配的 Immersive Space
  • 收到 nil 时,表示其他参与者离开了 Group Immersive Space,可以关闭自己的
  • 确保所有参与者始终处于相同的沉浸式环境中
  • 用户按数码表冠临时退出时,系统不会通知其他人改变状态

核心启发

  • 做什么:为文档协作应用添加 SharePlay 支持,让多人实时编辑和批注同一文档。

  • 为什么值得做:空间 SharePlay 让协作者感觉像围坐在同一张桌子前。System Coordinator 自动处理空间排列,应用只需同步文档状态。

  • 怎么开始:实现 GroupActivity 协议,配置 SystemCoordinatorspatialTemplatePreference.sideBySide,用 GroupSessionMessenger 同步文档编辑状态,监听 isSpatial 决定是否同步滚动位置。

  • 做什么:开发一个多人沉浸式观星应用,让用户一起在虚拟宇宙中探索行星。

  • 为什么值得做:Group Immersive Space 让所有参与者进入同一个共享坐标系,可以看到彼此的 Persona 和共享的 3D 内容。groupImmersionStyle 确保所有人自动进入相同环境。

  • 怎么开始:设置 supportsGroupImmersiveSpace = true,用 immersiveSpaceDisplacement 放置私人控制面板,监听 groupImmersionStyle 自动打开/关闭 Immersive Space,用 GroupSessionMessenger 同步 3D 对象状态。

  • 做什么:在音乐播放应用中集成 SharePlay,让远方的朋友一起听歌。

  • 为什么值得做:Conversational 模板将参与者排成半圆,应用在前方,营造围坐听歌的氛围。空间音频让每个人感受到音乐从不同方向传来。

  • 怎么开始:配置 spatialTemplatePreference = .conversational,在 Share 菜单中暴露 GroupActivity,同步播放进度和播放列表状态。

  • 做什么:为白板/脑图应用添加空间协作功能。

  • 为什么值得做:Freeform 已经展示了这种体验的可行性。Side-by-Side 模板让所有参与者面向同一内容,视觉一致性确保指向和批注对所有人可见。

  • 怎么开始:使用 .sideBySide 模板,同步画布缩放和滚动位置,用 GroupSessionMessenger 广播笔画和批注数据,多窗口应用设置 sceneAssociationBehavior = .content(documentID)

关联 Session

评论

GitHub Issues · utterances