1 /*
<lambda>null2 * Copyright 2024 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 * http://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
17 package androidx.xr.compose.subspace
18
19 import android.content.Intent
20 import android.graphics.Color
21 import android.graphics.Rect
22 import android.view.View
23 import android.view.View.MeasureSpec
24 import android.view.ViewGroup
25 import android.widget.FrameLayout
26 import androidx.annotation.RestrictTo
27 import androidx.compose.foundation.background
28 import androidx.compose.foundation.gestures.detectTapGestures
29 import androidx.compose.foundation.layout.Box
30 import androidx.compose.foundation.layout.fillMaxSize
31 import androidx.compose.foundation.shape.CornerSize
32 import androidx.compose.runtime.Composable
33 import androidx.compose.runtime.CompositionLocalProvider
34 import androidx.compose.runtime.DisposableEffect
35 import androidx.compose.runtime.LaunchedEffect
36 import androidx.compose.runtime.getValue
37 import androidx.compose.runtime.mutableIntStateOf
38 import androidx.compose.runtime.remember
39 import androidx.compose.runtime.setValue
40 import androidx.compose.ui.Modifier
41 import androidx.compose.ui.UiComposable
42 import androidx.compose.ui.graphics.Color as UiColor
43 import androidx.compose.ui.input.pointer.pointerInput
44 import androidx.compose.ui.layout.Layout
45 import androidx.compose.ui.platform.LocalContext
46 import androidx.compose.ui.platform.LocalView
47 import androidx.compose.ui.unit.dp
48 import androidx.compose.ui.util.fastForEach
49 import androidx.compose.ui.util.fastMaxOfOrNull
50 import androidx.xr.compose.platform.LocalDialogManager
51 import androidx.xr.compose.platform.LocalOpaqueEntity
52 import androidx.xr.compose.platform.LocalSession
53 import androidx.xr.compose.platform.getActivity
54 import androidx.xr.compose.subspace.layout.SpatialRoundedCornerShape
55 import androidx.xr.compose.subspace.layout.SpatialShape
56 import androidx.xr.compose.subspace.layout.SubspaceLayout
57 import androidx.xr.compose.subspace.layout.SubspaceModifier
58 import androidx.xr.compose.unit.Meter.Companion.millimeters
59 import androidx.xr.runtime.math.Pose
60 import androidx.xr.runtime.math.Vector3
61 import androidx.xr.scenecore.ActivityPanelEntity
62 import androidx.xr.scenecore.Dimensions
63 import androidx.xr.scenecore.PanelEntity
64
65 private const val DEFAULT_SIZE_PX = 400
66
67 /** Contains default values used by spatial panels. */
68 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
69 public object SpatialPanelDefaults {
70
71 /** Default shape for a Spatial Panel. */
72 public val shape: SpatialShape = SpatialRoundedCornerShape(CornerSize(32.dp))
73 }
74
75 /**
76 * Creates a [SpatialPanel] representing a 2D plane in 3D space in which an application can fill
77 * content.
78 *
79 * @param view Content view to be displayed within the SpatialPanel.
80 * @param modifier SubspaceModifiers to apply to the SpatialPanel.
81 * @param shape The shape of this Spatial Panel.
82 */
83 @Composable
84 @SubspaceComposable
85 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
SpatialPanelnull86 public fun SpatialPanel(
87 view: View,
88 modifier: SubspaceModifier = SubspaceModifier,
89 shape: SpatialShape = SpatialPanelDefaults.shape,
90 ) {
91 val minimumPanelDimension = Dimensions(10f, 10f, 10f)
92 val frameLayout = remember {
93 FrameLayout(view.context).also {
94 if (view.parent != it) {
95 val parent = view.parent as? ViewGroup
96 parent?.removeView(view)
97 it.addView(view)
98 }
99 }
100 }
101 val scrim = remember { View(view.context) }
102 val dialogManager = LocalDialogManager.current
103 val corePanelEntity =
104 rememberCorePanelEntity(shape = shape) {
105 PanelEntity.create(
106 session = this,
107 view = frameLayout,
108 dimensions = minimumPanelDimension,
109 name = entityName("SpatialPanel"),
110 pose = Pose.Identity,
111 )
112 }
113
114 LaunchedEffect(dialogManager.isSpatialDialogActive.value) {
115 if (dialogManager.isSpatialDialogActive.value) {
116 scrim.setBackgroundColor(Color.argb(90, 0, 0, 0))
117 val scrimLayoutParams =
118 FrameLayout.LayoutParams(
119 FrameLayout.LayoutParams.MATCH_PARENT,
120 FrameLayout.LayoutParams.MATCH_PARENT,
121 )
122
123 if (scrim.parent == null) {
124 frameLayout.addView(scrim, scrimLayoutParams)
125 }
126
127 scrim.setOnClickListener { dialogManager.isSpatialDialogActive.value = false }
128 } else {
129 frameLayout.removeView(scrim)
130 }
131 }
132
133 SubspaceLayout(modifier = modifier, coreEntity = corePanelEntity) { _, constraints ->
134 view.measure(
135 MeasureSpec.makeMeasureSpec(constraints.maxWidth, MeasureSpec.AT_MOST),
136 MeasureSpec.makeMeasureSpec(constraints.maxHeight, MeasureSpec.AT_MOST),
137 )
138 val width = view.measuredWidth.coerceIn(constraints.minWidth, constraints.maxWidth)
139 val height = view.measuredHeight.coerceIn(constraints.minHeight, constraints.maxHeight)
140 val depth = constraints.minDepth.coerceAtLeast(0)
141 layout(width, height, depth) {}
142 }
143 }
144
145 /**
146 * Creates a [SpatialPanel] representing a 2D plane in 3D space in which an application can fill
147 * content.
148 *
149 * @param modifier SubspaceModifiers to apply to the SpatialPanel.
150 * @param shape The shape of this Spatial Panel.
151 * @param content The composable content to render within the SpatialPanel.
152 */
153 @Composable
154 @SubspaceComposable
155 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
156 public fun SpatialPanel(
157 modifier: SubspaceModifier = SubspaceModifier,
158 shape: SpatialShape = SpatialPanelDefaults.shape,
159 content: @Composable @UiComposable () -> Unit,
160 ) {
161 val view = rememberComposeView()
162 val minimumPanelDimension = Dimensions(10f, 10f, 10f)
163 val dialogManager = LocalDialogManager.current
164 val corePanelEntity =
<lambda>null165 rememberCorePanelEntity(shape = shape) {
166 PanelEntity.create(
167 session = this,
168 view = view,
169 dimensions = minimumPanelDimension,
170 name = entityName("SpatialPanel"),
171 pose = Pose.Identity,
172 )
173 }
<lambda>null174 var intrinsicWidth by remember { mutableIntStateOf(DEFAULT_SIZE_PX) }
<lambda>null175 var intrinsicHeight by remember { mutableIntStateOf(DEFAULT_SIZE_PX) }
176
volumeConstraintsnull177 SubspaceLayout(modifier = modifier, coreEntity = corePanelEntity) { _, volumeConstraints ->
178 view.setContent {
179 CompositionLocalProvider(LocalOpaqueEntity provides corePanelEntity) {
180 Layout(content = content, modifier = Modifier) { measurables, constraints ->
181 intrinsicWidth =
182 measurables.fastMaxOfOrNull {
183 try {
184 it.maxIntrinsicWidth(volumeConstraints.maxHeight)
185 } catch (e: IllegalStateException) {
186 0
187 }
188 } ?: DEFAULT_SIZE_PX
189 intrinsicHeight =
190 measurables.fastMaxOfOrNull {
191 try {
192 it.maxIntrinsicHeight(volumeConstraints.maxWidth)
193 } catch (e: IllegalStateException) {
194 0
195 }
196 } ?: DEFAULT_SIZE_PX
197 val placeables = measurables.map { it.measure(constraints) }
198 layout(
199 placeables.fastMaxOfOrNull { it.measuredWidth } ?: DEFAULT_SIZE_PX,
200 placeables.fastMaxOfOrNull { it.measuredHeight } ?: DEFAULT_SIZE_PX,
201 ) {
202 placeables.fastForEach { placeable -> placeable.place(0, 0) }
203 }
204 }
205 }
206
207 if (dialogManager.isSpatialDialogActive.value) {
208 Box(
209 modifier =
210 Modifier.fillMaxSize()
211 .background(UiColor.Black.copy(alpha = 0.5f))
212 .pointerInput(Unit) {
213 detectTapGestures {
214 dialogManager.isSpatialDialogActive.value = false
215 }
216 }
217 ) {}
218 }
219 }
220 val width = intrinsicWidth.coerceIn(volumeConstraints.minWidth, volumeConstraints.maxWidth)
221 val height =
222 intrinsicHeight.coerceIn(volumeConstraints.minHeight, volumeConstraints.maxHeight)
223 val depth = volumeConstraints.minDepth.coerceAtLeast(0)
224 layout(width, height, depth) {}
225 }
226 }
227
228 /**
229 * Creates a [SpatialPanel] backed by the main Window content.
230 *
231 * This panel requires the following specific configuration in the Android Manifest for proper
232 * sizing/resizing behavior:
233 * ```
234 * <activity
235 * android:configChanges="orientation|screenSize|screenLayout|smallestScreenSize>
236 * <!--suppress AndroidElementNotAllowed -->
237 * <layout android:defaultWidth="50dp" android:defaultHeight="50dp" android:minHeight="50dp"
238 * android:minWidth="50dp"/>
239 * </activity>
240 * ```
241 *
242 * To disable Title Bars in the activity add the following to Activity#onCreate:
243 * ```
244 * requestWindowFeature(Window.FEATURE_NO_TITLE)
245 * ```
246 *
247 * Or disable Title Bars in the theme by setting:
248 * ```
249 * <item name="windowNoTitle">true</item>
250 * ```
251 *
252 * @param modifier SubspaceModifier to apply to the MainPanel.
253 * @param shape The shape of this Spatial Panel.
254 */
255 @Composable
256 @SubspaceComposable
257 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
MainPanelnull258 public fun MainPanel(
259 modifier: SubspaceModifier = SubspaceModifier,
260 shape: SpatialShape = SpatialPanelDefaults.shape,
261 ) {
262 val mainPanel = rememberCoreMainPanelEntity(shape = shape)
263 val view = LocalContext.current.getActivity().window?.decorView ?: LocalView.current
264
265 DisposableEffect(Unit) {
266 mainPanel.hidden = false
267 onDispose { mainPanel.hidden = true }
268 }
269
270 SubspaceLayout(modifier = modifier, coreEntity = mainPanel) { _, constraints ->
271 val width = view.measuredWidth.coerceIn(constraints.minWidth, constraints.maxWidth)
272 val height = view.measuredHeight.coerceIn(constraints.minHeight, constraints.maxHeight)
273 val depth = constraints.minDepth.coerceAtLeast(0)
274 layout(width, height, depth) {}
275 }
276 }
277
278 /**
279 * Creates a [SpatialPanel] and launches an Activity within it.
280 *
281 * To disable Title Bars in the activity add the following to Activity#onCreate:
282 * ```
283 * requestWindowFeature(Window.FEATURE_NO_TITLE)
284 * ```
285 *
286 * Or disable Title Bars in the theme by setting:
287 * ```
288 * <item name="windowNoTitle">true</item>
289 * ```
290 *
291 * @param intent The intent of an Activity to launch within this panel.
292 * @param modifier SubspaceModifiers to apply to the SpatialPanel.
293 * @param shape The shape of this Spatial Panel.
294 */
295 @Composable
296 @SubspaceComposable
297 @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP_PREFIX)
SpatialPanelnull298 public fun SpatialPanel(
299 intent: Intent,
300 modifier: SubspaceModifier = SubspaceModifier,
301 shape: SpatialShape = SpatialPanelDefaults.shape,
302 ) {
303 val session = checkNotNull(LocalSession.current) { "session must be initialized" }
304 val dialogManager = LocalDialogManager.current
305
306 val minimumPanelDimension = Dimensions(10f, 10f, 10f)
307 val rect = Rect(0, 0, DEFAULT_SIZE_PX, DEFAULT_SIZE_PX)
308 val activityPanelEntity = rememberCorePanelEntity {
309 ActivityPanelEntity.create(session, rect, entityName("ActivityPanel-${intent.action}"))
310 .also { it.launchActivity(intent) }
311 }
312
313 SpatialBox {
314 SubspaceLayout(modifier = modifier, coreEntity = activityPanelEntity) { _, constraints ->
315 val width = DEFAULT_SIZE_PX.coerceIn(constraints.minWidth, constraints.maxWidth)
316 val height = DEFAULT_SIZE_PX.coerceIn(constraints.minHeight, constraints.maxHeight)
317 val depth = constraints.minDepth.coerceAtLeast(0)
318 layout(width, height, depth) {}
319 }
320
321 if (dialogManager.isSpatialDialogActive.value) {
322 val scrimView = rememberComposeView()
323
324 scrimView.setContent {
325 Box(
326 modifier =
327 Modifier.fillMaxSize()
328 .background(UiColor.Black.copy(alpha = 0.5f))
329 .pointerInput(Unit) {
330 detectTapGestures {
331 dialogManager.isSpatialDialogActive.value = false
332 }
333 }
334 ) {}
335 }
336
337 val scrimPanelEntity =
338 rememberCorePanelEntity(shape = shape) {
339 PanelEntity.create(
340 session = session,
341 view = scrimView,
342 dimensions = minimumPanelDimension,
343 name = entityName("ScrimPanel"),
344 pose = Pose.Identity,
345 )
346 .also {
347 it.setParent(activityPanelEntity.entity)
348 it.setPose(Pose(translation = Vector3(0f, 0f, 3.millimeters.toM())))
349 }
350 }
351
352 LaunchedEffect(activityPanelEntity.size) {
353 val size = activityPanelEntity.size
354 scrimPanelEntity.size = size
355 }
356 }
357 }
358 }
359