Contents

Adding spatial Persona support to an activity

Update your SharePlay activities to support spatial Personas and the shared context when running in visionOS.

Overview

A person who participates in SharePlay activities on Apple Vision Pro has the option to participate using their spatial Persona. The system arranges spatial Personas around the activity content, giving each person a clear view of the content and each other. Each person sees the facial expressions of other participants, what they’re looking at, and where they’re pointing. This experience creates the feeling that they’re in the same physical space interacting with shared content and each other.

To maintain the experience when spatial Personas are visible, apps share additional information to maintain the shared context for the activity. Because participants can see where others are looking, your app’s content must look the same for everyone. Share any additional information you need to keep everyone’s content in sync visually. For example, synchronize your window’s scroll position to ensure everyone sees the same portion of that window.

You don’t need to define new GroupActivity types specifically to support spatial Personas. The system automatically displays spatial Personas for existing activities that take place in a window or volume. However, if you support activities in a Full Space, you need to do additional work to support spatial Personas for your experience. For information about how to define activities in your app, see Defining your app’s SharePlay activities.

Associate SharePlay activities with your app’s scenes

You use scenes to manage the content for your app’s windows, volumes, and immersive spaces. You also use scenes to display any activity-related content. When a participant joins an activity, the system selects or opens the scene that supports the activity. If your app has only one scene and one window, the system has only one choice. However, if your app has multiple scenes, you need to help the system choose the correct one.

For each of your app’s scenes, activation conditions tell the system how to handle your app’s SharePlay activities. You also use activation conditions to specify how to handle NSUserActivity objects, and other incoming events. To specify activation conditions for one of your scenes:

When you add an activation condition to a scene, you specify a string that uniquely identifies your SharePlay activity. The string can be anything you want, as long as it creates a unique connection between the activity and the specific scene. When there is a one-to-one correspondence between an activity and scene, the string in the activityIdentifier property of your activity object is a good choice. When there isn’t a one-to-one correspondence between scene and activity, you must create a string that uniquely identifies the scene. For example, in a document-based app, you might specify the name of a document to direct the system to the scene that contains the document.

The following code defines a SwiftUI scene for a window, and associates two activities with that window. Because the app uses the same scene to display content for two different activities, the activation conditions contain the identifiers for the two GroupActivity types. When either activity starts, the system directs the activity to the window containing the scene. If no such window is open, the system launches the app and creates the window as needed.

let activationConditions : Set = ["com.mycompany.MySharePlayActivity",
                                  "com.mycompany.MyUserActivity"]
var body: some Scene {
    WindowGroup {
        ContentView()
            .handlesExternalEvents(preferring: [], allowing: activationConditions)
    }

In addition to configuring your scene, you need to update your activity object’s metadata with the appropriate string. Specify the string using the sceneAssociationBehavior property of your activity object’s GroupActivityMetadata structure. Assign the default value to this property if you use the activity identifier as the string. For any custom strings, assign the content(_:) value and specify your string. To disable scene association for the activity and handle the presentation of activity-related UI yourself, specify the none value.

Although the activation conditions and the activity’s SceneAssociationBehavior determine the automatically associated scene, you can explicitly specify a different scene using the groupActivityAssociation(_:) modifier. Use this modifier on your view to set its activity association to primary(_:), and include an identifier that reflects the content of the view.

The system shares the most recently opened window whose GroupActivityAssociationKind is set to .primary(_:). To stop sharing a scene, set the GroupActivityAssociationKind to nil.

The following example shows how to dynamically change the shared scene depending on changes in your app:

struct GameApp: App {
    @State var isShowingVideo: Bool = true

    var body: some Scene {
        WindowGroup {
            VideoView()
                .groupActivityAssociation(isShowingVideo ? .primary("how-to-video") : nil)
        }


        WindowGroup {
            BoardGameView()
                .groupActivityAssociation(isShowingVideo ? nil : .primary("play-game"))
        }
    }
}

For a demonstration, watch the “Support multiple windows” chapter of the WWDC25 session Share visionOS experiences with nearby people.

For UIKit, create a GroupActivityAssociationInteraction and use addInteraction(_:) to add the scene association interaction.

Configure your app’s support for spatial Personas

The system provides some default behaviors to make it easier for you to adopt spatial Personas in your app. For activities that take place in a window or volume, the system chooses a default arrangement of the spatial Personas around your scene. The system also adorns the scene with a Share menu, which provides a visual indication when sharing is active and contains controls to start activities. The system doesn’t display spatial Personas in a Full Space by default.

To change the default behavior of your app, update the SystemCoordinator of your GroupSession before you call the session’s join() method. Use the SystemCoordinator to:

  • Specify a different arrangement for spatial Personas.

  • Specify whether your app supports spatial Personas when a Full Space is visible.

The following example changes the arrangement of spatial Personas to be side by side facing the app’s content. The code also tells the system that the app supports spatial Personas when a Full Space is open.

if let coordinator = await newSession.systemCoordinator {
    var config = SystemCoordinator.Configuration()
    config.spatialTemplatePreference = .sideBySide.contentExtent(200)
    config.supportsGroupImmersiveSpace = true
    coordinator.configuration = config
}

// Join the session.
newSession.join()

Spatial template preferences tell the system whether to arrange participants side by side, in a circle, or in an arrangement that supports conversation. For each template, the system uses the size of your content to determine how far away to place spatial Personas. For example, the system moves them farther away when your content is larger, and brings them closer together when your content is small. To override this distance, add the contentExtent(_:) modifier to your template preference. That modifier tells the system to use the distance you specify, instead of the content size.

If you enable support for spatial Personas in a Full Space, you must do additional work to support that experience. When an activity moves to a Full Space, the system creates a shared coordinate system for all participants and the content. In this new coordinate system, participants are no longer at the center of the coordinate system, which might require you to update the position of your content. For more information, see Place content in an immersive space.

Synchronize additional data when spatial Personas are visible

When spatial Personas are visible, it’s your responsibility to maintain the shared context for your activity. The shared context includes both the content you display and how that content appears to each participant. People can gesture or look at content using their spatial Persona, so it’s important that everyone sees the same thing when that happens. For example, Freeform synchronizes both the page content and the current scroll position to ensure everyone sees the same portion of the page.

When defining an activity, define additional data messages to synchronize any information you need to maintain the shared context. When the current participant shows their spatial Persona, send the extra messages when the participant makes relevant changes to the activity. For example, send them when the person scrolls the activity window. Similarly, receive those messages and incorporate the results into your app when the spatial Persona is visible.

To determine when someone’s spatial Persona is visible, monitor the localParticipantStates of your session’s SystemCoordinator object. The AsyncSequence in this property reports participant-related state changes, including changes to the visibility of their spatial Persona. Get the isSpatial property of the returned SystemCoordinator.ParticipantState structure and use it to configure your app’s behavior. The following example uses a task to update the game state to accommodate spatial Personas. When the current participant is spatial, the game sends additional messages to maintain the shared context.

Task {
    for await state in coordinator.localParticipantStates {
        if state.isSpatial {
            // Synchronize additional data for the shared context.
            gameModel.isSpatial = true
        } else {
            // Don't synchronize additional data.
            gameModel.isSpatial = false
        }
    }
}

Update the immersion level automatically for a Full Space

If one participant opens an immersive space as part of an activity, the system doesn’t automatically open the same immersive space for other participants. Making a transition to an immersive space is a significant change, and some participants might not want to make the transition right away. For example, if a participant is on a phone call, they might not want another app to open an immersive space and hide their call. Instead, the system reports when transitions to immersive spaces occur, and lets you decide when to transition other participants automatically.

To determine when any participant transitions to an immersive space, monitor the groupImmersionStyle property of your SystemCoordinator object. This property contains an AsyncSequence that reports the most recent immersion style that a participant adopts. When a participant presents an immersive space, or when they change the immersion style of the current space, the system updates the sequence with the new value. The following example shows a task that opens the immersive space with the same style as the group immersion style.

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

The system reports nil for the immersion style when a participant dismisses their open immersive space. Use that option to dismiss the space for other participants and return to a window or volume.

Place content in an immersive space

When an activity takes place in an immersive space, the system creates a shared coordinate system for the participants and content. In this new coordinate space, the origin of the coordinate space is not the same as the origin of any of the participants. If an activity-related window or volume is visible, the system places the window or volume at the new origin. If the activity doesn’t use a window or volume, the system arranges the participants in a circle and sets the origin of the coordinate space to the circle’s center. The following figure shows the origin of the shared coordinate system for a window, volume, and immersive space relative to several spatial Personas.

If the immersive space is already open before SharePlay begins, you can use the immersiveSpaceDisplacement environment value to detect when the coordinate space changes and get the current offset of the immersive space relative to its default position. When the value is identity, the immersive space has no offset. When the value changes to a non-identity pose, SharePlay has established a shared coordinate space.

To position content relative to a participant in the shared coordinate space, use the localParticipantState property of your SystemCoordinator to access the local participant’s pose. This property provides the participant’s offset from the spatial template origin. The following example shows how to place an object at a consistent position in front of the local participant:

@Environment(\.immersiveSpaceDisplacement) var immersiveSpaceDisplacement

func placeObject(systemCoordinator: SystemCoordinator) -> SIMD3<Float> {
    // Get the local participant's position relative to the spatial template origin.
    let localPose = systemCoordinator.localParticipantState.pose
    
    // Place the object in front of and slightly below the participant.
    let offset = SIMD3<Float>(x: 0, y: -0.2, z: -0.5)
    
    // Add the offset to the participant's position.
    return localPose.position + offset
}

The displacement between a person and the origin of the scene doesn’t change during the course of your activity even when the participant moves. The system sets the origin of an immersive space when you first present it, and updates it only once when changing it to a shared coordinate space.

To place content in front of a specific participant, observe remoteParticipantStates and use the pose property of SystemCoordinator.ParticipantState to get each remote participant’s position in the shared space:

Task {
    let observedRemoteParticipantStates = Observations {
        return systemCoordinator.remoteParticipantStates
    }
    for await observedRemoteParticipantState in observedRemoteParticipantStates {
        for (participant, state) in observedRemoteParticipantState {
            // Place presented content relative to the remote participant pose.
            placeContentNear(participant, at: participantState.pose)
        }
    }
}

See Also

Spatial activities