Développer une UI spatiale avec Jetpack Compose for XR

Appareils XR concernés
Ces conseils vous aident à créer des expériences pour ces types d'appareils XR.
Casques XR
Lunettes XR filaires

Avec Jetpack Compose pour XR, vous pouvez créer de manière déclarative votre UI et votre mise en page spatiales à l'aide de concepts Compose familiers tels que les lignes et les colonnes. Vous pouvez ainsi étendre votre UI Android existante à l'espace 3D ou créer des applications 3D immersives entièrement nouvelles.

Si vous spatialisez une application Android basée sur des vues existante, plusieurs options de développement s'offrent à vous. Vous pouvez utiliser des API d'interopérabilité, utiliser Compose et Views ensemble, ou travailler directement avec la bibliothèque SceneCore. Pour en savoir plus, consultez notre guide sur l'utilisation des vues.

À propos des sous-espaces et des composants spatialisés

Lorsque vous écrivez votre application pour Android XR, il est important de comprendre les concepts de sous-espace et de composants spatialisés.

À propos des sous-espaces

Lorsque vous développez pour Android XR, vous devez ajouter un sous-espace à votre application ou à votre mise en page. Un sous-espace est une partition d'un espace 3D au sein de votre application. Vous pouvez y placer du contenu 3D, créer des mises en page 3D et ajouter de la profondeur à des contenus qui seraient en 2D autrement. Un sous-espace n'est rendu que lorsque la spatialisation est activée. Dans l'affichage restreint ou sur les appareils non XR, tout code contenu dans ce sous-espace est ignoré.

Il existe plusieurs façons de créer un sous-espace :

  • Subspace: ce composable crée une hiérarchie d'UI spatiale nouvelle et indépendante. Il n'hérite pas de la position spatiale, de l'orientation ni de l'échelle d'un Subspace parent dans lequel il est imbriqué. Subspace est automatiquement lié à la zone de contenu recommandée par le système.
  • PlanarEmbeddedSubspace: ce composable peut être placé dans la hiérarchie d'UI de votre application, ce qui vous permet de conserver des mises en page pour l'UI 2D et spatiale. PlanarEmbeddedSubspace respecte les contraintes et le positionnement de son parent. Le contenu 3D placé à l'intérieur est ensuite positionné par rapport à cette zone définie en 2D.

Pour en savoir plus, consultez la section Ajouter un sous-espace à votre application.

À propos des composants spatialisés

Composables de sous-espace : ces composants ne peuvent être rendus que dans un sous-espace. Ils doivent être placés dans Subspace avant d'être placés dans une mise en page 2D. Un SubspaceModifier vous permet d'ajouter des attributs tels que la profondeur, le décalage et le positionnement à vos composables de sous-espace.

D'autres composants spatialisés n'ont pas besoin d'être appelés dans un sous-espace. Ils se composent d'éléments 2D classiques encapsulés dans un conteneur spatial. Ces éléments peuvent être utilisés dans des mises en page 2D ou 3D s'ils sont définis pour les deux. Lorsque la spatialisation n'est pas activée, leurs fonctionnalités spatialisées sont ignorées et elles reviennent à leurs équivalents 2D.

Créer un panneau spatial

Un SpatialPanel est un composable de sous-espace qui vous permet d'afficher le contenu de l'application. Par exemple, vous pouvez afficher la lecture vidéo, des images fixes ou tout autre contenu dans un panneau spatial.

Exemple de panneau d'UI spatiale

Vous pouvez utiliser SubspaceModifier pour modifier la taille, le comportement et le positionnement du panneau spatial, comme illustré dans l'exemple suivant.

Subspace {
    SpatialPanel(
        SubspaceModifier
            .height(824.dp)
            .width(1400.dp),
        dragPolicy = MovePolicy(),
        resizePolicy = ResizePolicy(),
    ) {
        SpatialPanelContent()
    }
}

@Composable
fun SpatialPanelContent() {
    Box(
        Modifier
            .background(color = Color.Black)
            .height(500.dp)
            .width(500.dp),
        contentAlignment = Alignment.Center
    ) {
        Text(
            text = "Spatial Panel",
            color = Color.White,
            fontSize = 25.sp
        )
    }
}

Points clés concernant le code

  • Étant donné que les API SpatialPanel sont des composables de sous-espace, vous devez les appeler dans Subspace. Si vous les appelez en dehors d'un sous-espace, une exception est générée.
  • La taille du SpatialPanel a été définie à l'aide des spécifications height et width sur le SubspaceModifier. Si vous omettez ces spécifications, la taille du panneau est déterminée par les mesures de son contenu.
  • Autorisez l'utilisateur à déplacer un panneau en ajoutant un movable modificateur de sous-espace.
  • Autorisez l'utilisateur à redimensionner un panneau en ajoutant un resizable modificateur de sous-espace.
  • Pour en savoir plus sur le dimensionnement et le positionnement, consultez nos conseils de conception de panneaux spatiaux. Pour en savoir plus sur l'implémentation du code, consultez notre documentation de référence.

Fonctionnement du modificateur movable

Par défaut, lorsqu'un utilisateur éloigne un panneau, le modificateur movable met le panneau à l'échelle de la même manière que le système redimensionne les panneaux dans l'affichage restreint. Tout le contenu enfant hérite de ce comportement. Pour désactiver cette fonctionnalité, définissez le paramètre shouldScaleWithDistance sur false.

Créer un orbiteur

Un orbiteur est un composant d'UI spatiale. Il est conçu pour être associé à un panneau spatial ou à un composant de mise en page spatiale correspondant, tel que SpatialColumn, SpatialRow, ou SpatialBox. Un orbiteur contient généralement des éléments de navigation et d'action contextuelle liés à l'entité à laquelle il est ancré. Par exemple, si vous avez créé un panneau spatial pour afficher du contenu vidéo, vous pouvez ajouter des commandes de lecture vidéo dans un orbiteur.

Exemple d'orbiteur

Comme illustré dans l'exemple suivant, appelez un orbiteur dans la mise en page 2D d'un SpatialPanel pour encapsuler les commandes utilisateur telles que la navigation. Cela les extrait de votre mise en page 2D et les associe au panneau spatial en fonction de votre configuration.

Subspace {
    SpatialPanel(
        SubspaceModifier
            .height(824.dp)
            .width(1400.dp),
        dragPolicy = MovePolicy(),
        resizePolicy = ResizePolicy(),
    ) {
        SpatialPanelContent()
        OrbiterExample()
    }
}

@Composable
fun OrbiterExample() {
    Orbiter(
        position = ContentEdge.Bottom,
        offset = 96.dp,
        alignment = Alignment.CenterHorizontally
    ) {
        Surface(Modifier.clip(CircleShape)) {
            Row(
                Modifier
                    .background(color = Color.Black)
                    .height(100.dp)
                    .width(600.dp),
                horizontalArrangement = Arrangement.Center,
                verticalAlignment = Alignment.CenterVertically
            ) {
                Text(
                    text = "Orbiter",
                    color = Color.White,
                    fontSize = 50.sp
                )
            }
        }
    }
}

Points clés concernant le code

  • Étant donné que les orbiteurs sont des composants d'UI spatiale, le code peut être réutilisé dans des mises en page 2D ou 3D. Dans une mise en page 2D, votre application ne rend que le contenu à l'intérieur de l'orbiteur et ignore l'orbiteur lui-même.
  • Pour en savoir plus sur l'utilisation et la conception des orbiteurs, consultez nos conseils de conception.

Ajouter plusieurs panneaux spatiaux à une mise en page spatiale

Vous pouvez créer plusieurs panneaux spatiaux et les placer dans une mise en page spatiale à l'aide de SpatialRow, SpatialColumn, SpatialBox, et SpatialSpacer.

Exemple de plusieurs panneaux spatiaux dans une mise en page spatiale

L'exemple de code suivant montre comment procéder :

Subspace {
    SpatialRow {
        SpatialColumn {
            SpatialPanel(SubspaceModifier.height(250.dp).width(400.dp)) {
                SpatialPanelContent("Top Left")
            }
            SpatialPanel(SubspaceModifier.height(200.dp).width(400.dp)) {
                SpatialPanelContent("Middle Left")
            }
            SpatialPanel(SubspaceModifier.height(250.dp).width(400.dp)) {
                SpatialPanelContent("Bottom Left")
            }
        }
        SpatialColumn {
            SpatialPanel(SubspaceModifier.height(250.dp).width(400.dp)) {
                SpatialPanelContent("Top Right")
            }
            SpatialPanel(SubspaceModifier.height(200.dp).width(400.dp)) {
                SpatialPanelContent("Middle Right")
            }
            SpatialPanel(SubspaceModifier.height(250.dp).width(400.dp)) {
                SpatialPanelContent("Bottom Right")
            }
        }
    }
}

@Composable
fun SpatialPanelContent(text: String) {
    Column(
        Modifier
            .background(color = Color.Black)
            .fillMaxSize(),
        horizontalAlignment = Alignment.CenterHorizontally,
        verticalArrangement = Arrangement.Center
    ) {
        Text(
            text = "Panel",
            color = Color.White,
            fontSize = 15.sp
        )
        Text(
            text = text,
            color = Color.White,
            fontSize = 25.sp,
            fontWeight = FontWeight.Bold
        )
    }
}

Points clés concernant le code

  • SpatialRow, SpatialColumn, SpatialBox, et SpatialSpacer sont tous des composables de sous-espace et doivent être placés dans un sous-espace.
  • Utilisez SubspaceModifier pour personnaliser votre mise en page.
  • Pour les mises en page comportant plusieurs panneaux sur une même ligne, nous vous recommandons de définir un rayon de courbe de 825 dp à l'aide d'un SubspaceModifier afin que les panneaux entourent votre utilisateur. Pour en savoir plus, consultez nos conseils de conception.

Ajouter un objet 3D à votre mise en page à l'aide de SpatialGltfModel

Android XR est compatible avec le format glTF pour les modèles 3D, généralement enregistrés sous forme de fichiers .glb. Pour ajouter ces objets à votre mise en page, vous devez utiliser le SpatialGltfModel composable. Cette API simplifie le processus de chargement des éléments et de gestion de leur état.

Pour afficher un modèle, définissez d'abord sa source et son état à l'aide de rememberSpatialGltfModelState. Vous pouvez charger des modèles à partir du dossier assets de votre application, d'un URI ou de raw data.

val modelState = rememberSpatialGltfModelState(
    source = SpatialGltfModelSource.fromPath(
        Paths.get("models/model_name.glb")
    )
)

Une fois l'état défini, utilisez le composable SpatialGltfModel pour le rendre dans un sous-espace.

SpatialGltfModel(state = modelState, modifier = SubspaceModifier)

Points clés concernant le code

  • Chargement asynchrone : le modèle est chargé de manière asynchrone. Lors de la composition initiale, sa taille intrinsèque peut être nulle. La mise en page est remesurée une fois le modèle prêt.
  • Contrôle de l'état : utilisez SpatialGltfModelState.status pour interroger l'état de chargement ou contrôler les animations.
  • Dimensionnement et mise à l'échelle : par défaut, la taille de la mise en page correspond au cadre de délimitation de l'élément. Vous pouvez remplacer ce comportement par un SubspaceModifier.size pour mettre le modèle à l'échelle de manière uniforme afin qu'il s'adapte aux limites spécifiées.

Utiliser une SceneCoreEntity pour placer des entités dans votre mise en page

Le SceneCoreEntity composable relie les bibliothèques Jetpack SceneCore et Compose pour XR afin que vous puissiez utiliser des entités créées avec SceneCore dans des mises en page Compose. Cela vous permet de créer des entités de niveau inférieur et des composants personnalisés tout en permettant à Compose de dimensionner, de positionner, de reparenter, d'ajouter des enfants et d'appliquer des modificateurs à ces entités.

Subspace {
    SceneCoreEntity(
        modifier = SubspaceModifier.offset(x = 50.dp),
        factory = {
            SurfaceEntity.create(
                session = session,
                pose = Pose.Identity,
                stereoMode = SurfaceEntity.StereoMode.MONO
            )
        },
        update = { entity ->
            // compose state changes may be applied to the
            // SceneCore entity here.
            entity.stereoMode = SurfaceEntity.StereoMode.SIDE_BY_SIDE
        },
        sizeAdapter =
            SceneCoreEntitySizeAdapter({
                IntSize2d(it.width, it.height)
            }),
    ) {
        // Content here will be children of the SceneCoreEntity
        // in the scene graph.
    }
}

Points clés concernant le code

  • Bloc d'usine : le bloc d'usine est l'endroit où vous initialisez l'entité SceneCore sous-jacente.
  • Bloc de mise à jour : utilisez le bloc de mise à jour pour modifier les propriétés de l'entité en réponse aux modifications apportées à votre état Compose.
  • Adaptation de la taille : le sizeAdapter communique les dimensions de l'entité au système de mise en page Compose.

Informations supplémentaires

Ajouter une surface pour le contenu image ou vidéo

Un SpatialExternalSurface est un composable de sous-espace qui crée et gère la Surface dans laquelle votre application peut dessiner du contenu, tel qu'une image ou vidéo. SpatialExternalSurface est compatible avec le contenu stéréoscopique ou monoscopique.

Cet exemple montre comment charger une vidéo stéréoscopique côte à côte à l'aide de Media3 Exoplayer et SpatialExternalSurface :

@OptIn(ExperimentalComposeApi::class)
@Composable
fun SpatialExternalSurfaceContent() {
    val context = LocalContext.current
    Subspace {
        SpatialExternalSurface(
            modifier = SubspaceModifier
                .width(1200.dp) // Default width is 400.dp if no width modifier is specified
                .height(676.dp), // Default height is 400.dp if no height modifier is specified
            // Use StereoMode.Mono, StereoMode.SideBySide, or StereoMode.TopBottom, depending
            // upon which type of content you are rendering: monoscopic content, side-by-side stereo
            // content, or top-bottom stereo content
            stereoMode = StereoMode.SideBySide,
        ) {
            val exoPlayer = remember { ExoPlayer.Builder(context).build() }
            val videoUri = Uri.Builder()
                .scheme(ContentResolver.SCHEME_ANDROID_RESOURCE)
                // Represents a side-by-side stereo video, where each frame contains a pair of
                // video frames arranged side-by-side. The frame on the left represents the left
                // eye view, and the frame on the right represents the right eye view.
                .path("sbs_video.mp4")
                .build()
            val mediaItem = MediaItem.fromUri(videoUri)

            // onSurfaceCreated is invoked only one time, when the Surface is created
            onSurfaceCreated { surface ->
                exoPlayer.setVideoSurface(surface)
                exoPlayer.setMediaItem(mediaItem)
                exoPlayer.prepare()
                exoPlayer.play()
            }
            // onSurfaceDestroyed is invoked when the SpatialExternalSurface composable and its
            // associated Surface are destroyed
            onSurfaceDestroyed { exoPlayer.release() }
        }
    }
}

Points clés concernant le code

  • Définissez StereoMode sur Mono, SideBySide, ou TopBottom en fonction du type de contenu que vous affichez :
    • Mono: l'image ou la trame vidéo se compose d'une seule image identique affichée aux deux yeux.
    • SideBySide: l'image ou la trame vidéo contient une paire d'images ou de trames vidéo disposées côte à côte, où l'image ou la trame de gauche représente la vue de l'œil gauche, et l'image ou la trame de droite représente la vue de l'œil droit.
    • TopBottom: l'image ou la trame vidéo contient une paire d'images ou de trames vidéo empilées verticalement, où l'image ou la trame du haut représente la vue de l'œil gauche, et l'image ou la trame du bas représente la vue de l'œil droit.
  • SpatialExternalSurface n'est compatible qu'avec les surfaces rectangulaires.
  • Cette Surface ne capture pas les événements d'entrée.
  • Il n'est pas possible de synchroniser les modifications StereoMode avec le rendu de l'application ou le décodage vidéo.
  • Ce composable ne peut pas être rendu devant d'autres panneaux. Vous ne devez donc pas utiliser de MovePolicy si d'autres panneaux sont présents dans la mise en page.

Ajouter une surface pour le contenu vidéo protégé par DRM

SpatialExternalSurface est également compatible avec la lecture de flux vidéo protégés par DRM. Pour activer cette fonctionnalité, vous devez créer une surface sécurisée qui s'affiche dans des tampons graphiques protégés. Cela empêche l'enregistrement de l'écran ou l'accès au contenu par des composants système non sécurisés.

Pour créer une surface sécurisée, définissez le surfaceProtection paramètre sur SurfaceProtection.Protected dans le SpatialExternalSurface composable. De plus, vous devez configurer Media3 Exoplayer avec les informations DRM appropriées pour gérer l'acquisition de licence à partir d'un serveur de licences.

L'exemple suivant montre comment configurer SpatialExternalSurface et ExoPlayer pour lire un flux vidéo protégé par DRM :

@OptIn(ExperimentalComposeApi::class)
@Composable
fun DrmSpatialVideoPlayer() {
    val context = LocalContext.current
    Subspace {
        SpatialExternalSurface(
            modifier = SubspaceModifier
                .width(1200.dp)
                .height(676.dp),
            stereoMode = StereoMode.SideBySide,
            surfaceProtection = SurfaceProtection.Protected
        ) {
            val exoPlayer = remember { ExoPlayer.Builder(context).build() }

            // Define the URI for your DRM-protected content and license server.
            val videoUri = "https://your-content-provider.com/video.mpd"
            val drmLicenseUrl = "https://your-license-server.com/license"

            // Build a MediaItem with the necessary DRM configuration.
            val mediaItem = MediaItem.Builder()
                .setUri(videoUri)
                .setDrmConfiguration(
                    MediaItem.DrmConfiguration.Builder(C.WIDEVINE_UUID)
                        .setLicenseUri(drmLicenseUrl)
                        .build()
                )
                .build()

            onSurfaceCreated { surface ->
                // The created surface is secure and can be used by the player.
                exoPlayer.setVideoSurface(surface)
                exoPlayer.setMediaItem(mediaItem)
                exoPlayer.prepare()
                exoPlayer.play()
            }

            onSurfaceDestroyed { exoPlayer.release() }
        }
    }
}

Points clés concernant le code

  • Surface protégée : il est essentiel de définir surfaceProtection = SurfaceProtection.Protected sur SpatialExternalSurface afin que la Surface sous-jacente soit soutenue par des tampons sécurisés adaptés au contenu DRM.
  • Configuration DRM : vous devez configurer le MediaItem avec le schéma DRM (par exemple, C.WIDEVINE_UUID) et l'URI de votre serveur de licences. ExoPlayer utilise ces informations pour gérer la session DRM.
  • Contenu sécurisé : lors du rendu sur une surface protégée, le contenu vidéo est décodé et affiché sur un chemin sécurisé, ce qui permet de répondre aux exigences de licence de contenu. Cela empêche également le contenu d'apparaître dans les captures d'écran.

Ajouter d'autres composants d'UI spatiale

Les composants d'UI spatiale peuvent être placés n'importe où dans la hiérarchie d'UI de votre application. Ces éléments peuvent être réutilisés dans votre UI 2D, et leurs attributs spatiaux ne seront visibles que lorsque les fonctionnalités spatiales seront activées. Cela vous permet d'ajouter de l'élévation aux menus, aux boîtes de dialogue et à d'autres composants sans avoir à écrire votre code deux fois. Consultez les exemples suivants d'UI spatiale pour mieux comprendre comment utiliser ces éléments.

Composant d'UI

Lorsque la spatialisation est activée

Dans un environnement 2D

SpatialDialog

Le panneau recule légèrement en profondeur Z pour afficher une boîte de dialogue surélevée.

Revient à Dialog en 2D.

SpatialPopup

Le panneau recule légèrement en profondeur Z pour afficher une fenêtre pop-up surélevée.

Revient à Popup en 2D.

SpatialElevation

SpatialElevationLevel peut être défini pour ajouter de l'altitude.

S'affiche sans élévation spatiale.

SpatialDialog

Voici un exemple de boîte de dialogue qui s'ouvre après un court délai. Lorsque SpatialDialog est utilisé, la boîte de dialogue apparaît à la même profondeur Z que le panneau spatial, et le panneau est repoussé de 125 dp lorsque la spatialisation est activée. SpatialDialog peut également être utilisé lorsque la spatialisation n'est pas activée. Dans ce cas, SpatialDialog revient à son équivalent 2D, Dialog.

@Composable
fun DelayedDialog() {
    var showDialog by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        delay(3000)
        showDialog = true
    }
    if (showDialog) {
        SpatialDialog(
            onDismissRequest = { showDialog = false },
            SpatialDialogProperties(
                dismissOnBackPress = true
            )
        ) {
            Box(
                Modifier
                    .height(150.dp)
                    .width(150.dp)
            ) {
                Button(onClick = { showDialog = false }) {
                    Text("OK")
                }
            }
        }
    }
}

Points clés concernant le code

Créer des panneaux et des mises en page personnalisés

Pour créer des panneaux personnalisés qui ne sont pas compatibles avec Compose pour XR, vous pouvez travailler directement avec PanelEntity instances et le graphe de scène à l'aide des SceneCore API.

Ancrer des orbiteurs à des panneaux et des mises en page spatiaux

Vous pouvez ancrer un orbiteur à des SpatialPanels et à des composants de mise en page spatiale déclarés dans Compose. Cela implique de déclarer un orbiteur dans une mise en page spatiale d'éléments d'UI tels que SpatialRow, SpatialColumn ou SpatialBox. L'orbiteur s'ancre au parent le plus proche de l'endroit où vous l'avez déclaré.

Le comportement de l'orbiteur est déterminé par l'endroit où vous le déclarez :

  • Dans une mise en page 2D encapsulée dans un SpatialPanel (comme illustré dans un extrait de code précédent), l'orbiteur s'ancre à ce SpatialPanel.
  • Dans un Subspace, l'orbiteur s'ancre à l'entité parente la plus proche, qui est la mise en page spatiale dans laquelle l'orbiteur est déclaré.

L'exemple suivant montre comment ancrer un orbiteur à une ligne spatiale :

Subspace {
    SpatialRow {
        Orbiter(
            position = ContentEdge.Top,
            offset = 8.dp,
            offsetType = OrbiterOffsetType.InnerEdge,
            shape = SpatialRoundedCornerShape(size = CornerSize(50))
        ) {
            Text(
                "Hello World!",
                style = MaterialTheme.typography.titleMedium,
                modifier = Modifier
                    .background(Color.White)
                    .padding(16.dp)
            )
        }
        SpatialPanel(
            SubspaceModifier
                .height(824.dp)
                .width(1400.dp)
        ) {
            Box(
                modifier = Modifier
                    .background(Color.Red)
            )
        }
        SpatialPanel(
            SubspaceModifier
                .height(824.dp)
                .width(1400.dp)
        ) {
            Box(
                modifier = Modifier
                    .background(Color.Blue)
            )
        }
    }
}

Points clés concernant le code

  • Lorsque vous déclarez un orbiteur en dehors d'une mise en page 2D, l'orbiteur s'ancre à son entité parente la plus proche. Dans ce cas, l'orbiteur s'ancre en haut de la SpatialRow dans laquelle il est déclaré.
  • Les mises en page spatiales telles que SpatialRow, SpatialColumn et SpatialBox sont toutes associées à des entités sans contenu. Par conséquent, un orbiteur déclaré dans une mise en page spatiale s'ancre à cette mise en page.

Voir aussi