使用 Jetpack Compose for XR 開發空間 UI

適用的 XR 裝置
這份指南可協助您為這類 XR 裝置打造體驗。
XR 頭戴式裝置
有線 XR 眼鏡

使用 Jetpack Compose for XR,您可以運用熟悉的 Compose 概念 (例如列和欄),以宣告方式建構空間 UI 和版面配置。您可以藉此將現有的 Android UI 擴展到 3D 空間,或建構全新的沉浸式 3D 應用程式。

如要將現有的 Android Views 應用程式空間化,可以選擇幾種開發方式。您可以運用互通性 API、同時使用 Compose 和 Views,或直接使用 SceneCore 程式庫。詳情請參閱檢視畫面使用指南

關於子空間和空間化元件

為 Android XR 編寫應用程式時,請務必瞭解子空間空間化元件的概念。

關於子空間

為 Android XR 開發應用程式時,您需要在應用程式或版面配置中新增子空間。子空間是應用程式內 3D 空間的分區,可在其中放置 3D 內容、建構 3D 版面配置,以及為其他 2D 內容加上深度。只有在啟用空間化功能時,才會算繪子空間。在主畫面或非 XR 裝置上,系統會忽略該子空間中的任何程式碼。

建立子空間的方法有幾種:

  • Subspace:這個可組合函式會建立新的獨立空間 UI 層級。不會繼承任何父項 Subspace 的空間位置、方向或比例。Subspace 會自動繫結至系統建議的內容方塊。
  • PlanarEmbeddedSubspace:這個可組合函式可放置在應用程式的 UI 階層中,讓您維護 2D 和空間 UI 的版面配置。PlanarEmbeddedSubspace 會遵守父項的限制和定位。然後,系統會根據這個 2D 定義區域,放置其中的 3D 內容。

詳情請參閱「在應用程式中新增子空間」。

關於空間化元件

子空間可組合函式:這些元件只能在子空間中算繪。 必須先以 Subspace 括住,才能放置在 2D 版面配置中。 SubspaceModifier 可讓您為子空間可組合函式新增深度、位移和定位等屬性。

其他空間化元件不需要在子空間內呼叫。這類元素由包裝在空間容器中的傳統 2D 元素組成。如果同時為 2D 和 3D 版面配置定義這些元素,即可在其中使用。如果未啟用空間化功能,系統會忽略空間化特徵,並改用 2D 對應特徵。

建立空間面板

SpatialPanel 是子空間可組合項,可顯示應用程式內容。舉例來說,您可以在空間面板中顯示影片播放畫面、靜態圖片或任何其他內容。

空間 UI 面板範例

您可以使用 SubspaceModifier 變更空間面板的大小、行為和位置,如下列範例所示。

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
        )
    }
}

程式碼重點

  • 由於 SpatialPanel API 是子空間可組合函式,因此您必須在 [Subspace][4] 內呼叫這些函式。在子空間外部呼叫這些函式會擲回例外狀況。
  • SpatialPanel 的大小已在 SubspaceModifier 上使用 heightwidth 規格設定。如果省略這些規格,系統會根據內容的測量結果決定面板大小。
  • 允許使用者新增 MovePolicy,移動面板。
  • 新增 ResizePolicy,允許使用者調整面板大小。
  • 如要瞭解大小和位置的詳細資訊,請參閱空間面板設計指南。如要進一步瞭解程式碼導入作業,請參閱參考文件

MovePolicy 的運作方式

根據預設,當使用者將面板移開時,MovePolicy 會以類似於系統在住家空間中調整面板大小的方式,縮放面板。所有子項內容都會沿用這項行為。如要停用這項功能,請將 shouldScaleWithDistance 參數設為 false

建立軌道器

軌道器是空間 UI 元件。這項函式旨在附加至對應的空間面板或空間版面配置元件,例如 SpatialColumnSpatialRowSpatialBox。軌跡球通常包含與所錨定實體相關的導覽和關聯動作項目。舉例來說,如果您已建立空間面板來顯示影片內容,可以在軌跡球內新增影片播放控制項。

軌道飛行器範例

如下列範例所示,在 2D 版面配置中呼叫軌跡球,以在 SpatialPanel 中包裝導覽等使用者控制項。這樣做會從 2D 版面配置中擷取這些項目,並根據設定附加至空間面板。

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
                )
            }
        }
    }
}

程式碼重點

  • 由於軌跡球是空間 UI 元件,因此程式碼可在 2D 或 3D 版面配置中重複使用。在 2D 版面配置中,應用程式只會算繪軌道器內的內容,並忽略軌道器本身。
  • 如要進一步瞭解如何使用及設計軌跡球,請參閱設計指南

在空間版面配置中新增多個空間面板

您可以使用 SpatialRowSpatialColumnSpatialBoxSpatialLayoutSpacer,建立多個空間面板並放置在空間版面配置中。

空間版面配置中的多個空間面板範例

以下程式碼範例說明如何執行這項操作。

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
        )
    }
}

程式碼重點

使用 SceneCoreEntity 將實體放在版面配置中

如要在版面配置中放置 3D 物件,您需要使用名為 SceneCoreEntity 的子空間可組合函式。以下提供範例。

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.
    }
}

其他資訊

新增圖片或影片內容的介面

SpatialExternalSurface 是可組合的子空間,可建立及管理應用程式可繪製內容 (例如圖片或影片) 的 SurfaceSpatialExternalSurface 支援立體或單視內容。

這個範例說明如何使用 Media3 ExoplayerSpatialExternalSurface 載入並排立體影片:

@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() }
        }
    }
}

程式碼重點

  • 根據您要算繪的內容類型,將 StereoMode 設為 MonoSideBySideTopBottom
    • Mono:影像或影片畫面由單一相同圖像組成,並顯示在雙眼。
    • SideBySide:圖片或影片影格包含並排排列的一對圖片或影片影格,左側的圖片或影格代表左眼視角,右側的圖片或影格代表右眼視角。
    • TopBottom:圖片或影片影格包含一組垂直堆疊的圖片或影片影格,其中頂端的圖片或影格代表左眼視角,底部的圖片或影格則代表右眼視角。
  • SpatialExternalSurface 僅支援矩形表面。
  • 這個 Surface 不會擷取輸入事件。
  • 無法將 StereoMode 變更內容與應用程式算繪或影片解碼同步處理。
  • 這個可組合項無法在其他面板前方算繪,因此如果版面配置中有其他面板,就不應使用 MovePolicy

為受 DRM 保護的影片內容新增介面

SpatialExternalSurface 也支援播放受數位版權管理保護的影片串流。如要啟用這項功能,您必須建立安全介面,並將其算繪至受保護的圖像緩衝區。這樣可防止內容遭到螢幕錄影,或遭不安全的系統元件存取。

如要建立安全介面,請在 SpatialExternalSurface 可組合函式中,將 surfaceProtection 參數設為 SurfaceProtection.Protected。 此外,您必須使用適當的 DRM 資訊設定 Media3 Exoplayer,才能處理授權伺服器傳送的授權。

以下範例說明如何設定 SpatialExternalSurfaceExoPlayer,播放受 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() }
        }
    }
}

程式碼重點

  • 受保護的介面:設定 surfaceProtection = SurfaceProtection.Protected SpatialExternalSurface 至關重要,這樣底層 Surface 才能由適用於 DRM 內容的安全緩衝區支援。
  • DRM 設定:您必須使用 DRM 方案 (例如 C.WIDEVINE_UUID) 和授權伺服器的 URI 設定 MediaItem。ExoPlayer 會使用這項資訊管理 DRM 工作階段。
  • 安全內容:轉譯至受保護的介面時,影片內容會透過安全路徑解碼及顯示,有助於滿足內容授權需求。這麼做也能防止內容出現在螢幕截圖中。

新增其他空間 UI 元件

空間 UI 元件可以放在應用程式 UI 層級的任何位置。 這些元素可在 2D UI 中重複使用,且只有在啟用空間功能時,才會顯示空間屬性。這樣一來,您就能為選單、對話方塊和其他元件新增高度,不必重複編寫程式碼。請參閱下列空間 UI 範例,進一步瞭解如何使用這些元素。

UI 元件

啟用空間化功能時

在 2D 環境中

SpatialDialog

面板會稍微向後推移,顯示高架對話方塊

改回 2D Dialog

SpatialPopup

面板會稍微向後推移,顯示彈出式視窗

改用 2D Popup

SpatialElevation

SpatialElevationLevel 可設定為新增海拔高度。

沒有空間升高的節目。

SpatialDialog

這是延遲一小段時間後開啟的對話方塊範例。使用 SpatialDialog 時,對話方塊會顯示在與空間面板相同的 z 深度,且啟用空間化時,面板會往後推 125 dp。如果未啟用空間化功能,也可以使用 SpatialDialog,此時 SpatialDialog 會改用 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")
                }
            }
        }
    }
}

程式碼重點

建立自訂面板和版面配置

如要建立 Compose for XR 不支援的自訂面板,可以使用 SceneCore API 直接處理 PanelEntity 執行個體和場景圖。

將軌跡球錨定至空間面板和版面配置

您可以將軌跡球錨定至 Compose 中宣告的 SpatialPanels 和空間版面配置元件。這包括在 UI 元素的空間布局中宣告軌道器,例如 SpatialRowSpatialColumnSpatialBox。軌域會錨定至最靠近您宣告位置的父項。

軌跡球的行為取決於您宣告的位置:

  • 在以 SpatialPanel 包裝的 2D 版面配置中 (如前述程式碼片段所示),軌跡球會錨定至該 SpatialPanel
  • Subspace 中,軌道器會錨定至最近的父系實體,也就是軌道器宣告所在的空間版面配置。

以下範例說明如何將軌跡球錨定至空間列:

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)
            )
        }
    }
}

程式碼重點

  • 在 2D 版面配置外宣告軌跡球時,軌跡球會錨定至最接近的父系實體。在本例中,軌跡球會錨定至所宣告 SpatialRow 的頂端。
  • SpatialRowSpatialColumnSpatialBox 等空間版面配置都與沒有內容的實體相關聯。因此,在空間版面配置中宣告的軌跡球會錨定至該版面配置。

另請參閱