XR용 Jetpack Compose로 UI 개발

XR용 Jetpack Compose를 사용하면 행, 열과 같은 익숙한 Compose 개념을 사용하여 공간 UI와 레이아웃을 선언적으로 빌드할 수 있습니다. 이를 통해 기존 Android UI를 3D 공간으로 확장하거나 완전히 새로운 몰입형 3D 애플리케이션을 빌드할 수 있습니다.

기존 Android Views 기반 앱을 공간화하는 경우 몇 가지 개발 옵션이 있습니다. 상호 운용성 API를 사용하거나, Compose와 뷰를 함께 사용하거나, SceneCore 라이브러리를 직접 사용할 수 있습니다. 자세한 내용은 뷰 작업 가이드를 참고하세요.

서브스페이스 및 공간화된 구성요소 정보

Android XR용 앱을 작성할 때는 하위 공간공간화된 구성요소의 개념을 이해하는 것이 중요합니다.

하위 스페이스 정보

Android XR용으로 개발할 때는 앱이나 레이아웃에 Subspace를 추가해야 합니다. 하위 공간은 앱 내의 3D 공간 파티션으로, 여기에서 3D 콘텐츠를 배치하고 3D 레이아웃을 빌드하고 2D 콘텐츠에 깊이를 더할 수 있습니다. 서브스페이스는 공간화가 사용 설정된 경우에만 렌더링됩니다. 홈 공간 또는 XR이 아닌 기기에서는 해당 하위 공간 내의 코드가 무시됩니다.

하위 공간을 만드는 방법에는 두 가지가 있습니다.

  • Subspace: 이 컴포저블은 앱의 UI 계층 구조 내 어디에나 배치할 수 있으므로 파일 간 컨텍스트를 잃지 않고 2D 및 공간 UI의 레이아웃을 유지할 수 있습니다. 이렇게 하면 전체 UI 트리를 통해 상태를 호이스팅하거나 앱을 다시 설계하지 않고도 XR과 기타 폼 팩터 간에 기존 앱 아키텍처와 같은 항목을 더 쉽게 공유할 수 있습니다.
  • ApplicationSubspace: 이 함수는 앱 수준의 하위 공간만 생성하며 애플리케이션의 공간 UI 계층 구조에서 최상위 수준에 배치해야 합니다. ApplicationSubspace은 선택사항인 VolumeConstraints을 사용하여 공간 콘텐츠를 렌더링합니다. Subspace와 달리 ApplicationSubspace은 다른 Subspace 또는 ApplicationSubspace 내에 중첩될 수 없습니다.

자세한 내용은 앱에 하위 스페이스 추가하기를 참고하세요.

공간화된 구성요소 정보

하위 스페이스 컴포저블: 이러한 구성요소는 하위 스페이스에서만 렌더링할 수 있습니다. 2D 레이아웃 내에 배치되기 전에 Subspace 또는 setSubspaceContent() 내에 포함되어야 합니다. SubspaceModifier를 사용하면 하위 공간 컴포저블에 깊이, 오프셋, 위치 지정과 같은 속성을 추가할 수 있습니다.

다른 공간화된 구성요소는 하위 스페이스 내에서 호출할 필요가 없습니다. 공간 컨테이너 내에 래핑된 기존 2D 요소로 구성됩니다. 이러한 요소는 2D 및 3D 레이아웃 모두에 정의된 경우 2D 또는 3D 레이아웃 내에서 사용할 수 있습니다. 공간화가 사용 설정되지 않으면 공간화된 기능이 무시되고 2D 대응 항목으로 대체됩니다.

공간 패널 만들기

SpatialPanel는 앱 콘텐츠를 표시할 수 있는 하위 공간 컴포저블입니다. 예를 들어 공간 패널에 동영상 재생, 정지 이미지 또는 기타 콘텐츠를 표시할 수 있습니다.

공간 UI 패널의 예

다음 예와 같이 SubspaceModifier를 사용하여 공간 패널의 크기, 동작, 위치를 변경할 수 있습니다.

Subspace {
    SpatialPanel(
        SubspaceModifier
            .height(824.dp)
            .width(1400.dp)
            .movable()
            .resizable()
    ) {
        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 내에서 호출해야 합니다. 하위 공간 외부에서 호출하면 예외가 발생합니다.
  • SpatialPanel의 크기는 SubspaceModifier에서 heightwidth 사양을 사용하여 설정되었습니다. 이 사양을 생략하면 패널의 크기가 콘텐츠의 측정값에 따라 결정됩니다.
  • movable 또는 resizable 수정자를 추가하여 사용자가 패널의 크기를 조절하거나 이동할 수 있도록 합니다.
  • 크기 조정 및 위치 지정에 관한 자세한 내용은 공간 패널 디자인 안내를 참고하세요. 코드 구현에 관한 자세한 내용은 참조 문서를 참고하세요.

이동 가능한 하위 공간 수정자 작동 방식

사용자가 패널을 멀리 이동하면 기본적으로 이동 가능한 하위 공간 수정자가 홈 스페이스에서 시스템이 패널의 크기를 조절하는 방식과 유사하게 패널의 크기를 조절합니다. 모든 하위 콘텐츠가 이 동작을 상속합니다. 이 기능을 사용 중지하려면 scaleWithDistance 매개변수를 false로 설정하세요.

오비터 만들기

Orbiter는 공간 UI 구성요소입니다. 해당 공간 패널, 레이아웃 또는 기타 항목에 연결되도록 설계되었습니다. 오비터에는 일반적으로 고정된 항목과 관련된 탐색 및 컨텍스트 작업 항목이 포함됩니다. 예를 들어 동영상 콘텐츠를 표시하는 공간 패널을 만든 경우, 오비터 내에 동영상 재생 컨트롤을 추가할 수 있습니다.

오비터의 예

다음 예와 같이 SpatialPanel의 2D 레이아웃 내에서 어비터를 호출하여 탐색과 같은 사용자 컨트롤을 래핑합니다. 이렇게 하면 2D 레이아웃에서 추출되어 구성에 따라 공간 패널에 연결됩니다.

Subspace {
    SpatialPanel(
        SubspaceModifier
            .height(824.dp)
            .width(1400.dp)
            .movable()
            .resizable()
    ) {
        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 레이아웃에서 앱은 오비터 내부의 콘텐츠만 렌더링하고 오비터 자체는 무시합니다.
  • 오비터를 사용하고 디자인하는 방법에 관한 자세한 내용은 디자인 안내를 참고하세요.

공간 레이아웃에 여러 공간 패널 추가

SpatialRow, SpatialColumn, SpatialBox, SpatialLayoutSpacer를 사용하여 여러 공간 패널을 만들고 공간 레이아웃 내에 배치할 수 있습니다.

공간 레이아웃의 여러 공간 패널 예

다음 코드 예시에서는 이 작업을 수행하는 방법을 보여줍니다.

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

코드에 관한 핵심 사항

  • SpatialRow, SpatialColumn, SpatialBox, SpatialLayoutSpacer은 모두 하위 공간 컴포저블이며 하위 공간 내에 배치해야 합니다.
  • SubspaceModifier를 사용하여 레이아웃을 맞춤설정합니다.
  • 행에 패널이 여러 개 있는 레이아웃의 경우 SubspaceModifier를 사용하여 곡선 반지름을 825dp로 설정하여 패널이 사용자를 둘러싸도록 하는 것이 좋습니다. 자세한 내용은 디자인 안내를 참고하세요.

볼륨을 사용하여 레이아웃에 3D 객체 배치

레이아웃에 3D 객체를 배치하려면 볼륨이라는 서브스페이스 컴포저블을 사용해야 합니다. 다음은 그 방법을 보여주는 예입니다.

레이아웃의 3D 객체 예

Subspace {
    SpatialPanel(
        SubspaceModifier.height(1500.dp).width(1500.dp)
            .resizable().movable()
    ) {
        ObjectInAVolume(true)
        Box(
            Modifier.fillMaxSize(),
            contentAlignment = Alignment.Center
        ) {
            Text(
                text = "Welcome",
                fontSize = 50.sp,
            )
        }
    }
}

@OptIn(ExperimentalSubspaceVolumeApi::class)
@Composable
fun ObjectInAVolume(show3DObject: Boolean) {

추가 정보

  • 볼륨 내에서 3D 콘텐츠를 로드하는 방법을 자세히 알아보려면 앱에 3D 모델 추가를 참고하세요.

이미지 또는 동영상 콘텐츠용 화면 추가

SpatialExternalSurface는 앱이 이미지나 동영상과 같은 콘텐츠를 그릴 수 있는 Surface를 만들고 관리하는 하위 공간 컴포저블입니다. SpatialExternalSurface는 스테레오스코피 또는 모노스코피 콘텐츠를 지원합니다.

이 예에서는 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() }
        }
    }
}

코드에 관한 핵심 사항

  • 렌더링하는 콘텐츠 유형에 따라 StereoModeMono, SideBySide 또는 TopBottom로 설정합니다.
    • Mono: 이미지 또는 동영상 프레임이 양쪽 눈에 표시되는 단일한 동일한 이미지로 구성됩니다.
    • SideBySide: 이미지 또는 동영상 프레임에 나란히 배치된 이미지 또는 동영상 프레임 쌍이 포함되어 있습니다. 왼쪽의 이미지 또는 프레임은 왼쪽 눈 보기를 나타내고 오른쪽의 이미지 또는 프레임은 오른쪽 눈 보기를 나타냅니다.
    • TopBottom: 이미지 또는 동영상 프레임에 세로로 쌓인 이미지 또는 동영상 프레임 쌍이 포함되어 있습니다. 상단의 이미지 또는 프레임은 왼쪽 눈 보기를 나타내고 하단의 이미지 또는 프레임은 오른쪽 눈 보기를 나타냅니다.
  • SpatialExternalSurface는 직사각형 표면만 지원합니다.
  • Surface는 입력 이벤트를 캡처하지 않습니다.
  • StereoMode 변경사항을 애플리케이션 렌더링 또는 동영상 디코딩과 동기화할 수 없습니다.
  • 이 컴포저블은 다른 패널 앞에 렌더링할 수 없으므로 레이아웃에 다른 패널이 있는 경우 이동 가능한 수정자를 사용하면 안 됩니다.

DRM으로 보호된 동영상 콘텐츠를 위한 표시 경로 추가

SpatialExternalSurface은 DRM으로 보호되는 동영상 스트림의 재생도 지원합니다. 이를 사용 설정하려면 보호된 그래픽 버퍼에 렌더링되는 보안 표면을 만들어야 합니다. 이렇게 하면 콘텐츠가 화면 녹화되거나 보안되지 않은 시스템 구성요소에 의해 액세스되는 것을 방지할 수 있습니다.

보안 노출 영역을 만들려면 SpatialExternalSurface 컴포저블에서 surfaceProtection 매개변수를 SurfaceProtection.Protected으로 설정합니다. 또한 라이선스 서버에서 라이선스 획득을 처리할 수 있도록 적절한 DRM 정보로 Media3 Exoplayer를 구성해야 합니다.

다음 예에서는 DRM으로 보호된 동영상 스트림을 재생하도록 SpatialExternalSurfaceExoPlayer을 구성하는 방법을 보여줍니다.

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

코드에 관한 핵심 사항

  • 보호된 화면: 기본 Surface이 DRM 콘텐츠에 적합한 보안 버퍼로 지원되도록 SpatialExternalSurfacesurfaceProtection = SurfaceProtection.Protected를 설정해야 합니다.
  • DRM 구성: DRM 스키마(예: C.WIDEVINE_UUID) 및 라이선스 서버의 URI를 사용하여 MediaItem를 구성해야 합니다. ExoPlayer는 이 정보를 사용하여 DRM 세션을 관리합니다.
  • 보안 콘텐츠: 보호된 표면에 렌더링할 때 동영상 콘텐츠가 보안 경로에서 디코딩되고 표시되어 콘텐츠 라이선스 요구사항을 충족하는 데 도움이 됩니다. 이렇게 하면 콘텐츠가 화면 캡처에 표시되지 않습니다.

기타 공간 UI 구성요소 추가

공간 UI 구성요소는 애플리케이션의 UI 계층 구조 어디에나 배치할 수 있습니다. 이러한 요소는 2D UI에서 재사용할 수 있으며 공간 속성은 공간 기능이 사용 설정된 경우에만 표시됩니다. 이렇게 하면 코드를 두 번 작성하지 않고도 메뉴, 대화상자, 기타 구성요소에 고도를 추가할 수 있습니다. 공간 UI의 다음 예를 참고하여 이러한 요소를 사용하는 방법을 자세히 알아보세요.

UI 구성요소

공간화가 사용 설정된 경우

2D 환경

SpatialDialog

패널이 z축으로 약간 뒤로 밀려 올라간 대화상자가 표시됩니다.

2D Dialog로 대체됩니다.

SpatialPopup

패널이 z축으로 약간 뒤로 밀려 올라간 팝업이 표시됩니다.

2D Popup로 대체됩니다.

SpatialElevation

SpatialElevationLevel를 설정하여 고도를 추가할 수 있습니다.

공간 고도가 없는 쇼

SpatialDialog

다음은 짧은 지연 시간 후에 열리는 대화상자의 예입니다. SpatialDialog를 사용하면 대화상자가 공간 패널과 동일한 z-깊이에 표시되고 공간화가 사용 설정되면 패널이 125dp만큼 뒤로 밀립니다. 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에 선언된 모든 항목에 어비터를 고정할 수 있습니다. 여기에는 SpatialRow, SpatialColumn, SpatialBox와 같은 UI 요소의 공간 레이아웃에서 orbiter를 선언하는 작업이 포함됩니다. 트래커는 선언된 위치에 가장 가까운 상위 항목에 고정됩니다.

오비터의 동작은 선언된 위치에 따라 결정됩니다.

  • SpatialPanel로 래핑된 2D 레이아웃에서 (이전 코드 스니펫 참고) 오비터는 해당 SpatialPanel에 고정됩니다.
  • Subspace에서 orbiter는 가장 가까운 상위 항목에 고정됩니다. 이는 orbiter가 선언된 공간 레이아웃입니다.

다음 예에서는 오비터를 공간 행에 고정하는 방법을 보여줍니다.

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 레이아웃 외부에 orbiter를 선언하면 orbiter가 가장 가까운 상위 항목에 고정됩니다. 이 경우 오비터는 선언된 SpatialRow의 상단에 고정됩니다.
  • SpatialRow, SpatialColumn, SpatialBox와 같은 공간 레이아웃에는 콘텐츠가 없는 엔티티가 연결되어 있습니다. 따라서 공간 레이아웃에서 선언된 오비터는 해당 레이아웃에 고정됩니다.

참고 항목