From 72cb5f07433509bb34b86b554d101bbf5fff379a Mon Sep 17 00:00:00 2001 From: Sudheer Shanka Date: Fri, 19 Sep 2025 01:31:23 +0000 Subject: [PATCH 1/5] Allow retrieval of nullable string arrays from Data Data.Builder#putStringArray() accepts an Array, which allows developers to store string arrays containing null elements. However, the corresponding getter, Data#getStringArray(), returns a non-nullable Array. This causes a ClassCastException at runtime if the array contains a null, making it impossible to retrieve the stored data. This change introduces a new method, getNullableStringArray(), which correctly returns an Array?, matching the setter's behavior and allowing for retrieval of string arrays with null values. Bug: 383071402 Relnote: Add a new API that allows retrieving nullable string arrays from instances of Data class Test: ./gradlew work:work-runtime:assemble \ && ./gradlew work:work-runtime:connectedCheck \ && ./gradlew updateApi Test: ./development/validate_changes.sh HEAD~1 buildOnServer,test,connectedAndroidTest --runOnDependentProjects Change-Id: I78bc3ed262fe129754d753d37c447e57af540e0b --- work/work-runtime/api/current.txt | 3 +- work/work-runtime/api/restricted_current.txt | 3 +- .../java/androidx/work/DataTest.kt | 59 +++++++++++++++++++ .../work/impl/WorkContinuationImplTestKt.kt | 3 +- .../src/main/java/androidx/work/Data_.kt | 29 +++++++-- 5 files changed, 90 insertions(+), 7 deletions(-) diff --git a/work/work-runtime/api/current.txt b/work/work-runtime/api/current.txt index 35b839b4e3966..8bc927adecb8f 100644 --- a/work/work-runtime/api/current.txt +++ b/work/work-runtime/api/current.txt @@ -171,8 +171,9 @@ package androidx.work { method @InaccessibleFromKotlin public java.util.Map getKeyValueMap(); method public long getLong(String key, long defaultValue); method public long[]? getLongArray(String key); + method public String?[]? getNullableStringArray(String key); method public String? getString(String key); - method public String[]? getStringArray(String key); + method @Deprecated public String[]? getStringArray(String key); method public boolean hasKeyWithValueOfType(String key, Class klass); method public byte[] toByteArray(); property public java.util.Map keyValueMap; diff --git a/work/work-runtime/api/restricted_current.txt b/work/work-runtime/api/restricted_current.txt index 35b839b4e3966..8bc927adecb8f 100644 --- a/work/work-runtime/api/restricted_current.txt +++ b/work/work-runtime/api/restricted_current.txt @@ -171,8 +171,9 @@ package androidx.work { method @InaccessibleFromKotlin public java.util.Map getKeyValueMap(); method public long getLong(String key, long defaultValue); method public long[]? getLongArray(String key); + method public String?[]? getNullableStringArray(String key); method public String? getString(String key); - method public String[]? getStringArray(String key); + method @Deprecated public String[]? getStringArray(String key); method public boolean hasKeyWithValueOfType(String key, Class klass); method public byte[] toByteArray(); property public java.util.Map keyValueMap; diff --git a/work/work-runtime/src/androidTest/java/androidx/work/DataTest.kt b/work/work-runtime/src/androidTest/java/androidx/work/DataTest.kt index de7a61759450e..6078944343cea 100644 --- a/work/work-runtime/src/androidTest/java/androidx/work/DataTest.kt +++ b/work/work-runtime/src/androidTest/java/androidx/work/DataTest.kt @@ -18,9 +18,11 @@ package androidx.work import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.SmallTest +import org.junit.Assert.assertArrayEquals import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith @@ -47,4 +49,61 @@ class DataTest { assertFalse(data.hasKeyWithValueOfType("nothing")) assertFalse(data.hasKeyWithValueOfType("two")) } + + @Test + fun getNullableStringArray_withNullValues() { + val array = arrayOf("foo", null, "bar") + val data = workDataOf("array" to array) + val result = data.getNullableStringArray("array") + assertNotNull(result) + assertArrayEquals(array, result) + } + + @Test + fun getNullableStringArray_noNullValues() { + val array = arrayOf("foo", "bar", "baz") + val data = workDataOf("array" to array) + val result = data.getNullableStringArray("array") + assertNotNull(result) + assertArrayEquals(array, result) + } + + @Test + fun getNullableStringArray_allNulls() { + val array = arrayOf(null, null, null) + val data = workDataOf("array" to array) + val result = data.getNullableStringArray("array") + assertNotNull(result) + assertArrayEquals(array, result) + } + + @Test + fun getNullableStringArray_emptyArray() { + val array = arrayOf() + val data = workDataOf("array" to array) + val result = data.getNullableStringArray("array") + assertNotNull(result) + assertArrayEquals(array, result) + } + + @Test + fun getNullableStringArray_keyNotFound() { + val data = workDataOf() + val result = data.getNullableStringArray("nonexistent_key") + assertNull(result) + } + + @Test + fun getNullableStringArray_wrongType() { + val data = workDataOf("array" to 123) + val result = data.getNullableStringArray("array") + assertNull(result) + } + + @Test + fun getNullableStringArray_wrongArrayType() { + val data = workDataOf("array" to intArrayOf(1, 2, 3)) + val result = data.getNullableStringArray("array") + assertNull(result) + } } diff --git a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTestKt.kt b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTestKt.kt index 46780fcc60d75..c99e0a5fa40ef 100644 --- a/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTestKt.kt +++ b/work/work-runtime/src/androidTest/java/androidx/work/impl/WorkContinuationImplTestKt.kt @@ -85,7 +85,8 @@ class WorkContinuationImplTestKt { it.state == WorkInfo.State.SUCCEEDED } assertThat(info.outputData.size()).isEqualTo(2) - assertThat(info.outputData.getStringArray(stringTag)).isEqualTo(arrayOf("hello")) + assertThat(info.outputData.getNullableStringArray(stringTag)) + .isEqualTo(arrayOf("hello")) val intArray = info.outputData.getIntArray(intTag)!!.sortedArray() assertThat(intArray).isEqualTo(intArrayOf(1, 3)) } diff --git a/work/work-runtime/src/main/java/androidx/work/Data_.kt b/work/work-runtime/src/main/java/androidx/work/Data_.kt index 9d151b07882fc..7190550261b03 100644 --- a/work/work-runtime/src/main/java/androidx/work/Data_.kt +++ b/work/work-runtime/src/main/java/androidx/work/Data_.kt @@ -56,14 +56,19 @@ public class Data { return if (value is T) value else defaultValue } - private inline fun getTypedArray( + private inline fun getTypedArray( key: String, constructor: (size: Int, init: (index: Int) -> T) -> TArray, ): TArray? { val value = values[key] - return if (value is Array<*> && value.isArrayOf()) - constructor(value.size) { i -> value[i] as T } - else null + if (value is Array<*>) { + try { + return constructor(value.size) { i -> value[i] as T } + } catch (e: ClassCastException) { + // Fall-through to return null if the cast fails + } + } + return null } /** @@ -184,8 +189,24 @@ public class Data { * @param key The key for the argument * @return The value specified by the key if it exists; `null` otherwise */ + @Deprecated( + message = + "Use getNullableStringArray(key) instead. This method does not correctly handle " + + "String arrays which may contain null elements.", + replaceWith = ReplaceWith("getNullableStringArray(key)"), + ) public fun getStringArray(key: String): Array? = getTypedArray(key, ::Array) + /** + * Gets the string array value for the given key. The array may contain `null` elements. + * + * @param key The key for the argument + * @return The value specified by the key if it exists; `null` otherwise + */ + // null elements are supported to match the setter + @SuppressLint("NullableCollection", "NullableCollectionElement") + public fun getNullableStringArray(key: String): Array? = getTypedArray(key, ::Array) + public val keyValueMap: Map /** * Gets all the values in this Data object. From 048b2516bc38fb283f34d83a880137be1d62e1d1 Mon Sep 17 00:00:00 2001 From: Jasmine Chen Date: Mon, 12 Jan 2026 14:17:31 -0800 Subject: [PATCH 2/5] Revert Timestamps changes for the coverage builder The coverage builder has been fixed to allow for inline functions. Revert the original changes. Bug: 446807322 Test: Build CameraPipe Change-Id: If2969087e93f30dc0707ad3a98a413354cd699e4 --- .../camera/camera2/pipe/core/Timestamps.kt | 31 +++++++++++-------- 1 file changed, 18 insertions(+), 13 deletions(-) diff --git a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Timestamps.kt b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Timestamps.kt index 05b64a36c38cd..a64cb8a1a310f 100644 --- a/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Timestamps.kt +++ b/camera/camera-camera2-pipe/src/main/java/androidx/camera/camera2/pipe/core/Timestamps.kt @@ -25,18 +25,22 @@ import javax.inject.Singleton /** A nanosecond timestamp */ @JvmInline public value class TimestampNs constructor(public val value: Long) { - public operator fun minus(other: TimestampNs): DurationNs = DurationNs(value - other.value) + public inline operator fun minus(other: TimestampNs): DurationNs = + DurationNs(value - other.value) - public operator fun plus(other: DurationNs): TimestampNs = TimestampNs(value + other.value) + public inline operator fun plus(other: DurationNs): TimestampNs = + TimestampNs(value + other.value) } @JvmInline public value class DurationNs(public val value: Long) { - public operator fun minus(other: DurationNs): DurationNs = DurationNs(value - other.value) + public inline operator fun minus(other: DurationNs): DurationNs = + DurationNs(value - other.value) - public operator fun plus(other: DurationNs): DurationNs = DurationNs(value + other.value) + public inline operator fun plus(other: DurationNs): DurationNs = DurationNs(value + other.value) - public operator fun plus(other: TimestampNs): TimestampNs = TimestampNs(value + other.value) + public inline operator fun plus(other: TimestampNs): TimestampNs = + TimestampNs(value + other.value) public operator fun compareTo(other: DurationNs): Int { return if (value == other.value) { @@ -49,7 +53,7 @@ public value class DurationNs(public val value: Long) { } public companion object { - public fun fromMs(durationMs: Long): DurationNs = DurationNs(durationMs * 1_000_000L) + public inline fun fromMs(durationMs: Long): DurationNs = DurationNs(durationMs * 1_000_000L) } } @@ -63,17 +67,18 @@ public class SystemTimeSource @Inject constructor() : TimeSource { } public object Timestamps { - public fun now(timeSource: TimeSource): TimestampNs = timeSource.now() + public inline fun now(timeSource: TimeSource): TimestampNs = timeSource.now() - public fun DurationNs.formatNs(): String = "$this ns" + public inline fun DurationNs.formatNs(): String = "$this ns" - public fun DurationNs.formatMs(decimals: Int = 3): String = + public inline fun DurationNs.formatMs(decimals: Int = 3): String = "%.${decimals}f ms".format(null, this.value / 1_000_000.0) - public fun TimestampNs.formatNs(): String = "$this ns" + public inline fun TimestampNs.formatNs(): String = "$this ns" - public fun TimestampNs.formatMs(): String = "${this.value / 1_000_000} ms" + public inline fun TimestampNs.formatMs(): String = "${this.value / 1_000_000} ms" - public fun TimestampNs.measureNow(timeSource: TimeSource = SystemTimeSource()): DurationNs = - now(timeSource).minus(this) + public inline fun TimestampNs.measureNow( + timeSource: TimeSource = SystemTimeSource() + ): DurationNs = now(timeSource) - this } From 7de1500561e0bab19cb34df21e77e43ce501147f Mon Sep 17 00:00:00 2001 From: Michal Idzkowski Date: Fri, 9 Jan 2026 14:04:09 -0500 Subject: [PATCH 3/5] Adds check for the Projected device context and renames the `getDeviceActivityFeatures` function to `getDeviceContextFeatures` Bug: 474095480 Test: Presubmit Change-Id: Id1b9b3d59618b046cc100beef79ac8349a6512e2 --- .../androidx/xr/runtime/ServiceLoaderExt.kt | 27 ++++++++++++++++--- .../kotlin/androidx/xr/runtime/Session.kt | 2 +- .../xr/runtime/ServiceLoaderExtTest.kt | 24 ++++++++--------- 3 files changed, 37 insertions(+), 16 deletions(-) diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/ServiceLoaderExt.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/ServiceLoaderExt.kt index 1cd2a6aee6ad5..bc3c3ee8210cf 100644 --- a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/ServiceLoaderExt.kt +++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/ServiceLoaderExt.kt @@ -17,10 +17,12 @@ package androidx.xr.runtime import android.app.Activity +import android.companion.virtual.VirtualDeviceManager import android.content.Context import android.content.pm.ActivityInfo import android.content.pm.PackageManager import android.os.Build +import androidx.annotation.RequiresApi import androidx.xr.runtime.internal.Feature import androidx.xr.runtime.internal.Service import androidx.xr.runtime.manifest.FEATURE_XR_API_OPENXR @@ -70,6 +72,7 @@ internal fun loadProviders( } private const val REQUIRED_DISPLAY_CATEGORY_XR_PROJECTED = "xr_projected" +private const val PROJECTED_DEVICE_NAME = "ProjectionDevice" private fun hasXrProjectedDisplayCategory(activityInfo: ActivityInfo): Boolean { // TODO b/460536048 - Remove reflection once requiredDisplayCategory is public in SDK 36 @@ -110,6 +113,18 @@ internal fun isProjectedActivity(context: Context): Boolean { } } +// TODO: b/458737779 - Implement tests when the test rule is available +/** Returns whether the provided context is the Projected device context. */ +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +internal fun isProjectedDeviceContext(context: Context): Boolean = + getVirtualDevice(context)?.name?.startsWith(PROJECTED_DEVICE_NAME) == true + +@RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) +private fun getVirtualDevice(context: Context) = + context.getSystemService(VirtualDeviceManager::class.java).virtualDevices.find { + it.deviceId == context.deviceId + } + /** * Returns the first service provider from [providers] that has its requirements satisfied by the * [features] supported by the current device. @@ -117,15 +132,21 @@ internal fun isProjectedActivity(context: Context): Boolean { internal fun selectProvider(providers: List, features: Set): S? = providers.firstOrNull { features.containsAll(it.requirements) } -/** Returns the set of features available for the current activity on this device. */ -internal fun getDeviceActivityFeatures(context: Context): Set { +/** Returns the set of features available for the current context associated with the device. */ +internal fun getDeviceContextFeatures(context: Context): Set { // Short-circuit for unit tests environments. if (Build.FINGERPRINT.contains("robolectric")) return emptySet() val features = mutableSetOf(Feature.FULLSTACK) val packageManager = context.packageManager - if (isProjectedActivity(context)) { + if (context is Activity && isProjectedActivity(context)) { + features.add(Feature.PROJECTED) + } else if ( + // TODO: b/458737779 - Implement tests when the test rule is available + Build.VERSION.SDK_INT >= Build.VERSION_CODES.UPSIDE_DOWN_CAKE && + isProjectedDeviceContext(context) + ) { features.add(Feature.PROJECTED) } diff --git a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt index 02490e418a04d..2a5de7afd4632 100644 --- a/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt +++ b/xr/runtime/runtime/src/main/kotlin/androidx/xr/runtime/Session.kt @@ -233,7 +233,7 @@ public constructor( return SessionCreateSuccess(activitySessionMap[activity]!!) } - val features = getDeviceActivityFeatures(activity) + val features = getDeviceContextFeatures(activity) val runtimes = mutableListOf() diff --git a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/ServiceLoaderExtTest.kt b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/ServiceLoaderExtTest.kt index e018adf049354..4ada17784d684 100644 --- a/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/ServiceLoaderExtTest.kt +++ b/xr/runtime/runtime/src/test/kotlin/androidx/xr/runtime/ServiceLoaderExtTest.kt @@ -93,41 +93,41 @@ class ServiceLoaderExtTest { } @Test - fun getDeviceActivityFeatures_onRobolectric_returnsEmptySet() { - assertThat(getDeviceActivityFeatures(ApplicationProvider.getApplicationContext())).isEmpty() + fun getDeviceContextFeatures_onRobolectric_returnsEmptySet() { + assertThat(getDeviceContextFeatures(ApplicationProvider.getApplicationContext())).isEmpty() } @Test - fun getDeviceActivityFeatures_notOnRobolectric_addsFullStack() { + fun getDeviceContextFeatures_notOnRobolectric_addsFullStack() { ShadowBuild.setFingerprint("a_real_device") - assertThat(getDeviceActivityFeatures(ApplicationProvider.getApplicationContext())) + assertThat(getDeviceContextFeatures(ApplicationProvider.getApplicationContext())) .containsExactly(Feature.FULLSTACK) } @Test - fun getDeviceActivityFeatures_onOpenXrDevice_addsOpenXr() { + fun getDeviceContextFeatures_onOpenXrDevice_addsOpenXr() { ShadowBuild.setFingerprint("a_real_device") val context: Context = ApplicationProvider.getApplicationContext() shadowOf(context.packageManager) .setSystemFeature(FEATURE_XR_API_OPENXR, /* supported= */ true) - assertThat(getDeviceActivityFeatures(context)).contains(Feature.OPEN_XR) + assertThat(getDeviceContextFeatures(context)).contains(Feature.OPEN_XR) } @Test - fun getDeviceActivityFeatures_onSpatialDevice_addsSpatial() { + fun getDeviceContextFeatures_onSpatialDevice_addsSpatial() { ShadowBuild.setFingerprint("a_real_device") val context: Context = ApplicationProvider.getApplicationContext() shadowOf(context.packageManager) .setSystemFeature(FEATURE_XR_API_SPATIAL, /* supported= */ true) - assertThat(getDeviceActivityFeatures(context)).contains(Feature.SPATIAL) + assertThat(getDeviceContextFeatures(context)).contains(Feature.SPATIAL) } @Test @Config(sdk = [Build.VERSION_CODES.UPSIDE_DOWN_CAKE]) - fun getDeviceActivityFeatures_onProjectedActivity_addsProjected() { + fun getDeviceContextFeatures_onProjectedActivity_addsProjected() { ShadowBuild.setFingerprint("a_real_device") val activity = Robolectric.buildActivity(Activity::class.java).create().get() val activityInfo = ActivityInfo() @@ -141,14 +141,14 @@ class ServiceLoaderExtTest { shadowOf(activity.packageManager).installPackage(packageInfo) - assertThat(getDeviceActivityFeatures(activity)).contains(Feature.PROJECTED) + assertThat(getDeviceContextFeatures(activity)).contains(Feature.PROJECTED) } @Test - fun getDeviceActivityFeatures_onNonProjectedActivity_doesNotAddProjected() { + fun getDeviceContextFeatures_onNonProjectedActivity_doesNotAddProjected() { ShadowBuild.setFingerprint("a_real_device") - assertThat(getDeviceActivityFeatures(ApplicationProvider.getApplicationContext())) + assertThat(getDeviceContextFeatures(ApplicationProvider.getApplicationContext())) .doesNotContain(Feature.PROJECTED) } From 2c963aef83f72bdd1440fd91101df2bb40311248 Mon Sep 17 00:00:00 2001 From: Yashwanth Gajji Date: Thu, 8 Jan 2026 14:36:03 -0800 Subject: [PATCH 4/5] Add SubspacePlaceable.placeRelative for RTL layout support This commit introduces the placeRelative function to SubspacePlacementScope to provide automatic support for Right-To-Left (RTL) layouts. Similar to its counterpart in 2D Compose, placeRelative to position elements adapt to the current LayoutDirection. When the layout direction is RTL, this function automatically mirrors the provided Pose across the YZ plane. This is achieved by: - Inverting the x component of the translation. - Inverting the y and z components of the rotation. Bug: 474409614 Test: Added Unit tests for the new function Relnote: N/A Change-Id: Ie61ac8e85e6faa9fbc27405e0ea9afa84f88a88f --- xr/compose/compose/api/current.txt | 2 + xr/compose/compose/api/restricted_current.txt | 2 + .../subspace/layout/SubspacePlaceable.kt | 58 ++++++++++++- .../SubspaceLayoutModifierNodeCoordinator.kt | 4 + .../subspace/node/SubspaceLayoutNode.kt | 1 + .../subspace/layout/SubspacePlaceableTest.kt | 87 +++++++++++++++++++ 6 files changed, 151 insertions(+), 3 deletions(-) create mode 100644 xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceableTest.kt diff --git a/xr/compose/compose/api/current.txt b/xr/compose/compose/api/current.txt index 79a2a2db88800..d0193739033a5 100644 --- a/xr/compose/compose/api/current.txt +++ b/xr/compose/compose/api/current.txt @@ -1109,8 +1109,10 @@ package androidx.xr.compose.subspace.layout { public abstract static class SubspacePlaceable.SubspacePlacementScope { ctor public SubspacePlaceable.SubspacePlacementScope(); method @InaccessibleFromKotlin public androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates? getCoordinates(); + method @InaccessibleFromKotlin protected abstract androidx.compose.ui.unit.LayoutDirection getParentLayoutDirection(); method public final void place(androidx.xr.compose.subspace.layout.SubspacePlaceable, androidx.xr.runtime.math.Pose pose); property public androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates? coordinates; + property protected abstract androidx.compose.ui.unit.LayoutDirection parentLayoutDirection; } } diff --git a/xr/compose/compose/api/restricted_current.txt b/xr/compose/compose/api/restricted_current.txt index 237f9dca12e2e..e9d6b362ddc63 100644 --- a/xr/compose/compose/api/restricted_current.txt +++ b/xr/compose/compose/api/restricted_current.txt @@ -1124,8 +1124,10 @@ package androidx.xr.compose.subspace.layout { public abstract static class SubspacePlaceable.SubspacePlacementScope { ctor public SubspacePlaceable.SubspacePlacementScope(); method @InaccessibleFromKotlin public androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates? getCoordinates(); + method @InaccessibleFromKotlin protected abstract androidx.compose.ui.unit.LayoutDirection getParentLayoutDirection(); method public final void place(androidx.xr.compose.subspace.layout.SubspacePlaceable, androidx.xr.runtime.math.Pose pose); property public androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates? coordinates; + property protected abstract androidx.compose.ui.unit.LayoutDirection parentLayoutDirection; } } diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceable.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceable.kt index 4987e17421acd..d43c35260a1bd 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceable.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceable.kt @@ -16,12 +16,13 @@ package androidx.xr.compose.subspace.layout +import androidx.annotation.RestrictTo +import androidx.compose.ui.unit.LayoutDirection import androidx.xr.runtime.math.Pose /** * A [SubspacePlaceable] corresponds to a child layout that can be positioned by its parent layout. - * Most [SubspacePlaceables][SubspacePlaceable] are the result of a [SubspaceMeasurable.measure] - * call. + * Most [SubspacePlaceable] are the result of a [SubspaceMeasurable.measure] call. * * Based on [androidx.compose.ui.layout.Placeable]. */ @@ -43,6 +44,13 @@ public abstract class SubspacePlaceable { /** Receiver scope that permits explicit placement of a [SubspacePlaceable]. */ public abstract class SubspacePlacementScope { + /** + * Keeps the layout direction of the parent of the subspace placeable that is being placed + * using current [SubspacePlacementScope]. Used to support automatic position mirroring for + * convenient RTL support in custom layouts. + */ + protected abstract val parentLayoutDirection: LayoutDirection + /** * The [SubspaceLayoutCoordinates] of this layout, if known or `null` if the layout hasn't * been placed yet. @@ -50,9 +58,53 @@ public abstract class SubspacePlaceable { public open val coordinates: SubspaceLayoutCoordinates? get() = null - /** Place a [SubspacePlaceable] at the [Pose] in its parent's coordinate system. */ + /** + * Place a [SubspacePlaceable] at the [Pose] in its parent's coordinate system. + * + * @param pose The pose of the layout. + */ public fun SubspacePlaceable.place(pose: Pose) { placeAt(pose) } + + /** + * Place a [SubspacePlaceable] at the [Pose] in its parent's coordinate system with auto + * mirrored position along YZ plane if parent layout direction is [LayoutDirection.Rtl]. + * + * If the [parentLayoutDirection] is [LayoutDirection.Rtl], this function calculates a new + * pose by mirroring the original [pose] across the YZ plane. This ensures that layouts + * designed for LTR behave intuitively when the locale is switched to RTL. + * + * The mirroring transformation involves: + * 1. Translation: The `x` component of the translation vector is negated (`x -> -x`). This + * moves the object from the right side to the left side, or vice versa. + * 2. Rotation: The `y` and `z` components of the underlying quaternion are negated. This + * has the effect of reversing the direction of yaw (rotation around the Y-axis) and roll + * (rotation around the Z-axis), while leaving pitch (rotation around the X-axis) + * unchanged. + * + * For example, a pose that places an object 20 dp to the left and yawed 30 degrees to the + * right will be transformed to place the object 20 dp to the right and yawed 30 degrees to + * the left. + * + * If the layout direction is LTR, the original [pose] is used without modification. + * + * This API is not mirroring the internal mesh geometry of 3D models. This function only + * affects pose of the layout. + * + * @param pose The pose of the layout. + */ + @RestrictTo(RestrictTo.Scope.LIBRARY_GROUP) + public fun SubspacePlaceable.placeRelative(pose: Pose) { + var newPose = pose + if (parentLayoutDirection == LayoutDirection.Rtl) { + newPose = + Pose( + translation = pose.translation.copy(x = -pose.translation.x), + rotation = pose.rotation.copy(y = -pose.rotation.y, z = -pose.rotation.z), + ) + } + placeAt(newPose) + } } } diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt index 7e42ef0f5a99d..0ef171353dee8 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutModifierNodeCoordinator.kt @@ -16,6 +16,7 @@ package androidx.xr.compose.subspace.node +import androidx.compose.ui.unit.LayoutDirection import androidx.xr.compose.subspace.layout.LayoutSubspaceMeasureScope import androidx.xr.compose.subspace.layout.ParentLayoutParamsAdjustable import androidx.xr.compose.subspace.layout.SubspaceLayoutCoordinates @@ -121,6 +122,9 @@ internal class SubspaceLayoutModifierNodeCoordinator( logger?.nodePlaced(layoutModifierNode, pose) subspaceMeasureResult?.placeChildren( object : SubspacePlacementScope() { + override val parentLayoutDirection = + this@SubspaceLayoutModifierNodeCoordinator.layoutNode?.layoutDirection + ?: LayoutDirection.Ltr public override val coordinates = this@SubspaceLayoutModifierNodeCoordinator } ) diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt index 4ba7b6e5a74cb..e08daffd4a0f9 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/node/SubspaceLayoutNode.kt @@ -507,6 +507,7 @@ internal class SubspaceLayoutNode : ComposeSubspaceNode { subspaceMeasureResult?.placeChildren( object : SubspacePlacementScope() { + override val parentLayoutDirection = this@SubspaceLayoutNode.layoutDirection override val coordinates = this@SubspaceMeasurableLayout } ) diff --git a/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceableTest.kt b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceableTest.kt new file mode 100644 index 0000000000000..dd79865a7342e --- /dev/null +++ b/xr/compose/compose/src/test/kotlin/androidx/xr/compose/subspace/layout/SubspacePlaceableTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2024 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package androidx.xr.compose.subspace.layout + +import androidx.compose.ui.unit.LayoutDirection +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.xr.runtime.math.Pose +import androidx.xr.runtime.math.Quaternion +import androidx.xr.runtime.math.Vector3 +import com.google.common.truth.Truth.assertThat +import kotlin.test.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class SubspacePlaceableTest { + + private class TestSubspacePlaceable : SubspacePlaceable() { + var placedPose: Pose? = null + + override fun placeAt(pose: Pose) { + placedPose = pose + } + } + + private class TestSubspacePlacementScope(override val parentLayoutDirection: LayoutDirection) : + SubspacePlaceable.SubspacePlacementScope() + + @Test + fun place_callsPlaceAtWithCorrectPose() { + val placeable = TestSubspacePlaceable() + val scope = TestSubspacePlacementScope(LayoutDirection.Ltr) + val pose = Pose() + + with(scope) { placeable.place(pose) } + + assertThat(placeable.placedPose).isEqualTo(pose) + } + + @Test + fun placeRelative_ltr_callsPlaceAtWithOriginalPose() { + val placeable = TestSubspacePlaceable() + val scope = TestSubspacePlacementScope(LayoutDirection.Ltr) + val pose = Pose(translation = Vector3(1f, 2f, 3f)) + + with(scope) { placeable.placeRelative(pose) } + + assertThat(placeable.placedPose).isEqualTo(pose) + } + + @Test + fun placeRelative_rtl_callsPlaceAtWithMirroredPose() { + val placeable = TestSubspacePlaceable() + val scope = TestSubspacePlacementScope(LayoutDirection.Rtl) + val originalPose = + Pose(translation = Vector3(1f, 2f, 3f), rotation = Quaternion(0.1f, 0.2f, 0.3f, 0.9f)) + + with(scope) { placeable.placeRelative(originalPose) } + + val placedPose = assertNotNull(placeable.placedPose) + + // Translation x is negated + assertThat(placedPose.translation.x).isEqualTo(-originalPose.translation.x) + assertThat(placedPose.translation.y).isEqualTo(originalPose.translation.y) + assertThat(placedPose.translation.z).isEqualTo(originalPose.translation.z) + + // Rotation y and z components are negated. + assertThat(placedPose.rotation.x).isWithin(1e-6f).of(originalPose.rotation.x) + assertThat(placedPose.rotation.y).isWithin(1e-6f).of(-originalPose.rotation.y) + assertThat(placedPose.rotation.z).isWithin(1e-6f).of(-originalPose.rotation.z) + assertThat(placedPose.rotation.w).isWithin(1e-6f).of(originalPose.rotation.w) + } +} From 7069f86e4dcb698deeadde43821dad9123800113 Mon Sep 17 00:00:00 2001 From: Yashwanth Gajji Date: Tue, 30 Dec 2025 15:18:23 -0800 Subject: [PATCH 5/5] Making SpatialColumn inline function Relnote: "Making SpatialColumn an inline function" Bug: 469149576 Test: N/A Change-Id: I681be0b9b5263f0a2b54eac21d54c81c502b02ce --- xr/compose/compose/api/current.txt | 4 +- xr/compose/compose/api/restricted_current.txt | 14 +++- .../xr/compose/subspace/SpatialColumn.kt | 43 +++++++--- .../compose/subspace/layout/SubspaceLayout.kt | 82 ++++++++++++++++--- 4 files changed, 120 insertions(+), 23 deletions(-) diff --git a/xr/compose/compose/api/current.txt b/xr/compose/compose/api/current.txt index 79a2a2db88800..cf9aa1d671f9d 100644 --- a/xr/compose/compose/api/current.txt +++ b/xr/compose/compose/api/current.txt @@ -304,7 +304,7 @@ package androidx.xr.compose.subspace { } public final class SpatialColumnKt { - method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialColumn(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical verticalArrangement, kotlin.jvm.functions.Function1 content); + method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SpatialColumn(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical verticalArrangement, kotlin.jvm.functions.Function1 content); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialColumn(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SpatialAlignment?, androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical?, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer?, int, int); } @@ -1037,7 +1037,9 @@ package androidx.xr.compose.subspace.layout { method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SubspaceLayout(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy measurePolicy); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy, androidx.compose.runtime.Composer?, int, int); method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SubspaceLayout(kotlin.jvm.functions.Function0 content, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy measurePolicy); + method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SubspaceLayout(kotlin.jvm.functions.Function0 content, String coreEntityName, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy measurePolicy); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(kotlin.jvm.functions.Function2, androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy, androidx.compose.runtime.Composer?, int, int); + method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(kotlin.jvm.functions.Function2, String, androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy, androidx.compose.runtime.Composer?, int, int); } public interface SubspaceMeasurable { diff --git a/xr/compose/compose/api/restricted_current.txt b/xr/compose/compose/api/restricted_current.txt index 237f9dca12e2e..8c504dd66485e 100644 --- a/xr/compose/compose/api/restricted_current.txt +++ b/xr/compose/compose/api/restricted_current.txt @@ -314,8 +314,12 @@ package androidx.xr.compose.subspace { } public final class SpatialColumnKt { - method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialColumn(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical verticalArrangement, kotlin.jvm.functions.Function1 content); + method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SpatialColumn(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, optional androidx.xr.compose.subspace.layout.SpatialAlignment alignment, optional androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical verticalArrangement, kotlin.jvm.functions.Function1 content); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SpatialColumn(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SpatialAlignment?, androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical?, kotlin.jvm.functions.Function3, androidx.compose.runtime.Composer?, int, int); + method @InaccessibleFromKotlin @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy getDefaultSpatialColumnMeasurePolicy(); + method @KotlinOnly @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy spatialColumnMeasurePolicy(androidx.xr.compose.subspace.layout.SpatialAlignment alignment, androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical verticalArrangement); + method @BytecodeOnly @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy spatialColumnMeasurePolicy(androidx.xr.compose.subspace.layout.SpatialAlignment, androidx.xr.compose.subspace.layout.SpatialArrangement.Vertical, androidx.compose.runtime.Composer?, int); + property @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy DefaultSpatialColumnMeasurePolicy; } @androidx.compose.foundation.layout.LayoutScopeMarker public interface SpatialColumnScope { @@ -325,6 +329,12 @@ package androidx.xr.compose.subspace { method @BytecodeOnly public static androidx.xr.compose.subspace.layout.SubspaceModifier! weight$default(androidx.xr.compose.subspace.SpatialColumnScope!, androidx.xr.compose.subspace.layout.SubspaceModifier!, float, boolean, int, Object!); } + @kotlin.PublishedApi internal final class SpatialColumnScopeInstance implements androidx.xr.compose.subspace.SpatialColumnScope { + method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment.Depth alignment); + method public androidx.xr.compose.subspace.layout.SubspaceModifier align(androidx.xr.compose.subspace.layout.SubspaceModifier, androidx.xr.compose.subspace.layout.SpatialAlignment.Horizontal alignment); + method public androidx.xr.compose.subspace.layout.SubspaceModifier weight(androidx.xr.compose.subspace.layout.SubspaceModifier, float weight, boolean fill); + } + public final class SpatialCurvedRowDefaults { method @BytecodeOnly public float getCurveRadius-D9Ej5fM(); property public androidx.compose.ui.unit.Dp curveRadius; @@ -1050,7 +1060,9 @@ package androidx.xr.compose.subspace.layout { method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SubspaceLayout(optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy measurePolicy); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy, androidx.compose.runtime.Composer?, int, int); method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SubspaceLayout(kotlin.jvm.functions.Function0 content, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy measurePolicy); + method @KotlinOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static inline void SubspaceLayout(kotlin.jvm.functions.Function0 content, String coreEntityName, optional androidx.xr.compose.subspace.layout.SubspaceModifier modifier, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy measurePolicy); method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(kotlin.jvm.functions.Function2, androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy, androidx.compose.runtime.Composer?, int, int); + method @BytecodeOnly @androidx.compose.runtime.Composable @androidx.xr.compose.subspace.SubspaceComposable public static void SubspaceLayout(kotlin.jvm.functions.Function2, String, androidx.xr.compose.subspace.layout.SubspaceModifier?, androidx.xr.compose.subspace.layout.SubspaceMeasurePolicy, androidx.compose.runtime.Composer?, int, int); method @BytecodeOnly @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.OpaqueEntity rememberOpaqueEntity(kotlin.jvm.functions.Function1, androidx.compose.runtime.Composer?, int); method @KotlinOnly @androidx.compose.runtime.Composable @kotlin.PublishedApi internal static androidx.xr.compose.subspace.layout.OpaqueEntity rememberOpaqueEntity(kotlin.jvm.functions.Function1 entityFactory); } diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt index 8c83ca125abc1..7f0b2c3b0e876 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/SpatialColumn.kt @@ -22,8 +22,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.remember import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastRoundToInt -import androidx.xr.compose.platform.LocalSession -import androidx.xr.compose.subspace.layout.CoreGroupEntity import androidx.xr.compose.subspace.layout.SpatialAlignment import androidx.xr.compose.subspace.layout.SpatialArrangement import androidx.xr.compose.subspace.layout.SubspaceLayout @@ -38,7 +36,6 @@ import androidx.xr.compose.unit.VolumeConstraints import androidx.xr.runtime.math.Pose import androidx.xr.runtime.math.Quaternion import androidx.xr.runtime.math.Vector3 -import androidx.xr.scenecore.GroupEntity /** * A layout composable that arranges its children in a vertical sequence. @@ -52,24 +49,47 @@ import androidx.xr.scenecore.GroupEntity */ @Composable @SubspaceComposable -public fun SpatialColumn( +public inline fun SpatialColumn( modifier: SubspaceModifier = SubspaceModifier, alignment: SpatialAlignment = SpatialAlignment.Center, verticalArrangement: SpatialArrangement.Vertical = SpatialArrangement.Center, - content: @Composable @SubspaceComposable SpatialColumnScope.() -> Unit, + crossinline content: @Composable @SubspaceComposable SpatialColumnScope.() -> Unit, ) { - val session = checkNotNull(LocalSession.current) { "session must be initialized" } - val coreGroupEntity = remember { - CoreGroupEntity(GroupEntity.create(session, name = "SpatialColumn", pose = Pose.Identity)) - } + val measurePolicy = + spatialColumnMeasurePolicy(alignment = alignment, verticalArrangement = verticalArrangement) + SubspaceLayout( modifier = modifier, content = { SpatialColumnScopeInstance.content() }, - coreEntity = coreGroupEntity, - measurePolicy = SpatialColumnMeasurePolicy(alignment, verticalArrangement), + coreEntityName = "SpatialColumn", + measurePolicy = measurePolicy, ) } +@PublishedApi +internal val DefaultSpatialColumnMeasurePolicy: SubspaceMeasurePolicy = + SpatialColumnMeasurePolicy( + alignment = SpatialAlignment.Center, + verticalArrangement = SpatialArrangement.Center, + ) + +@PublishedApi +@Composable +internal fun spatialColumnMeasurePolicy( + alignment: SpatialAlignment, + verticalArrangement: SpatialArrangement.Vertical, +): SubspaceMeasurePolicy = + if (alignment == SpatialAlignment.Center && verticalArrangement == SpatialArrangement.Center) { + DefaultSpatialColumnMeasurePolicy + } else { + remember(alignment, verticalArrangement) { + SpatialColumnMeasurePolicy( + alignment = alignment, + verticalArrangement = verticalArrangement, + ) + } + } + /** * Measure policy for [SpatialColumn] layouts. Handles the measurement and placement of children in * a vertical sequence. @@ -255,6 +275,7 @@ public interface SpatialColumnScope { } /** Default implementation of the [SpatialColumnScope] interface. */ +@PublishedApi internal object SpatialColumnScopeInstance : SpatialColumnScope { override fun SubspaceModifier.weight(weight: Float, fill: Boolean): SubspaceModifier { require(weight > 0.0) { "invalid weight $weight; must be greater than zero" } diff --git a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt index 7b879753beff8..ec0e8f49cb413 100644 --- a/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt +++ b/xr/compose/compose/src/main/kotlin/androidx/xr/compose/subspace/layout/SubspaceLayout.kt @@ -38,8 +38,8 @@ import androidx.xr.scenecore.GroupEntity /** * [SubspaceLayout] is the main component for laying out leaf nodes with zero children. * - * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by - * the [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. + * The measurement, layout and intrinsic measurement behaviors of this layout will be defined by the + * [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. * * Example: * ```kotlin @@ -80,8 +80,8 @@ public inline fun SubspaceLayout( * [SubspaceLayout] is the main core component for layout. It can be used to measure and position * zero or more layout children. * - * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by - * the [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. + * The measurement, layout and intrinsic measurement behaviors of this layout will be defined by the + * [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. * * Example: * ```kotlin @@ -132,6 +132,69 @@ public inline fun SubspaceLayout( } } +/** + * [SubspaceLayout] is the main core component for layout. It can be used to measure and position + * zero or more layout children. + * + * The measurement, layout and intrinsic measurement behaviors of this layout will be defined by the + * [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. + * + * Example: + * ```kotlin + * fun MyLayout( + * modifier: SubspaceModifier = SubspaceModifier, + * content: @SubspaceComposable @Composable () -> Unit) { + * SubspaceLayout(content = content, modifier = modifier) { + * measurables, constraints -> + * val placeables = measurables.map { it.measure(constraints) } + * layout(constraints.maxWidth, constraints.maxHeight, constraints.maxDepth) { + * placeables.forEach { it.place(Pose.Identity) } + * } + * } + * } + * ``` + * + * @param modifier SubspaceModifier to apply during layout + * @param content the child composables to be laid out. + * @param coreEntityName A name for the underlying [androidx.xr.scenecore.GroupEntity] that is + * created to host the content of this layout. This name is used for debugging and identification + * purposes; it will appear in scene graph inspectors, making it easier to correlate this + * composable with its corresponding node in the 3D scene. + * @param measurePolicy a policy defining the measurement and positioning of the layout. + */ +@Suppress("ComposableLambdaParameterPosition", "NOTHING_TO_INLINE") +@SubspaceComposable +@Composable +public inline fun SubspaceLayout( + crossinline content: @Composable @SubspaceComposable () -> Unit, + coreEntityName: String, + modifier: SubspaceModifier = SubspaceModifier, + measurePolicy: SubspaceMeasurePolicy, +) { + check(currentComposer.applier.current is ComposeSubspaceNode) { + "SubspaceComposable functions are expected to be used within the context of a " + + "Subspace composition. Please ensure that this component is in a Subspace or " + + " is a child of another SubspaceComposable." + } + + val coreEntity = rememberOpaqueEntity { + GroupEntity.create(session = this, name = coreEntityName) + } + val compositionLocalMap = currentComposer.currentCompositionLocalMap + CompositionLocalProvider(LocalOpaqueEntity provides coreEntity) { + ComposeNode>( + factory = ComposeSubspaceNode.Constructor, + update = { + set(compositionLocalMap, SetCompositionLocalMap) + set(measurePolicy, SetMeasurePolicy) + set(coreEntity, SetCoreEntity) + set(modifier, SetModifier) + }, + content = content, + ) + } +} + /** Creates a [CoreGroupEntity] that is automatically disposed of when it leaves the composition. */ @Composable @PublishedApi @@ -146,8 +209,8 @@ internal fun rememberOpaqueEntity( * [SubspaceLayout] is the main core component for layout for "leaf" nodes. It can be used to * measure and position zero children. * - * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by - * the [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. + * The measurement, layout and intrinsic measurement behaviors of this layout will be defined by the + * [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. * * @param modifier SubspaceModifier to apply during layout. * @param coreEntity SceneCore Entity being placed in this layout. This parameter is generally not @@ -187,8 +250,8 @@ internal inline fun SubspaceLayout( * [SubspaceLayout] is the main core component for layout. It can be used to measure and position * zero or more layout children. * - * The measurement, layout and intrinsic measurement behaviours of this layout will be defined by - * the [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. + * The measurement, layout and intrinsic measurement behaviors of this layout will be defined by the + * [SubspaceMeasurePolicy] instance. See [SubspaceMeasurePolicy] for more details. * * @param modifier SubspaceModifier to apply during layout * @param coreEntity SceneCore Entity being placed in this layout. This parameter is generally not @@ -208,9 +271,8 @@ internal inline fun SubspaceLayout( measurePolicy: SubspaceMeasurePolicy, ) { - val session = checkNotNull(LocalSession.current) { "session must be initialized" } val coreGroupEntity = - coreEntity ?: remember { CoreGroupEntity(GroupEntity.create(session, name = "Entity")) } + coreEntity ?: rememberOpaqueEntity { GroupEntity.create(this, name = "Entity") } check(currentComposer.applier.current is ComposeSubspaceNode) { "SubspaceComposable functions are expected to be used within the context of a " +