Как разрабатывать пространственные интерфейсы с помощью Jetpack Compose для XR

Поддерживаемые XR-устройства
Это руководство поможет вам создавать контент для таких устройств.
XR-шлемы
Проводные XR-очки

С помощью Jetpack Compose для XR можно декларативно создавать пространственный интерфейс и макет, используя знакомые концепции Compose, такие как строки и столбцы. Это позволяет расширить существующий интерфейс Android до трехмерного пространства или создать совершенно новые приложения с эффектом погружения.

Если вы пространственно обрабатываете существующее приложение на основе Android Views, у вас есть несколько вариантов разработки. Вы можете использовать API совместимости, работать с Compose и Views вместе или напрямую с библиотекой SceneCore. Подробнее о том, как работать с представлениями…

О подпространствах и пространственных компонентах

При разработке приложений для Android XR важно понимать, что такое подпространство и пространственные компоненты.

О подпространстве

При разработке для Android XR вам нужно добавить в приложение или макет подпространство. Подпространство – это часть трехмерного пространства в приложении, в которой можно размещать 3D-контент, создавать 3D-макеты и добавлять глубину в 2D-контент. Подпространство отображается, только если включена пространственная обработка. В главном пространстве или на устройствах, не поддерживающих XR, любой код в этом подпространстве игнорируется.

Создать подпространство можно несколькими способами:

  • Subspace: этот компонент создает новую независимую иерархию пространственного интерфейса. Он не наследует пространственное положение, ориентацию или масштаб родительского элемента Subspace, в который вложен. Subspace автоматически привязывается к рекомендованному системой блоку контента.
  • PlanarEmbeddedSubspace – эту composable-функцию можно разместить в иерархии интерфейса приложения, чтобы поддерживать макеты для 2D- и пространственного интерфейса. PlanarEmbeddedSubspace учитывает ограничения и положение родительского элемента. 3D-контент, помещенный в эту область, будет позиционироваться относительно нее.

Подробнее о том, как добавить подпространство в приложение…

О пространственных компонентах

Компоненты подпространства можно отрисовывать только в подпространстве. Перед тем как разместить их в 2D-макете, их нужно заключить в теги Subspace. SubspaceModifier позволяет добавлять в композиции подпространства такие атрибуты, как глубина, смещение и позиционирование.

Другие пространственные компоненты не обязательно вызывать внутри подпространства. Они состоят из обычных 2D-элементов, помещенных в пространственный контейнер. Эти элементы можно использовать в 2D- и 3D-макетах, если они определены для обоих типов. Если пространственное звучание отключено, пространственные функции будут игнорироваться и заменяться на 2D-аналоги.

Как создать пространственную панель

SpatialPanel – это composable-функция подпространства, которая позволяет показывать контент приложения, например видео, изображения или любой другой контент на пространственной панели.

Пример панели пространственного интерфейса

Вы можете использовать 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
        )
    }
}

Ключевые моменты

  • Поскольку API SpatialPanel являются композициями подпространства, их необходимо вызывать внутри Subspace. Вызов их вне подпространства приводит к исключению.
  • Размер элемента SpatialPanel задан с помощью спецификаций height и width для элемента SubspaceModifier. Если не указать эти характеристики, размер панели будет определяться размерами ее содержимого.
  • Разрешите пользователю перемещать панель, добавив модификатор подпространства movable.
  • Разрешите пользователю изменять размер панели, добавив модификатор подпространства resizable.
  • Подробнее о размерах и расположении панелей можно узнать из руководства по дизайну пространственных панелей. Подробнее о реализации кода можно узнать в справочной документации.

Как работает модификатор movable

Когда пользователь перемещает панель, по умолчанию модификатор movable масштабирует ее так же, как система масштабирует панели в домашнем пространстве. Все детские видео наследуют это поведение. Чтобы отключить эту функцию, установите для параметра shouldScaleWithDistance значение false.

Как создать орбитер

Орбитер – это пространственный компонент интерфейса. Он предназначен для прикрепления к соответствующей пространственной панели или компоненту пространственного макета, например SpatialColumn, SpatialRow или SpatialBox. Орбитер обычно содержит элементы навигации и контекстные действия, связанные с объектом, к которому он прикреплен. Например, если вы создали пространственную панель для показа видеоконтента, то можете добавить элементы управления воспроизведением видео в орбитер.

Пример орбитера

Как показано в примере ниже, вызовите орбитер в двумерной разметке в SpatialPanel, чтобы обернуть элементы управления, например навигацию. При этом они будут извлечены из 2D-макета и прикреплены к пространственной панели в соответствии с вашей конфигурацией.

Subspace {
    SpatialPanel(
        SubspaceModifier
            .height(824.dp)
            .width(1400.dp)
            .resizable()
            .movable(),
    ) {
        SpatialPanelContent()
        OrbiterExample()
    }
}

@Composable
fun OrbiterExample() {
    Orbiter(
        alignment = OrbiterAlignment.BottomCenter(
            edgeOffsetType = OrbiterEdgeOffsetType.OuterEdge,
            offset = DpVolumeOffset(y = 96.dp)
        ),
    ) {
        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
                )
            }
        }
    }
}

Ключевые моменты

  • Поскольку орбиты являются пространственными компонентами интерфейса, код можно использовать повторно в 2D- или 3D-макетах. В 2D-макете приложение обрабатывает только контент внутри орбитера и игнорирует сам орбитер.
  • Подробнее о том, как использовать и создавать орбитеры, рассказывается в руководстве по дизайну.

Как добавить несколько пространственных панелей в пространственную раскладку

Вы можете создать несколько пространственных панелей и разместить их в пространственной разметке, используя SpatialRow, SpatialColumn, SpatialBox и SpatialSpacer.

Пример нескольких пространственных панелей в пространственном макете

Ниже приведен пример кода.

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 и SpatialSpacer – это композиции подпространства, которые должны быть размещены в подпространстве.
  • Используйте SubspaceModifier, чтобы настроить макет.
  • Для макетов с несколькими панелями в ряд рекомендуется задать радиус кривизны 825 dp, используя SubspaceModifier, чтобы панели окружали пользователя. Подробнее о дизайне…

Как добавить 3D-объект в макет с помощью SpatialGltfModel

Android XR поддерживает формат glTF для 3D-моделей, которые обычно сохраняются в виде файлов .glb. Чтобы добавить эти объекты в макет, используйте функцию SpatialGltfModel. Этот API упрощает процесс загрузки объектов и управления их состоянием.

Чтобы отобразить модель, сначала определите ее источник и состояние с помощью rememberSpatialGltfModelState. Вы можете загружать модели из папки assets приложения, URI или raw data.

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

После того как состояние определено, используйте composable-функцию SpatialGltfModel, чтобы отрисовать его в Subspace.

SpatialGltfModel(state = modelState, modifier = SubspaceModifier)

Ключевые моменты

  • Асинхронная загрузка. Модель загружается асинхронно. При первоначальном создании размер может быть нулевым. Макет будет пересчитан, когда модель будет готова.
  • Управление состоянием. Используйте SpatialGltfModelState.status, чтобы запросить статус загрузки или управлять анимацией.
  • Размер и масштабирование. По умолчанию размер макета соответствует ограничительной рамке объекта. Вы можете переопределить это значение с помощью параметра SubspaceModifier.size, чтобы равномерно масштабировать модель в пределах указанных границ.

Используйте SceneCoreEntity, чтобы размещать объекты в макете

Компонуемый элемент SceneCoreEntity связывает библиотеки Jetpack SceneCore и Compose for XR, чтобы вы могли использовать объекты, созданные с помощью SceneCore, в макетах Compose. Это позволяет создавать объекты более низкого уровня и собственные компоненты, а Compose – определять их размер и положение, добавлять дочерние элементы и модификаторы, а также изменять родительские объекты.

val virtualPixelDensity = session.scene.virtualPixelDensity

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 =
            object : SceneCoreEntitySizeAdapter<SurfaceEntity> {
                override fun onLayoutSizeChanged(
                    entity: SurfaceEntity,
                    size: IntVolumeSize
                ) {
                    val extents = FloatSize2d(
                        size.width.toFloat(),
                        size.height.toFloat()
                    )
                    entity.shape = SurfaceEntity.Shape.Quad(extents)
                }
            },
    ) {
        // Content here will be children of the SceneCoreEntity
        // in the scene graph.
    }
}

Ключевые моменты

  • Блок фабрики. В этом блоке инициализируется базовый объект SceneCore.
  • Блок обновления. Используйте его, чтобы изменять свойства объекта в ответ на изменения состояния Compose.
  • Адаптация размера. sizeAdapter передает размеры объекта обратно в систему макетов Compose.

Дополнительная информация

Как добавить поверхность для изображений или видео

SpatialExternalSurface – это композитный подпространственный объект, который создает и управляет Surface, в котором приложение может отрисовывать контент, например изображение или видео. SpatialExternalSurface поддерживает стереоскопический и моноскопический контент.

В этом примере показано, как загрузить стереоскопическое видео с горизонтальной стереопарой с помощью Media3 Exoplayer и 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() }
        }
    }
}

Ключевые моменты

  • Установите для параметра StereoMode значение Mono, SideBySide или TopBottom в зависимости от типа контента, который вы хотите отобразить:
    • Mono: изображение или видеокадр состоит из одного и того же изображения, которое показывается обоим глазам.
    • SideBySide: изображение или видеокадр содержит пару изображений или видеокадров, расположенных рядом друг с другом. Изображение или кадр слева представляет собой вид левого глаза, а справа – вид правого глаза.
    • TopBottom – изображение или видеокадр содержит пару изображений или видеокадров, расположенных друг над другом. Верхнее изображение или кадр представляет собой вид для левого глаза, а нижнее – для правого.
  • SpatialExternalSurface поддерживает только прямоугольные поверхности.
  • Этот тег Surface не регистрирует события ввода.
  • Невозможно синхронизировать изменения StereoMode с отрисовкой приложения или декодированием видео.
  • Этот компонент не может быть отрисован поверх других панелей, поэтому не следует использовать MovePolicy, если в макете есть другие панели.

Как добавить поверхность для видеоконтента с защитой DRM

SpatialExternalSurface также поддерживает воспроизведение видеопотоков, защищенных DRM. Чтобы включить эту функцию, необходимо создать защищенную поверхность, которая будет отрисовываться в защищенных графических буферах. Это предотвращает запись экрана и доступ к контенту через небезопасные компоненты системы.

Чтобы создать безопасную поверхность, задайте для параметра SpatialExternalSurfaceProtection значение SpatialExternalSurfaceProtection.Protected в composable-функции SpatialExternalSurface. Кроме того, вам необходимо настроить Media3 Exoplayer с подходящей информацией о DRM, чтобы обрабатывать получение лицензии с сервера лицензий.

В следующем примере показано, как настроить SpatialExternalSurface и ExoPlayer для воспроизведения видеопотока, защищенного 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 = SpatialExternalSurfaceProtection.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 = SpatialExternalSurfaceProtection.Protected на SpatialExternalSurface необходима, чтобы базовый Surface поддерживался защищенными буферами, подходящими для контента с DRM.
  • Настройки DRM. В файле MediaItem необходимо указать схему DRM (например, C.WIDEVINE_UUID) и URI сервера лицензий. ExoPlayer использует эту информацию для управления сеансом DRM.
  • Защищенный контент. При отрисовке на защищенной поверхности видеоконтент декодируется и отображается по защищенному пути, что помогает соблюдать требования лицензирования контента. Это также предотвращает появление контента на скриншотах.

Как добавить другие пространственные компоненты интерфейса

Компоненты пространственного интерфейса можно размещать в любом месте иерархии интерфейса приложения. Эти элементы можно повторно использовать в 2D-интерфейсе, а их пространственные атрибуты будут видны только при включенных пространственных возможностях. Это позволяет добавлять тень к меню, диалоговым окнам и другим компонентам, не дублируя код. Ниже приведены примеры пространственного интерфейса, которые помогут вам понять, как использовать эти элементы.

Компонент интерфейса

Если пространственное звучание включено

В 2D-среде

SpatialDialog

Панель немного сдвинется назад по оси Z, чтобы показать диалоговое окно.

Переход на 2D-режим Dialog.

SpatialPopup

Панель немного сдвинется назад по оси Z, чтобы показать всплывающее окно.

Переход на 2D-версию Popup.

SpatialElevation

SpatialElevationLevel – добавить высоту.

Шоу без пространственного звучания.

SpatialDialog

Это пример диалогового окна, которое открывается с небольшой задержкой. Если используется SpatialDialog, диалоговое окно появляется на той же глубине, что и пространственная панель, а панель отодвигается на 125 dp, когда пространственное звучание включено. SpatialDialog также можно использовать, когда пространственное звучание отключено. В этом случае SpatialDialog будет работать как 2D-аналог – Dialog.

@Composable
fun DelayedDialog() {
    var showDialog by remember { mutableStateOf(false) }
    LaunchedEffect(Unit) {
        delay(3000.milliseconds)
        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, вы можете работать непосредственно с экземплярами PanelEntity и графом сцены, используя API SceneCore.

Как прикрепить орбитеры к пространственным панелям и макетам

Орбиту можно привязать к компонентам SpatialPanels и пространственной разметки, объявленным в Compose. Для этого нужно объявить орбитер в пространственной структуре элементов интерфейса, например SpatialRow, SpatialColumn или SpatialBox. Орбитер прикрепляется к родительскому элементу, который находится ближе всего к тому месту, где вы его объявили.

Поведение орбитера зависит от того, где вы его объявите:

  • В 2D-макете, заключенном в тег SpatialPanel (как показано в предыдущем фрагменте кода), орбитер привязывается к этому тегу SpatialPanel.
  • В Subspace орбитер привязывается к ближайшему родительскому объекту, то есть к пространственной структуре, в которой он объявлен.

В следующем примере показано, как привязать орбитер к пространственной строке:

Subspace {
    SpatialRow {
        Orbiter(
            alignment = OrbiterAlignment.TopCenter(
                edgeOffsetType = OrbiterEdgeOffsetType.OuterEdge,
                offset = DpVolumeOffset(y = 8.dp)
            ),
            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, в котором он объявлен.
  • Пространственные макеты, такие как SpatialRow, SpatialColumn, SpatialBox, связаны с объектами без контента. Поэтому орбитер, объявленный в пространственной разметке, привязывается к ней.

Дополнительная информация