데보션앱 소개페이지 바로가기
로그인 선택

신고하기

CLOSE
신고사유 (대표 사유 1개)
상세내용 (선택)
0/200
  • 신고한 게시글은 더 이상 보이지 않습니다.
  • 이용약관과 운영정책에 따라 신고사유에 해당하는지 검토 후 조치됩니다.
  • 허위 신고인 경우, 신고자의 서비스 이용이 제한될 수 있으니 유의하시어 신중하게 신고해 주세요.
(이 회원이 작성한 모든 댓글과 커뮤니티 게시물이 보이지 않고, 알림도 오지 않습니다.)

미리보기

커뮤니티

      1,234

      badge 23.06.15

      글 등록

      카테고리를 선택해주세요.

      DEVOTEE를 활성화 시키면
      지금 작성한 커뮤니티 글에 대해 1개의 댓글을 달아줍니다.

      버튼을 누르면 글 수정 시 ChatGPT가 작성한 댓글이 수정됩니다.

      임시저장함에 저장되었습니다. 저장일시 : 2022.5.17 14:29:08

      임시저장함

      제목을 선택하시면 이어서 작성이 가능하며,
      최대 20건까지 저장합니다.
      컨텐츠 유형, 제목, 저장일시, 삭제로 이뤄진 임시저장 목록
      컨텐츠 유형 제목 저장일 삭제

      데보션 블로그 게재 요청

      CLOSE
      • *
      • *

      본인인증

      효율적인 데보션 서비스 이용 및
      고객님의 소중한 개인정보보호를 위해
      본인인증을 진행해주세요. 본인인증 미 진행 시 로그인이 제한됩니다.
      본인인증 실패

      본인인증 로그인에 실패하였습니다.
      회원이 아니시거나 본인인증 등록이
      완료되지 않은 사용자입니다.

      회원정보 연결

      어서와요 XR의 숲 : Android Jetpack XR SDK 활용기

      androiji 25.06.04
      2,035 8 1
      DEVOTEE 요약
      본 블로그는 2025 Google I/O에서 공개된 Android XR Glasses와 관련 기술 데모 소개를 중심으로 하고, 이를 기반으로 Android XR SDK를 사용해 XR 환경에서 애플리케이션을 개발하는 방법과 개발 경험을 공유합니다. Jetpack XR SDK의 주요 구성 요소 및 사용법(예: ARCore 및 UI 컴포저블)과 함께, XR 환경에서 Full Space 및 Home Space의 차이점, SpatialDialog와 Orbiter 같은 UI 요소 활용법이 설명됩니다. 마지막으로 실제 앱(AiDot)에 이러한 기술들을 적용한 사례를 소개하며, XR 기술을 보다 구체적으로 활용할 수 있는 방안들을 탐구합니다.

      2025 Google I/O에서 Android XR Glasses의 데모를 공개했습니다.

      실시간으로 상대방의 말을 번역하여 소통의 허들을 낮추고, 원하는 장소로 갈 수 있게끔 사용자의 시야에 따라 방향과 설명을 제공합니다.

      사용자가 입력한 텍스트를 넘어서 XR과 Gemini를 결합하여 AI가 사용자가 보고 있는 것, 듣고 있는 것을 바탕으로 정보를 제공하는 미래가 성큼 다가왔음을 느낄 수 있었습니다.


      그래서! 오늘은 Android XR에 대해 알아보고, 에이닷 앱에 XR을 적용해 본 경험을 공유하고자 합니다.

      삼성의 Project Moohan (Headset)

      XReal의 Project Aura (Glasses)



      🤔 XR 톺아보기

      읽으시기 전에!
      • Android XR에서 제공하는 개발 옵션 중 Jetpack XR SDK를 통한 개발 방법에 대한 글임을 밝힙니다.

      • Jetpack XR SDK 중 ARCore, Scenecore로 3D 모델 구현에 관한 내용은 제외하였으니 참고하시길 바랍니다.

      개발 환경
      • Android Studio 버전

        • Android Studio Narwhal 2025.1.1 Canary 10

        • 가장 최신 버전의 Canary에서만 가능하니 스튜디오 버전을 확인하시길 바랍니다.

      • Device

        • Apple M1(Apple Silicon), 16GB, Sequoia 15.4.1

        • 매번 다르지만 10분 이내로 빌드되었습니다.

      • Sdk Versoin

        • minSdk = 29, targetSdk = 35

      dependencies
      • 가장 마지막으로 release 된 라이브러리 버전을 기준으로 작업했습니다.

        • XR compose / scenecore / runtime -> 1.0.0-alpha04 (2025.5.8 release)

        • XR material3 ->1.0.0-alpha08 (2025.5.20 release)

      • 라이브러리가 지속적으로 업데이트 되고 있으니 공식문서를 꼭 확인하시길 바랍니다.

      • alpha04를 적용하면서 XrExtensions 관련 에러가 있었습니다.

        • 에러 발생 파일인 JxrPlatformAdapterAxr.java 파일을 보니 import 하는 라이브러리가 존재하지 않았고 아래 libs.versions.toml 파일에 맨 아래 2줄을 추가하여 해결했습니다.

        • XrExtensions는 scenecore-testing 라이브러리에 존재합니다.


      // libs.versions.toml
      ## Android XR
      
      xrCompose = "1.0.0-alpha04"
      xrScenecore = "1.0.0-alpha04"
      xrMaterial3 = "1.0.0-alpha08"
      xrRuntime = "1.0.0-alpha04"
      impress = "0.0.1"
      
      androidx-xr-compose = { group = "androidx.xr.compose", name = "compose", version.ref = "xrCompose" }
      androidx-xr-runtime = { group = "androidx.xr.runtime", name = "runtime", version.ref = "xrRuntime" }
      androidx-xr-scenecore = { group = "androidx.xr.scenecore", name = "scenecore", version.ref = "xrScenecore" }
      androidx-xr-material3 = { group = "androidx.xr.compose.material3", name = "material3", version.ref = "xrMaterial3" }
      ## Solve for Err
      androidx-scenecore-testing = { group = "androidx.xr.scenecore", name = "scenecore-testing", version.ref = "xrScenecore" }
      google-ar-impress = { group = "com.google.ar", name = "impress", version.ref = "impress"}

      XR Project 만들기

      공식 문서의 내용을 하나씩 이해하고 체험하기 위해 새로운 프로젝트를 만들어서 확인해 보았습니다.

      Android Studio > New Project를 누르면 아래 화면이 나오는데, 좌측 메뉴에 'XR'이 있습니다. 선택하여 프로젝트 이름 제외 아무 설정도 바꾸지 않고 생성했습니다.


      XR에는 Home Space, Full Space 두 가지 모드가 있습니다. 공간화된 앱을 테스트하고 싶다면 Full Space로 두어 확인을 해야 합니다.

      별다른 설정을 하지 않으면 앱을 실행하자마자 Home Space로 보이는데, 이를 바꾸고 싶다면 AndroidManifest 파일에서 아래와 같이 변경할 수 있습니다.

      <application
          ...
          <property
              android:name="android.window.PROPERTY_XR_ACTIVITY_START_MODE"
              android:value="XR_ACTIVITY_START_MODE_FULL_SPACE_MANAGED" />


      Home

      Full

      실행할 수 있는 앱 개수

      N개 (멀티태스킹 가능)

      1개 (하나의 앱에만 집중)

      공간화 적용

      X(모바일 앱과 동일하게 보임)

      O

      그리고 지금 Android XR 기기가 출시되지 않았기 때문에 XR Device Emulator를 꼭 설치해야 테스트할 수 있습니다.

      Device Manager > '+' 버튼 > 'Create Virtual Machine을 선택합니다.

      New Project 눌렀을 때와 동일하게 좌측 메뉴에 'XR'을 선택할 수 있습니다. 선택 후 수정 없이 Finish 버튼을 눌렀습니다.





      Emulator를 실행하게 되면 간혹 아래 왼쪽 그림처럼 Android Studio의 Device Manager 창 내에서 Emulator가 열립니다.

      이 상태에선 조작하기 불편하므로 수정이 필요합니다. Android Studio의 Settings > Tools > Emulator에서 'Launch in the Running Devices tool window' 항목의 체크박스를 풀어줍니다.

      그러면 가장 오른쪽 사진처럼 Emulator가 별개의 창으로 열립니다.







      직접 코딩하며 알아보자!

      프로젝트 새로 생성 시 있는 기본 코드를 바탕으로 테스트했습니다.

      // MainActivity.kt
      override fun onCreate(savedInstanceState: Bundle?) {
          super.onCreate(savedInstanceState)
          enableEdgeToEdge()
      
          setContent {
              /**
               * 1.0.0-alpha01에선 [val session = LocalSession.current] 선언 후,
               * session?.requestHomeSpaceMode() -> 이렇게 Home Space로 이동.
               * alpha04 에선 에러 발생함.
               */
              val env = LocalSpatialConfiguration.current
              XRTestAlpha04Theme {
                  if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
                      ApplicationSubspace {
                          MySpatialContent(
      			onRequestHomeSpaceMode = { env.requestHomeSpaceMode() }
                          )
                      }
                  } else {
                      My2DContent(
                          onRequestFullSpaceMode = { env.requestFullSpaceMode() }
                      )
                  }
              }
          }
      }
      
      @Composable
      fun MySpatialContent(onRequestHomeSpaceMode: () -> Unit) {
          SpatialPanel(
              SubspaceModifier
                  .width(1280.dp)
                  .height(800.dp)
                  .movable()
                  .resizable()
              ) {
              if (showDialog) {
                  SpatialDialog(onDismissRequest = { showDialog = false }) {   
                      MiniSpace { showDialog = false }
                  }
              }   
              if (showPopup) {
                  SpatialPopup(onDismissRequest = { showPopup = false }) {
                      MiniSpace { showPopup = false }
                  }
              }
              if (showElevation) {
                  SpatialElevation { MiniSpace { showElevation = false } }
              }
              Surface {
                  Text(
                      text = "3D",
                      modifier = Modifier.fillMaxSize().padding(48.dp)
                  )
              }
              // 커스텀 Orbiter
              SpatialOrbiter(
                  onRequestHomeSpaceMode = onRequestHomeSpaceMode,
                  showDialog = { showDialog = true },
                  showPopup = { showPopup = true},
                  showElevation = { showElevation = true }
              )
          }
      }
      
      @Composable
      fun My2DContent(onRequestFullSpaceMode: () -> Unit) {
          Surface {
              Row(
                  modifier = Modifier.fillMaxSize(),
                  horizontalArrangement = Arrangement.SpaceBetween
              ) {
                  Text(text = "2D", modifier = Modifier.padding(48.dp))
                  if (LocalHasXrSpatialFeature.current) {
                      SpaceButton(
                          onClick = onRequestFullSpaceMode,
                          buttonText = "Full"
                      )
                  }
              }
          }
      }

      Android XR UI Component

      Android XR에서 제공하는 UI Component는 크게 2개로 나뉩니다.


      Subspace Composable

      Spatialized Composable

      특징

      Subspace 내에서만 공간화 요소들만 render

      Space 관계없이 어디서든 render 가능하며 공간화 불가능 시 기존 UI 대로 표시됨

      예

      SpatialBox/Row/Column, SpatialPanel, SpatialExternalSurface, SpatialLayoutSpacer, Volume

      Orbiter, SpatialDialog, SpatialPopup, SpatialElevation, Subspace

      Subspace

      Subspace는 XR 환경에서 3D UI를 담는 Container 역할을 합니다. Full Space 에서만 rendering 되며, 코드 내 어느 곳에든 배치할 수 있습니다.

      Subspace 중 특별하게 가장 최상위에 두어야 하는 ApplicationSubspace이 있습니다.

      ApplicationSubspace는 그 어떤 것에도 nested 될 수 없습니다. 무조건 Subspace 중 최상위에 있어야 하며 다른 Subspace에 nest 되면 아래와 같은 에러가 발생합니다.

      그래서 ApplicationSubspace는 앱 전체를 XR 환경에 둘 때 사용하고, Subspace는 코드 내 어디에든 둘 수 있어서 앱 전체 또는 일부만 XR 환경에 둘 때 용이합니다.


      Subspace Composables

      : Subspace Composables는 Subspace 내에서만 위치 가능합니다.

      SpatialPanel (feat. SpatialBox/Row/Column)

      SpatialPanel은 Subspace 내 가장 기본이 되는 요소이며 2D UI를 담는 Container 역할을 합니다.

      • SpatialPanel은 여러 개를 만들 수 있고 가로 혹은 세로로 Panel을 배치하고 싶다면 SpatialBox/Row/Column을 사용하여 나타낼 수 있습니다.

      • SpatialPanel은 Jetpack Compose에서 쓰던 Modifier와 조금 다른 SubspaceModifier를 사용합니다.

      • SubspaceModifier에는 resizable, movable 이란 속성이 있는데 이는 XR 환경에서 Panel의 사이즈를 조절할 수 있고 위치를 옮길 수 있도록 합니다.

        • resizable과 movable을 두는 순서가 중요한데 resizable이 된 채로 이동하면 panel의 위칫값에 영향을 줄 수 있으니, movable을 먼저 쓰고 그 뒤에 resizable을 써서 테스트를 진행했습니다.

      만약 Subspace 없이 SpatialPanel을 실행하면 아래와 같은 'ClassCastException' 에러가 발생합니다.


      Spatialized Composables

      : Spatialized Composable은 2D Layout과 함께 위치할 수 있으며 Full Space 일 경우에만 3D로 rendering 됩니다.

      Orbiter

      Orbiter는 Floating 요소입니다.

      앱 화면에서 사용자가 제어할 수 있는 요소를 두어 정돈된 느낌을 주고 사용자가 제어할 수 있는 것들에 더 빠르게 접근할 수 있게 해줍니다.

      (예시) BottomTab, SideDrawer, 영상의 ProgressBar

      저는 Orbiter에 아래 4개 버튼을 두고 테스트했습니다.

      // MainActivity.kt
      
      @Composable
      fun SpatialOrbiter(
          onRequestHomeSpaceMode: () -> Unit,
          showDialog: () -> Unit,
          showPopup: () -> Unit,
          showElevation: () -> Unit
      ) {
      
          Orbiter(
              position = OrbiterEdge.Top,
              offset = EdgeOffset.inner(offset = 20.dp),
              alignment = Alignment.End,
              shape = SpatialRoundedCornerShape(CornerSize(28.dp))
          ) {
              Row(
                  modifier = Modifier
                      .height(60.dp)
                      .wrapContentWidth()
                      .background(Color.White),
                  verticalAlignment = Alignment.CenterVertically
              ) {
                  SpaceButton(
                      onClick = onRequestHomeSpaceMode,
                      buttonText = "Home"
                  )
                  SpaceButton(
                      onClick = showDialog,
                      buttonText = "Dialog"
                  )
                  SpaceButton(
                      onClick = showPopup,
                      buttonText = "Popup"
                  )
                  SpaceButton(
                      onClick = showElevation,
                      buttonText = "Elevation"
                  )
              }
          }
      }
      
      @Composable
      fun SpaceButton(onClick: () -> Unit, modifier: Modifier = Modifier, buttonText: String) {
          FilledTonalIconButton(onClick = onClick, modifier = modifier.size(56.dp).padding(5.dp)) {
              Text(buttonText)
          }
      }

      Orbiter는 Spatialized Composables 라서 2D Layout 과 같이 배치될 수 있는데, 각 Space에서 Orbiter는 어떻게 보일까요?

      코드는 아래와 같습니다. Home Space 일 때 실행되는 함수 My2DContent 내에 SpaceOrbiter와 Text를 가로로 배치했습니다.

      그리고 Space 구분없이 함수 My2DContent 만 실행되도록 했습니다.

      // MainActivity.kt
      
      @Composable
      fun My2DContent(onRequestFullSpaceMode: () -> Unit) {
          Surface {
              Row(
                  modifier = Modifier.fillMaxSize(),
                  horizontalArrangement = Arrangement.SpaceBetween
              ) {
                  SpatialOrbiter { onRequestFullSpaceMode }
                  Text(text = "2D", modifier = Modifier.padding(48.dp))
              }
          }
      }

      실행하면 아래 화면처럼 보입니다.

      Home Space 에선 Composable 함수인 Orbiter의 content를 실행시켜 Text와 나란히 배치된 Orbiter의 모습을 볼 수 있습니다.

      그리고 앱을 Full Space로 전환 시 XR 환경에 맞게 Floating 요소로 변화되는 걸 볼 수 있습니다.

      Home Space

      Full Space



      SpatialDialog/Popup/Elevation

      이 3개는 elevation을 가질 수 있습니다.

      SpatialDialog와 SpatialPopup은 Full Space에선 elevation을 두며 나타나고, Home Space에선 일반 앱에서 나타나는 것처럼 나타납니다.

      실제 구현된 코드를 보면 LocalSpatialCapabilities.current.isSpatialUiEnabled 에 따라 분기 처리 되어있는 걸 확인할 수 있습니다.

      // SpatialDialog.kt
      
      @Composable
      public fun SpatialDialog(
          onDismissRequest: () -> Unit,
          properties: SpatialDialogProperties = SpatialDialogProperties(),
          content: @Composable () -> Unit,
      ) {
          if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
      		// Full Space(3D)
              LayoutSpatialDialog(onDismissRequest, properties, content)
          } else {
      		// Home Space(2D)
              Dialog(
                  onDismissRequest = onDismissRequest,
                  properties = properties.toBaseDialogProperties(),
                  content = content,
              )
          }
      }

      아래는 SpatialDialog를 Full Space에서 실행한 모습입니다.

      Dialog를 정면에서 보면 SpatialPanel이 비교적 크기가 더 작아 보이며, Orbiter에도 옅은 그림자가 진 걸 볼 수 있습니다.

      더 자세하게 측면으로 보니 SpatialPanel이 Dialog 보다 뒤쪽에 위치하며 이로써 Dialog를 실행하면 Panel이 뒤로 살짝 밀려서 elevation이 있는 걸 알 수 있습니다.

      기본 SpatialPanel

      Dialog (정면)

      Dialog (측면)




      마찬가지로 Popup도 Dialog와 동일한 걸 측면 사진을 통해서 볼 수 있습니다.

      하지만 다른 점이 있습니다. Dialog의 경우 화면에 나타나면 SpatialPanel이 조정되었지만, Popup은 Popup 자체가 조정됩니다.

      즉 SpatialElevationLevel 값은 Popup이 SpatialPanel로부터 얼마나 앞으로 떨어진 채로 나오는가에 대한 값입니다.

      기본 SpatialPanel

      Popup (정면)

      Popup (측면)




      SpatialElevation은 2D 상황에선 특별한 효과가 없는데, Full Space 상황에선 elevation을 주는 효과가 있습니다.

      SpatialDialog, SpatialPopup은 화면에 뜨면 자동으로 elevation이 적용되지만, 그 외에 elevation을 주고 싶은 요소가 있는 경우 사용합니다. 실행하면 아래처럼 나타납니다.

      Elevation (측면)


      SpatialElevation의 측면을 보면 화면과 붙어있는 것처럼 보입니다.

      그 이유는 아래 코드처럼 SpatialElevation의 기본 Level이 Level0, 즉 0.1dp라서 화면과 거의 붙어있기 때문입니다.

      // SpatialElevation.kt
      
      @Composable
      public fun SpatialElevation(
          spatialElevationLevel: SpatialElevationLevel = SpatialElevationLevel.Level0,
          content: @Composable () -> Unit,
      ) {
          if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
              LayoutSpatialElevation(spatialElevationLevel, content)
          } else {
              content()
          }
      }

      매개변수 spatialElevationLevel에 SpatialElevationLevel.Level5를 두고 실행하면 아래 측면 사진과 같습니다.

      SpatialElevation도 SpatialPopup과 마찬가지로 SpatialPanel가 고정된 채로 Elevation이 조정되는 걸 사진을 통해 알 수 있습니다.

      SpatialDialog, SpatialPopup도 spatialElevationLevel 값을 설정할 수 있으니 참고하시길 바랍니다.

      기본 SpatialPanel

      Elevation (정면) (Level5)

      Elevation (측면) (Level5)




      EnableXrComponentOverrides

      기존 코드를 수정하지 않고 딱! 하나만, EnableXrComponentOverrides만 추가하면 XR 환경에 맞게 Material3 Design이 적용됩니다.

      이를 사용하기 위해선 androidx.xr.compose.material3:material3 라이브러리가 필요합니다.

      또한 아직 실험 단계이기 때문에 @OptIn(ExperimentalMaterial3XrApi::class) annotation도 추가해야 합니다.


      EnableXrComponentOverrides.kt에 들어가면 XrComponentOverride class를 발견할 수 있는데, 하단에 적힌 Material3 컴포넌트만 XR 환경에 맞게 변화할 수 있습니다.

      • NavigationRail (material3)

      • NavigationBar (material3)

      • NavigationSuiteScaffold (material3.adaptive)

      • ThreePaneScaffold (material3.adaptive)

      • SingleRowTopAppBar → private이라서 바로 사용하지 못함. TopAppBar, CenterAlignedTopAppBar 구현에 사용됨.

      • TwoRowsTopAppBar → 바로 호출 가능. MediumTopAppBar, LargeTopAppBar 구현에도 사용됨.

      • BasicAlertDialog (material3)

      사용법은 매우 간단합니다. XR이 적용되지 않은 기존 코드를 EnableXrComponentOverrides로 감싸면 됩니다.

      제 테스트 코드에선 My2DContent 함수가 기존 코드에 해당하므로 이를 EnableXrComponentOverrides로 감쌌습니다.

      (자동으로 변환해 주는 것을 보기 위해 onCreate에서 MySpatialContent 함수는 제외했습니다.)

      // MainActivity.kt
          
          @OptIn(ExperimentalMaterial3XrApi::class)
          @SuppressLint("RestrictedApi")
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              enableEdgeToEdge()
      
              setContent {
                  val env = LocalSpatialConfiguration.current
                  XRTestTheme {
                      EnableXrComponentOverrides {
                          My2DContent(
                              onRequestFullSpaceMode = { env.requestFullSpaceMode() }
                          )
                      }
                  }
              }
          }

      아래는 My2DContent 코드입니다.

      XrComponentOverride class 중 NavigationBar와 NavigationRail을 사용했습니다.

      NavigationBar를 상단에, 그 아래로 Text와 NavigationRail을 가로로 배치했습니다.

      androidx.xr.compose.material3 라이브러리의 NavigationBar/Rail 구현 코드를 보면 각각 HorizontalOrbiter, VerticalOrbiter로 구현된 걸 볼 수 있습니다.

      즉 둘 다 Full Space에선 Orbiter로 표현됩니다.

      EnableXrComponentOverrides는 androidx.xr.compose.material3를 사용하지 않고도 XR 환경에 맞게 디자인해 주므로

      androidx.compose.material3 라이브러리의 NavigationBar, NavigationRail을 사용하여 확인해 보겠습니다.

      // MainActivity.kt
      
      @Composable
      fun My2DContent(onRequestFullSpaceMode: () -> Unit) {
          Surface {
              Column {
      		    // From "androidx.compose.material3"
                  NavigationBar(
                      containerColor = Color.Yellow
                  ) {
                      SpaceButton(
                          onClick = onRequestFullSpaceMode,
                          modifier = Modifier.padding(32.dp)
                      )
                  }
                  Row(
                      modifier = Modifier.fillMaxSize(),
                      horizontalArrangement = Arrangement.SpaceBetween
                  ) {
      			    Text(text = "2D", modifier = Modifier.padding(48.dp))
      				// From "androidx.compose.material3"
                      NavigationRail(
                          containerColor = Color.White,
                          header = { Text("Header") }
                      ) {
                          SpaceButton(
                              onClick = onRequestFullSpaceMode,
                              modifier = Modifier.padding(32.dp)
                          )
                      }
                  }
              }
          }
      }

      결과는 아래와 같습니다.

      Home Space에선 NavigationBar/Rail이 한 Panel에 같이 배치되어 있지만, Full Space로 전환했을 땐 Panel에서 떨어져서 각자 왼쪽과 하단에 자리하고 있습니다.

      androidx.xr.compose.material3를 사용하지 않았음에도 Full Space로 전환했을 때 XR 환경에 적합하게 적용되는 걸 볼 수 있습니다.

      Home Space

      Full Space



      🛠️ 차세대 에이닷, XR 속으로!

      지금까지 Android XR에 대해 간단히 살펴봤습니다. 살펴본 내용을 바탕으로 에이닷 앱에 적용해 보겠습니다.

      제가 적용할 내용은 아래 3가지 입니다.

      1. 에이닷 앱 전체를 SpatialPanel에 두기

      2. Dialog를 SpatialDialog으로 변경하기

      3. 메인 에이전트 내 BottomTab과 뮤직 내 컨트롤러를 Orbiter로 만들기

      준비

      dependencies는 상단에 기재된 그대로 붙여넣고 build.gradle(:app)에 아래와 같이 기재합니다.

      // build.gradle(:app)
      
      // AndroidXR
      implementation(libs.androidx.xr.compose)
      implementation(libs.androidx.xr.scenecore)
      implementation(libs.androidx.xr.material3)
      implementation(libs.androidx.xr.runtime)
      implementation(libs.google.ar.impress)
      androidTestImplementation(libs.androidx.scenecore.testing)
      ...

      이제 변경한 코드를 하나씩 살펴보겠습니다. (보기 좋게 일부 색상이나 width/height 값을 바꿨습니다. 이는 코드에 기재하지 않았습니다.)

      2. Dialog를 SpatialDialog으로 변경하기

      SpatialDialog의 properties는 Dialog의 properties와 다르지만, Dialog의 properties 값을 포함한 몇 가지가 더 있습니다.

      그래서 Dialog의 properties를 그대로 반영하여 properties를 설정했습니다.

      // 기존
      Dialog(
          properties = properties,
      	onDismissRequest = onDismissRequest
      ) {...}
      
      // 변경
      SpatialDialog(
          properties = SpatialDialogProperties(
              properties.dismissOnBackPress,
              properties.dismissOnClickOutside,
              properties.usePlatformDefaultWidth,
          ),
          onDismissRequest = onDismissRequest
      ) {...}
      3. 메인 에이전트 내 BottomTab과 뮤직 내 컨트롤러를 Orbiter로 만들기

      BottomTab은 Orbiter로, 뮤직 컨트롤러는 EnableXrComponentOverrides를 테스트 하기 위해서 NavigationBar로 변경했습니다.

      //// BottomTab
      
      // 변경
      Orbiter(
      	position = OrbiterEdge.Bottom,
      	offset = 100.dp,
      	shape = SpatialRoundedCornerShape(CornerSize(50))
      ) { 
      	Surface(color = Color.Black) { 기존 코드 }
      }
      
      
      //// 뮤직 컨트롤러
      
      // 기존
      Row() {...}
      // 변경
      NavigationBar() {...}

      BottomTab에서 기존 코드를 Surface로 한 번 감싼 이유?

      Surface를 적용했을 때 인자 값이 제대로 반영된 Orbiter를 볼 수 있기 때문입니다.

      Surface 내부에 CompositionLocalProvider가 있어서 Orbiter에서 전달하는 CompositionLocal 값에 접근할 수 있습니다.


      Orbiter 구현을 살펴보면 ElevatedPanel 이란 함수가 있습니다.

      변수 panelEntity는 Orbiter의 UI 요소가 설정된 PanelEntity이고, CompositionLocalProvider를 통해서 하위 composable 함수인 content, 즉 Surface에서 접근이 가능한 구조입니다.

      Row는 단순 Layout인 Container 라서 CompositionLocal 값을 전달하기 어렵기 때문에 Surface 없이 적용했을 경우 예상과는 다른 모습이 나온 것입니다.

      // ElevatedPanel.kt
      @Composable
      internal fun ElevatedPanel(
          contentSize: IntSize,
          shape: SpatialShape = ElevatedPanelDefaults.shape,
          pose: Pose? = null,
          content: @Composable () -> Unit,
      ) {
          ...
          // PanelEntity는 Scenecore 라이브러리에 소속된 API로 SpatialPanel을 생성할 때 사용됨
          val panelEntity =
              rememberCorePanelEntity(shape = shape) {
                  PanelEntity.create(
                      session = session,
                      view = view,
                      pixelDimensions = contentSize.run { PixelDimensions(width, height) },
                      name = "ElevatedPanel:${view.id}",
                  )
              }
      
          view.setContent {
              // OpaqueEntity는 Compose XR의 Spatial content를 렌더링하고 제어하는 기본 API 
              CompositionLocalProvider(LocalOpaqueEntity provides panelEntity) {
                  Box(Modifier.alpha(if (pose == null) 0.0f else 1.0f)) { content() }
              }
          }
      
          ...
      }
      1. 에이닷 앱 전체를 SpatialPanel에 두기

      (a) EnableXrComponentOverrides를 사용하는 방법과 (b) Space를 분기 처리하는 방법을 적용해 보겠습니다.

      그리고 용이한 Space 변경을 위해 상단에 Text Button을 만들었습니다. (== 함수 TextButtonForSwitchSpace)

      (a) EnableXrComponentOverrides를 사용

      우선 (a) 방법을 살펴보겠습니다. 코드를 아래와 같이 입력했습니다.

      // MainActivity.kt
      
      @OptIn(ExperimentalMaterial3XrApi::class)
      override fun onCreate(savedInstanceState: Bundle?) {
          setContent {
              EnableXrComponentOverrides {
                  Column {
                      TextButtonForSwitchSpace()
                      AndroidView(
                          factory = { binding.root },
                          modifier = Modifier.fillMaxSize()
                      )
                  }
              }
          }
      }
      (a) Full Space

      Dialog

      Dialog (Pressed)

      Dialog (Side)




      BottomTab

      뮤직 컨트롤러



      (a) Home Space

      Dialog

      Dialog (Pressed)

      Dialog (Side)




      BottomTab

      뮤직 컨트롤러



      resizable

      movable



      정리

      • Home Space에선 현재 모바일 에이닷 앱과 동일하게 보입니다.

      • Full Space에선 XR 환경에 맞게 잘 적용되었으며 Home Space의 결과와 비교했을 때 차이가 있음을 알 수 있습니다.

      • Full Space에서 resizable, movable은 불가능했지만 Home Space에선 resizable, movable이 가능했습니다.

        • 그 이유는 Home Space는 공간에 여러 개의 앱이 뜰 수 있는 환경이기 때문입니다.

        • 사용자가 여러 앱을 열었을 때 공간 내 앱의 위치를 조정할 수 있도록 하기 위해서 설정된 사항입니다.

        • 아래 2개 앱은 모두 Home Space라서 같이 배치될 수 있습니다. 이 상태에서 Full Space의 앱을 띄우면 앱 2개는 모두 사라지고 Full Space의 앱만 보입니다.


      (b) Space를 분기 처리하는 방법

      이제 Space 분기 처리를 하여 확인해 보겠습니다.

      EnableXrComponentOverrides를 삭제해서 뮤직 컨트롤러의 NavigationBar를 Orbiter로 변환했습니다.

      코드를 유지할 경우 뮤직 컨트롤러가 XR 환경에 맞게 바뀌지 않기 때문입니다.

      주목해야 할 건 SpatialPanel 입니다. SpatialPanel은 각기 다른 param을 갖는 4개의 함수가 있습니다.

      저는 앱의 MainActivity 자체를 SpatialPanel에 넣고 싶어서 방법 (4)를 선택했습니다.

      // SpatialPanel.kt
      // (1) View 자체
      public fun SpatialPanel(
          view: View,
          modifier: SubspaceModifier = SubspaceModifier,
          shape: SpatialShape = SpatialPanelDefaults.shape,
      )
      // (2) Compose의 AndroidView
      public fun <T : View> SpatialPanel(
          factory: (Context) -> T,
          modifier: SubspaceModifier = SubspaceModifier,
          update: (T) -> Unit = {},
          shape: SpatialShape = SpatialPanelDefaults.shape,
      )
      // (3) @Composable 함수
      public fun SpatialPanel(
          modifier: SubspaceModifier = SubspaceModifier,
          shape: SpatialShape = SpatialPanelDefaults.shape,
          content: @Composable @UiComposable () -> Unit,
      )
      // (4) Activity의 Intent
      public fun SpatialPanel(
          intent: Intent,
          modifier: SubspaceModifier = SubspaceModifier,
          shape: SpatialShape = SpatialPanelDefaults.shape,
      )

      (4) SpatialPanel의 구현 부분을 보면 ActivityPanelEntity를 생성한 후 launchActivity 함수를 호출하고 있습니다.

      launchActivity 함수는 Activity를 Embed 합니다. 즉 Activity의 2D 화면을 실시간으로 Panel에 배치합니다.

      // SpatialPanel.kt
      
      @Composable
      @SubspaceComposable
      public fun SpatialPanel(
          intent: Intent,
          modifier: SubspaceModifier = SubspaceModifier,
          shape: SpatialShape = SpatialPanelDefaults.shape,
      ) {
          ...
          val activityPanelEntity = rememberCorePanelEntity {
              ActivityPanelEntity.create(session, rect, entityName("ActivityPanel-${intent.action}"))
                  .also { it.launchActivity(intent) }
          }
      	...
      }
      
      // ActivityPanelEntity.kt
      
      @JvmOverloads
      public fun launchActivity(intent: Intent, bundle: Bundle? = null) {
      		rtActivityPanelEntity.launchActivity(intent, bundle)
      }
      ...

      이때 Intent는 SpatialPanel을 호출하는 Activity가 아닌 다른 Activity여야 합니다.

      MainActivity의 onCreate에서 SpatialPanel의 인자로 MainActivity의 intent를 넣으면 자기 자신을 embed 하게 되어 충돌 및 무한루프 위험이 있기 때문입니다.

      그래서 PanelActivity 라는 별도의 Activity를 만들어서 그 안에서 activity_main의 복사본인 activity_panel.xml을 inflate 했습니다.

      (AndroidManifest.xml에 PanelActivity 선언도 잊지 않고 해야 합니다.)

      // PanelActivity.kt
      
      class PanelActivity : ComponentActivity() {
          override fun onCreate(savedInstanceState: Bundle?) {
              super.onCreate(savedInstanceState)
              setContent {
                  AndroidView(
                      factory = {
                          LayoutInflater.from(it).inflate(R.layout.activity_panel, null, false).apply {
                              layoutParams = ViewGroup.LayoutParams(
                                  ViewGroup.LayoutParams.MATCH_PARENT,
                                  ViewGroup.LayoutParams.MATCH_PARENT
                              )
                          }
                      },
                      modifier = Modifier.fillMaxSize()
                  )
              }
          }
      }

      아래와 같이 코드를 작성했습니다. 하지만 결과가 잘 나오지 않았습니다.

      • "No WindowHierarchyInfo found." 라는 에러가 발생했습니다. Panel로 rendering 할 때 View가 attach 된 상태여야 하는데 동적으로 attach 되는 View가 있어서 rendering 할 때 attach 된 상태가 아니라서 발생했다고 판단했습니다.

      • 여러 경우를 고려했을 때 Fragment 관련 코드의 영향을 받아서 에러가 발생한 것으로 보았습니다.

      다른 SpatialPanel 함수를 활용할 수 있을지 생각해 봤지만 위의 문제와 모두 연관 있었습니다.

      그래서 Composable 함수를 인자로 받는 (3) SpatialPanel을 사용하여 간단한 Composable 함수를 인자로 넣은 후 크기 변화(resizable), 이동(movable)이 잘 되는지만 확인했습니다.

      // MainActivity.kt	
      
      override fun onCreate(savedInstanceState: Bundle?) {
      	// val panelIntent = Intent(this, PanelActivity::class.java)
      	setContent {
      		if (LocalSpatialCapabilities.current.isSpatialUiEnabled) {
                  Subspace {
                      SpatialPanel(
                          modifier = SubspaceModifier
                              .width(800.dp)
                              .height(1000.dp)
                              .movable()
                              .resizable()
                      ) { 간단한 Composable 함수 }
                  }
              } else {
                  AndroidView(
                      factory = { binding.root },
                      modifier = Modifier.fillMaxSize()
                  )
              }
          }
      }
      (b) Full Space

      resizable

      movable



      (b) Home Space

      -> (a)의 Home Space와 동일하여 생략


      정리

      • Home Space와 Full Space에서의 에이닷앱은 (a) 방법과 동일하게 보였습니다.

      • 하지만 (a) 와 다른 점은 Home/Full Space 모두 resizable, movable이 가능한 점입니다.

      • Full Space에서 가능한 이유는 SubspaceModifier에서 설정해 주었기 때문입니다. 설정하지 않으면 크기가 조절되지도, 이동되지도 않습니다.

      결론
      • Spatialized Composable을 XR 환경에 맞게 실행하도록 하기 위해선 (a), (b) 방법 모두 가능하지만, Panel을 여러 개 두어 활용하거나 앱의 크기를 조절할 수 있도록 하기 위해선 (b) 방법을 사용해야 합니다.

      • 그리고 Compose 환경에 최적화된 앱이라면 (a), (b)방법 모두 적용하기 쉽지만, 아니라면 (a) 방법으로만 접근할 수 있습니다.

      🙇🏻‍♀️ 마무리하며

      Android XR에 대해서 알아보고 에이닷에 적용까지 진행해 보았습니다.

      3D 공간에서 실행되는 에이닷을 보니 XR Glasses로 바라볼 에이닷의 모습은 어떨지 기대되었습니다.


      Android XR의 공식 문서 내용은 계속 업데이트되고 있으니 새로 버전이 release 될 때마다 변경 사항을 꼼꼼하게 확인하시길 바랍니다.

      일례로 alpha01 버전 나왔을 때 작성했던 코드 중 requestHomeSpaceMode 함수를 Session을 통해서 가져왔는데, alpha04 버전으로 코드를 짤 때는 SpatialConfiguration을 통해서 가져와야 했습니다.


      Android Compose XR 개발은 Jetpack Compose에 익숙한 개발자라면 쉽게 이해하고 적용할 수 있을 것 같습니다. XR Headset, Glasses가 출시되면 바로 적용할 수 있도록 지금부터 시작해 보시는 건 어떨까요?

      우리 모두 XR에서 만나길 기대하며 이만 글 마칩니다. 읽어주셔서 감사합니다!

      (그리고 잘 버텨준 저의 맥북도 너무 감사합니다...)

      🔗 Reference

      [삼성 뉴스룸][MWC 2025] 갤럭시 S25 엣지와 프로젝트 무한, 유럽에서도 관심 집중

      [구글코리아 블로그] [I/O 2025] 안드로이드 XR: 제미나이가 XR 안경 및 헤드셋에 가져올 새로운 변화

      [AndroidDevelopersBlog] 16 things to know for Android developers at Google I/O 2025

      [Google I/O 2025] The future is now, with Compose and AI on Android XR

      댓글 0

      DEVOTEE를 활성화 시키면
      지금 작성한 댓글에 AI가 댓글을 달아줍니다.

      androiji 님의 최신 블로그

      더보기
      동영상 기고하기