Skip to content

Commit c96d983

Browse files
authored
Merge pull request #144 from devbridie/feature/xr/camera-screen
Add a Spatial layout for the Camera screen
2 parents bc5c47c + 1ec9632 commit c96d983

File tree

5 files changed

+138
-8
lines changed

5 files changed

+138
-8
lines changed

feature/camera/build.gradle.kts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -68,6 +68,7 @@ dependencies {
6868
implementation(libs.androidx.concurrent.futures.ktx)
6969
implementation(libs.androidx.window)
7070
implementation(libs.androidx.window.core)
71+
implementation(libs.androidx.xr.compose)
7172
implementation(libs.accompanist.permissions)
7273
implementation(libs.coil.compose)
7374
implementation(libs.kotlinx.coroutines.play.services)
@@ -82,6 +83,7 @@ dependencies {
8283
implementation(projects.core.theme)
8384
implementation(projects.core.util)
8485
implementation(projects.data)
86+
implementation(projects.core.xr)
8587

8688
// Android Instrumented Tests
8789
androidTestImplementation(platform(libs.androidx.compose.bom))

feature/camera/src/main/java/com/android/developers/androidify/camera/CameraLayout.kt

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,10 +45,12 @@ import androidx.compose.ui.Alignment
4545
import androidx.compose.ui.Modifier
4646
import androidx.compose.ui.graphics.Color
4747
import androidx.compose.ui.platform.LocalContext
48+
import androidx.compose.ui.platform.LocalInspectionMode
4849
import androidx.compose.ui.tooling.preview.Preview
4950
import androidx.compose.ui.tooling.preview.PreviewParameter
5051
import androidx.compose.ui.unit.dp
5152
import androidx.lifecycle.compose.LifecycleStartEffect
53+
import com.android.developers.androidify.camera.xr.CameraLayoutSpatial
5254
import com.android.developers.androidify.theme.AndroidifyTheme
5355
import com.android.developers.androidify.theme.TertiaryContainer
5456
import com.android.developers.androidify.util.FoldablePreviewParameters
@@ -57,6 +59,7 @@ import com.android.developers.androidify.util.allowsFullContent
5759
import com.android.developers.androidify.util.isAtLeastMedium
5860
import com.android.developers.androidify.util.shouldShowTabletopLayout
5961
import com.android.developers.androidify.util.supportsTabletop
62+
import com.android.developers.androidify.xr.LocalSpatialCapabilities
6063

6164
@Composable
6265
internal fun CameraLayout(
@@ -68,12 +71,16 @@ internal fun CameraLayout(
6871
guideText: @Composable (modifier: Modifier) -> Unit,
6972
guide: @Composable (modifier: Modifier) -> Unit,
7073
rearCameraButton: @Composable (modifier: Modifier) -> Unit,
74+
surfaceAspectRatio: Float,
75+
xrEnabled: Boolean = false,
7176
supportsTabletop: Boolean = supportsTabletop(),
7277
isTabletop: Boolean = false,
7378
) {
7479
val mContext = LocalContext.current
80+
val inspection = LocalInspectionMode.current
7581
var isCameraLeft by remember { mutableStateOf(false) }
7682
LifecycleStartEffect(Unit) {
83+
if (inspection) return@LifecycleStartEffect onStopOrDispose { }
7784
val displayManager = mContext.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager
7885
val displayListener = object : DisplayManager.DisplayListener {
7986
override fun onDisplayChanged(displayId: Int) {
@@ -94,6 +101,16 @@ internal fun CameraLayout(
94101
.background(TertiaryContainer),
95102
) {
96103
when {
104+
xrEnabled && LocalSpatialCapabilities.current.isSpatialUiEnabled -> CameraLayoutSpatial(
105+
viewfinder,
106+
captureButton,
107+
flipCameraButton,
108+
zoomButton,
109+
guideText,
110+
guide,
111+
surfaceAspectRatio,
112+
)
113+
97114
isAtLeastMedium() && shouldShowTabletopLayout(
98115
supportsTabletop = supportsTabletop,
99116
isTabletop = isTabletop,
@@ -565,6 +582,7 @@ private fun CameraOverlayPreview(
565582
},
566583
supportsTabletop = parameters.supportsTabletop,
567584
isTabletop = parameters.isTabletop,
585+
surfaceAspectRatio = 16f / 9f,
568586
)
569587
}
570588
}

feature/camera/src/main/java/com/android/developers/androidify/camera/CameraScreen.kt

Lines changed: 16 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,8 @@ fun CameraPreviewScreen(
147147
toggleRearCameraFeature = { viewModel.toggleRearDisplayFeature(activity) },
148148
isRearCameraEnabled = uiState.isRearCameraActive,
149149
cameraSessionId = uiState.cameraSessionId,
150+
xrEnabled = uiState.xrEnabled,
151+
surfaceAspectRatio = uiState.surfaceAspectRatio,
150152
)
151153
}
152154
} else {
@@ -203,12 +205,14 @@ fun StatelessCameraPreviewContent(
203205
onAnimateZoom: (Float) -> Unit,
204206
requestCaptureImage: () -> Unit,
205207
modifier: Modifier = Modifier,
208+
surfaceAspectRatio: Float = 9f / 16f,
209+
xrEnabled: Boolean = false,
206210
foldingFeature: FoldingFeature? = null,
207211
shouldShowRearCameraFeature: () -> Boolean = { false },
208212
toggleRearCameraFeature: () -> Unit = {},
209213
isRearCameraEnabled: Boolean = false,
210214
) {
211-
var aspectRatio by remember { mutableFloatStateOf(9f / 16f) }
215+
var layoutAspectRatio by remember { mutableFloatStateOf(9f / 16f) }
212216
val emptyComposable: @Composable (Modifier) -> Unit = {}
213217
val rearCameraButton: @Composable (Modifier) -> Unit = { rearModifier ->
214218
RearCameraButton(
@@ -259,7 +263,7 @@ fun StatelessCameraPreviewContent(
259263
CameraGuide(
260264
detectedPose = detectedPose,
261265
modifier = guideModifier,
262-
defaultAspectRatio = aspectRatio,
266+
defaultAspectRatio = layoutAspectRatio,
263267
)
264268
},
265269
rearCameraButton = (
@@ -273,9 +277,12 @@ fun StatelessCameraPreviewContent(
273277
modifier = modifier.onSizeChanged { size ->
274278
if (size.height > 0) {
275279
// Recalculate aspect ratio based on the overall layout size
276-
aspectRatio = calculateCorrectAspectRatio(size.height, size.width, aspectRatio)
280+
layoutAspectRatio =
281+
calculateCorrectAspectRatio(size.height, size.width, layoutAspectRatio)
277282
}
278283
},
284+
surfaceAspectRatio = surfaceAspectRatio,
285+
xrEnabled = xrEnabled,
279286
)
280287
}
281288

@@ -298,11 +305,13 @@ private fun CameraPreviewContent(
298305
zoomLevel: () -> Float,
299306
onChangeZoomLevel: (zoomLevel: Float) -> Unit,
300307
requestCaptureImage: () -> Unit,
308+
surfaceAspectRatio: Float,
301309
modifier: Modifier = Modifier,
302310
foldingFeature: FoldingFeature? = null,
303311
shouldShowRearCameraFeature: () -> Boolean = { false },
304312
toggleRearCameraFeature: () -> Unit = {},
305313
isRearCameraEnabled: Boolean = false,
314+
xrEnabled: Boolean = false,
306315
) {
307316
val scope = rememberCoroutineScope()
308317
val zoomState = remember(cameraSessionId) {
@@ -324,7 +333,8 @@ private fun CameraPreviewContent(
324333
onScaleZoom = { scope.launch { zoomState.scaleZoom(it) } },
325334
modifier = viewfinderModifier.onSizeChanged { size -> // Apply modifier from slot
326335
if (size.height > 0) {
327-
aspectRatio = calculateCorrectAspectRatio(size.height, size.width, aspectRatio)
336+
aspectRatio =
337+
calculateCorrectAspectRatio(size.height, size.width, aspectRatio)
328338
}
329339
},
330340
)
@@ -341,6 +351,8 @@ private fun CameraPreviewContent(
341351
shouldShowRearCameraFeature = shouldShowRearCameraFeature,
342352
toggleRearCameraFeature = toggleRearCameraFeature,
343353
isRearCameraEnabled = isRearCameraEnabled,
354+
surfaceAspectRatio = surfaceAspectRatio,
355+
xrEnabled = xrEnabled,
344356
modifier = modifier,
345357
)
346358
}

feature/camera/src/main/java/com/android/developers/androidify/camera/CameraViewModel.kt

Lines changed: 10 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ import androidx.lifecycle.asFlow
5050
import androidx.lifecycle.viewModelScope
5151
import androidx.window.layout.FoldingFeature
5252
import androidx.window.layout.WindowInfoTracker
53+
import com.android.developers.androidify.data.ConfigProvider
5354
import com.android.developers.androidify.util.LocalFileProvider
5455
import com.google.mlkit.vision.common.InputImage
5556
import com.google.mlkit.vision.pose.PoseDetection
@@ -76,8 +77,9 @@ class CameraViewModel
7677
application: Application,
7778
val localFileProvider: LocalFileProvider,
7879
val rearCameraUseCase: RearCameraUseCase,
80+
configProvider: ConfigProvider,
7981
) : AndroidViewModel(application) {
80-
private var _uiState = MutableStateFlow(CameraUiState())
82+
private var _uiState = MutableStateFlow(CameraUiState(xrEnabled = configProvider.isXrEnabled()))
8183
val uiState: StateFlow<CameraUiState>
8284
get() = _uiState
8385

@@ -90,10 +92,12 @@ class CameraViewModel
9092

9193
private val cameraPreviewUseCase = Preview.Builder().build().apply {
9294
setSurfaceProvider { newSurfaceRequest ->
93-
_uiState.update { it.copy(surfaceRequest = newSurfaceRequest) }
95+
val width = newSurfaceRequest.resolution.width.toFloat()
96+
val height = newSurfaceRequest.resolution.height.toFloat()
97+
_uiState.update { it.copy(surfaceRequest = newSurfaceRequest, surfaceAspectRatio = height / width) }
9498
surfaceMeteringPointFactory = SurfaceOrientedMeteringPointFactory(
95-
newSurfaceRequest.resolution.width.toFloat(),
96-
newSurfaceRequest.resolution.height.toFloat(),
99+
width,
100+
height,
97101
)
98102
}
99103
}
@@ -351,6 +355,8 @@ data class CameraUiState(
351355
val canFlipCamera: Boolean = true,
352356
val isRearCameraActive: Boolean = false,
353357
val autofocusUiState: AutofocusUiState = AutofocusUiState.Unspecified,
358+
val xrEnabled: Boolean = false,
359+
val surfaceAspectRatio: Float = 9f / 16f,
354360
) {
355361
val zoomOptions = when {
356362
zoomMinRatio <= 0.6f && zoomMaxRatio >= 1f -> listOf(0.6f, 1f)
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
/*
2+
* Copyright 2025 The Android Open Source Project
3+
*
4+
* Licensed under the Apache License, Version 2.0 (the "License");
5+
* you may not use this file except in compliance with the License.
6+
* You may obtain a copy of the License at
7+
*
8+
* https://www.apache.org/licenses/LICENSE-2.0
9+
*
10+
* Unless required by applicable law or agreed to in writing, software
11+
* distributed under the License is distributed on an "AS IS" BASIS,
12+
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13+
* See the License for the specific language governing permissions and
14+
* limitations under the License.
15+
*/
16+
package com.android.developers.androidify.camera.xr
17+
18+
import androidx.compose.foundation.layout.Box
19+
import androidx.compose.foundation.layout.Column
20+
import androidx.compose.foundation.layout.fillMaxSize
21+
import androidx.compose.foundation.layout.padding
22+
import androidx.compose.foundation.layout.size
23+
import androidx.compose.material3.IconButtonDefaults
24+
import androidx.compose.material3.MaterialTheme
25+
import androidx.compose.runtime.Composable
26+
import androidx.compose.ui.Alignment
27+
import androidx.compose.ui.Modifier
28+
import androidx.compose.ui.unit.dp
29+
import androidx.xr.compose.spatial.ContentEdge
30+
import androidx.xr.compose.spatial.Orbiter
31+
import androidx.xr.compose.spatial.OrbiterOffsetType
32+
import androidx.xr.compose.spatial.Subspace
33+
import androidx.xr.compose.subspace.SpatialPanel
34+
import androidx.xr.compose.subspace.layout.SubspaceModifier
35+
import androidx.xr.compose.subspace.layout.aspectRatio
36+
import androidx.xr.compose.subspace.layout.fillMaxSize
37+
import com.android.developers.androidify.xr.MainPanelWorkaround
38+
import com.android.developers.androidify.xr.RequestHomeSpaceIconButton
39+
40+
@Composable
41+
fun CameraLayoutSpatial(
42+
viewfinder: @Composable (modifier: Modifier) -> Unit,
43+
captureButton: @Composable (modifier: Modifier) -> Unit,
44+
flipCameraButton: @Composable (modifier: Modifier) -> Unit,
45+
zoomButton: @Composable (modifier: Modifier) -> Unit,
46+
guideText: @Composable (modifier: Modifier) -> Unit,
47+
guide: @Composable (modifier: Modifier) -> Unit,
48+
surfaceAspectRatio: Float,
49+
) {
50+
Subspace {
51+
MainPanelWorkaround()
52+
SpatialPanel(
53+
SubspaceModifier
54+
.fillMaxSize(0.5f)
55+
.aspectRatio(surfaceAspectRatio),
56+
) {
57+
Orbiter(
58+
position = ContentEdge.Top,
59+
offsetType = OrbiterOffsetType.InnerEdge,
60+
offset = 32.dp,
61+
alignment = Alignment.End,
62+
) {
63+
RequestHomeSpaceIconButton(
64+
modifier = Modifier
65+
.size(64.dp, 64.dp)
66+
.padding(8.dp),
67+
colors = IconButtonDefaults.iconButtonColors(
68+
containerColor = MaterialTheme.colorScheme.secondaryContainer,
69+
),
70+
)
71+
}
72+
Orbiter(ContentEdge.Start, offsetType = OrbiterOffsetType.InnerEdge, offset = 16.dp) {
73+
Column(horizontalAlignment = Alignment.CenterHorizontally) {
74+
captureButton(Modifier)
75+
flipCameraButton(Modifier)
76+
}
77+
}
78+
Orbiter(ContentEdge.Bottom, offsetType = OrbiterOffsetType.InnerEdge) {
79+
zoomButton(Modifier)
80+
}
81+
Box(Modifier.fillMaxSize()) {
82+
viewfinder(Modifier)
83+
guide(Modifier.fillMaxSize())
84+
guideText(
85+
Modifier
86+
.align(Alignment.BottomCenter)
87+
.padding(horizontal = 36.dp, vertical = 64.dp),
88+
)
89+
}
90+
}
91+
}
92+
}

0 commit comments

Comments
 (0)