diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/ApiCompat.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/ApiCompat.kt index 1798e146368d2..1804f9f639e71 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/ApiCompat.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/ApiCompat.kt @@ -16,10 +16,14 @@ package androidx.camera.camera2.compat +import android.content.Context import android.hardware.camera2.CameraCaptureSession import android.hardware.camera2.CaptureRequest import android.os.Build +import android.util.Size +import android.view.Display import android.view.Surface +import android.view.WindowManager import androidx.annotation.RequiresApi @RequiresApi(Build.VERSION_CODES.N) @@ -36,6 +40,20 @@ internal object Api24Compat { } } +@RequiresApi(Build.VERSION_CODES.R) +internal object Api30Compat { + @JvmStatic + fun getDisplaySize(context: Context, display: Display): Size { + val displayContext = context.createDisplayContext(display) + val windowManager = displayContext.getSystemService(WindowManager::class.java) + val bounds = + checkNotNull(windowManager) { "WindowManager is not available." } + .maximumWindowMetrics + .bounds + return Size(bounds.width(), bounds.height()) + } +} + @RequiresApi(Build.VERSION_CODES.UPSIDE_DOWN_CAKE) internal object Api34Compat { @JvmStatic diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/DisplayCompat.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/DisplayCompat.kt new file mode 100644 index 0000000000000..3561913e4a629 --- /dev/null +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/compat/DisplayCompat.kt @@ -0,0 +1,45 @@ +/* + * Copyright 2026 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.camera.camera2.compat + +import android.content.Context +import android.graphics.Point +import android.os.Build +import android.util.Size +import android.view.Display + +/** Helper for methods of [Display] that are backward compatible. */ +public object DisplayCompat { + + /** + * Gets the real size of the display. + * + * Uses [Api30Compat.getDisplaySize] on API 30+ to avoid using the deprecated + * [Display.getRealSize] method. + */ + @JvmStatic + @Suppress("DEPRECATION") + public fun getDisplaySize(context: Context, display: Display): Size { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + Api30Compat.getDisplaySize(context, display) + } else { + val displaySize = Point() + display.getRealSize(displaySize) + Size(displaySize.x, displaySize.y) + } + } +} diff --git a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DisplayInfoManager.kt b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DisplayInfoManager.kt index 819240285443b..fa0cceb06ba24 100644 --- a/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DisplayInfoManager.kt +++ b/camera/camera-camera2/src/main/java/androidx/camera/camera2/impl/DisplayInfoManager.kt @@ -17,7 +17,6 @@ package androidx.camera.camera2.impl import android.content.Context -import android.graphics.Point import android.hardware.display.DisplayManager import android.hardware.display.DisplayManager.DisplayListener import android.os.Handler @@ -25,6 +24,7 @@ import android.os.Looper import android.util.Size import android.view.Display import androidx.annotation.VisibleForTesting +import androidx.camera.camera2.compat.DisplayCompat.getDisplaySize import androidx.camera.camera2.compat.workaround.DisplaySizeCorrector import androidx.camera.camera2.compat.workaround.MaxPreviewSize import androidx.camera.core.impl.utils.ContextUtil @@ -40,8 +40,7 @@ import androidx.camera.core.internal.utils.SizeUtil * the display configuration changes. The next call to [getDisplays] or [getPreviewSize] will then * fetch the fresh data. */ -@Suppress("DEPRECATION") // getRealSize -public class DisplayInfoManager private constructor(context: Context) { +public class DisplayInfoManager private constructor(private val context: Context) { private val maxPreviewSize = MaxPreviewSize() private val displaySizeCorrector = DisplaySizeCorrector() @@ -179,18 +178,16 @@ public class DisplayInfoManager private constructor(context: Context) { var maxDisplaySize = -1 for (display: Display in displays) { - val displaySize = Point() - // TODO(b/230400472): Use WindowManager#getCurrentWindowMetrics(). Display#getRealSize() - // is deprecated since API level 31. - display.getRealSize(displaySize) + val displaySize = getDisplaySize(context, display) + val area = displaySize.width * displaySize.height - if (displaySize.x * displaySize.y > maxDisplaySize) { - maxDisplaySize = displaySize.x * displaySize.y + if (area > maxDisplaySize) { + maxDisplaySize = area maxDisplay = display } if (display.state != Display.STATE_OFF) { - if (displaySize.x * displaySize.y > maxDisplaySizeWhenStateNotOff) { - maxDisplaySizeWhenStateNotOff = displaySize.x * displaySize.y + if (area > maxDisplaySizeWhenStateNotOff) { + maxDisplaySizeWhenStateNotOff = area maxDisplayWhenStateNotOff = display } } @@ -216,9 +213,7 @@ public class DisplayInfoManager private constructor(context: Context) { } private fun getCorrectedDisplaySize(): Size { - val displaySize = Point() - getMaxSizeDisplay(false).getRealSize(displaySize) - var displayViewSize = Size(displaySize.x, displaySize.y) + var displayViewSize = getDisplaySize(context, getMaxSizeDisplay(false)) // Checks whether the display size is abnormally small. if (SizeUtil.isSmallerByArea(displayViewSize, ABNORMAL_DISPLAY_SIZE_THRESHOLD)) { diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/adapter/SupportedSurfaceCombinationTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/adapter/SupportedSurfaceCombinationTest.kt index d444df3934082..d2d3a2ec66ba8 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/adapter/SupportedSurfaceCombinationTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/adapter/SupportedSurfaceCombinationTest.kt @@ -71,6 +71,7 @@ import androidx.camera.camera2.pipe.testing.FakeCameraBackend import androidx.camera.camera2.pipe.testing.FakeCameraDevices import androidx.camera.camera2.pipe.testing.FakeCameraMetadata import androidx.camera.camera2.pipe.testing.HighEndDeviceTemplate +import androidx.camera.camera2.testing.TestShadowWindowManager import androidx.camera.core.CameraSelector import androidx.camera.core.CameraSelector.LensFacing import androidx.camera.core.CameraX @@ -167,7 +168,7 @@ import org.robolectric.util.ReflectionHelpers @Suppress("DEPRECATION") @RunWith(RobolectricTestRunner::class) @DoNotInstrument -@Config(sdk = [Config.ALL_SDKS]) +@Config(sdk = [Config.ALL_SDKS], shadows = [TestShadowWindowManager::class]) class SupportedSurfaceCombinationTest { private val streamUseCaseOption: androidx.camera.core.impl.Config.Option = androidx.camera.core.impl.Config.Option.create( diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DisplayInfoManagerTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DisplayInfoManagerTest.kt index 5f675bef8e5ca..47030f4ca136c 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DisplayInfoManagerTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/DisplayInfoManagerTest.kt @@ -17,13 +17,14 @@ package androidx.camera.camera2.impl import android.content.Context -import android.graphics.Point import android.hardware.display.DisplayManager import android.os.Build import android.util.Size import android.view.Display import android.view.WindowManager import androidx.camera.camera2.adapter.RobolectricCameraPipeTestRunner +import androidx.camera.camera2.compat.DisplayCompat +import androidx.camera.camera2.testing.TestShadowWindowManager import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat import org.junit.After @@ -39,11 +40,11 @@ import org.robolectric.shadows.ShadowDisplayManager import org.robolectric.shadows.ShadowDisplayManager.removeDisplay import org.robolectric.util.ReflectionHelpers -@Suppress("DEPRECATION") // getRealSize @RunWith(RobolectricCameraPipeTestRunner::class) @DoNotInstrument -@Config(sdk = [Config.ALL_SDKS]) +@Config(sdk = [Config.ALL_SDKS], shadows = [TestShadowWindowManager::class]) class DisplayInfoManagerTest { + private val context = ApplicationProvider.getApplicationContext() private val displayInfoManager = DisplayInfoManager.run { // DisplayInfoManager is used in multiple classes which may be initiated in many other @@ -51,7 +52,7 @@ class DisplayInfoManagerTest { // releaseInstance once before getInstance too so that the first test in this class can // also start with a clean slate. releaseInstance() - getInstance(ApplicationProvider.getApplicationContext()) + getInstance(context) } private fun addDisplay(width: Int, height: Int, state: Int = Display.STATE_ON): Int { @@ -59,10 +60,7 @@ class DisplayInfoManagerTest { val displayId = ShadowDisplayManager.addDisplay(displayStr) if (state != Display.STATE_ON) { - val displayManager = - (ApplicationProvider.getApplicationContext() as Context).getSystemService( - Context.DISPLAY_SERVICE - ) as DisplayManager + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager (Shadow.extract(displayManager.getDisplay(displayId)) as ShadowDisplay).setState(state) } @@ -77,16 +75,11 @@ class DisplayInfoManagerTest { @Test fun defaultDisplayIsDeviceDisplay_whenOneDisplay() { // Arrange - val displayManager = - (ApplicationProvider.getApplicationContext() as Context).getSystemService( - Context.DISPLAY_SERVICE - ) as DisplayManager - val currentDisplaySize = Point() - displayManager.displays[0].getRealSize(currentDisplaySize) + val displayManager = context.getSystemService(Context.DISPLAY_SERVICE) as DisplayManager + val currentDisplaySize = DisplayCompat.getDisplaySize(context, displayManager.displays[0]) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert assertEquals(currentDisplaySize, size) @@ -99,11 +92,10 @@ class DisplayInfoManagerTest { addDisplay(480, 640) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert - assertEquals(Point(2000, 3000), size) + assertEquals(Size(2000, 3000), size) } @Test @@ -114,11 +106,10 @@ class DisplayInfoManagerTest { removeDisplay(id) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert - assertEquals(Point(480, 640), size) + assertEquals(Size(480, 640), size) } @Test @@ -130,11 +121,10 @@ class DisplayInfoManagerTest { displayInfoManager.getMaxSizeDisplay() addDisplay(2000, 3000) - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert - assertEquals(Point(2000, 3000), size) + assertEquals(Size(2000, 3000), size) } @Test @@ -146,11 +136,10 @@ class DisplayInfoManagerTest { addDisplay(200, 300, Display.STATE_OFF) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert - assertEquals(Point(480, 640), size) + assertEquals(Size(480, 640), size) } @Test @@ -162,11 +151,10 @@ class DisplayInfoManagerTest { addDisplay(200, 300, Display.STATE_OFF) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert - assertEquals(Point(480, 640), size) + assertEquals(Size(480, 640), size) } @Test @@ -178,11 +166,10 @@ class DisplayInfoManagerTest { removeDisplay(0) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + val size = DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) // Assert - assertEquals(Point(2000, 3000), size) + assertEquals(Size(2000, 3000), size) } @Test(expected = IllegalStateException::class) @@ -191,8 +178,7 @@ class DisplayInfoManagerTest { removeDisplay(0) // Act - val size = Point() - displayInfoManager.getMaxSizeDisplay().getRealSize(size) + DisplayCompat.getDisplaySize(context, displayInfoManager.getMaxSizeDisplay()) } @Test @@ -227,28 +213,25 @@ class DisplayInfoManagerTest { assertEquals(Size(1920, 1080), displayInfoManager.getPreviewSize()) } + @Suppress("DEPRECATION") // WindowManager.defaultDisplay @Test fun canReturnFallbackPreviewSize640x480_displaySmallerThan320x240() { // Arrange - val windowManager = - ApplicationProvider.getApplicationContext() - .getSystemService(Context.WINDOW_SERVICE) as WindowManager + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager Shadows.shadowOf(windowManager.defaultDisplay).setRealWidth(16) Shadows.shadowOf(windowManager.defaultDisplay).setRealHeight(16) // Act & Assert DisplayInfoManager.releaseInstance() - val displayInfoManager = - DisplayInfoManager.getInstance(ApplicationProvider.getApplicationContext()) + val displayInfoManager = DisplayInfoManager.getInstance(context) assertThat(displayInfoManager.getPreviewSize()).isEqualTo(Size(640, 480)) } + @Suppress("DEPRECATION") // WindowManager.defaultDisplay @Test fun canReturnCorrectPreviewSize_fromDisplaySizeCorrector() { // Arrange - val windowManager = - ApplicationProvider.getApplicationContext() - .getSystemService(Context.WINDOW_SERVICE) as WindowManager + val windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager Shadows.shadowOf(windowManager.defaultDisplay).setRealWidth(16) Shadows.shadowOf(windowManager.defaultDisplay).setRealHeight(16) @@ -256,8 +239,7 @@ class DisplayInfoManagerTest { // Act & Assert DisplayInfoManager.releaseInstance() - val displayInfoManager = - DisplayInfoManager.getInstance(ApplicationProvider.getApplicationContext()) + val displayInfoManager = DisplayInfoManager.getInstance(context) assertThat(displayInfoManager.getPreviewSize()).isEqualTo(Size(1600, 720)) } } diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/MeteringRepeatingTest.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/MeteringRepeatingTest.kt index eeaf7f4b7440c..44cdde333e45f 100644 --- a/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/MeteringRepeatingTest.kt +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/impl/MeteringRepeatingTest.kt @@ -25,6 +25,7 @@ import androidx.camera.camera2.adapter.RobolectricCameraPipeTestRunner import androidx.camera.camera2.pipe.testing.FakeCameraMetadata import androidx.camera.camera2.pipe.testing.HighEndDeviceTemplate import androidx.camera.camera2.testing.FakeCameraProperties +import androidx.camera.camera2.testing.TestShadowWindowManager import androidx.camera.core.impl.StreamSpec import androidx.test.core.app.ApplicationProvider import com.google.common.truth.Truth.assertThat @@ -41,7 +42,7 @@ import org.robolectric.util.ReflectionHelpers @RunWith(RobolectricCameraPipeTestRunner::class) @DoNotInstrument -@Config(sdk = [Config.ALL_SDKS]) +@Config(sdk = [Config.ALL_SDKS], shadows = [TestShadowWindowManager::class]) class MeteringRepeatingTest { companion object { val dummyZeroSizeStreamSpec = StreamSpec.builder(Size(0, 0)).build() diff --git a/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/TestShadowWindowManager.kt b/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/TestShadowWindowManager.kt new file mode 100644 index 0000000000000..8f50c5b89ccff --- /dev/null +++ b/camera/camera-camera2/src/test/java/androidx/camera/camera2/testing/TestShadowWindowManager.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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.camera.camera2.testing + +import android.content.Context +import android.graphics.Point +import android.graphics.Rect +import android.os.Build +import android.view.WindowInsets +import android.view.WindowMetrics +import androidx.annotation.RequiresApi +import org.robolectric.annotation.Implementation +import org.robolectric.annotation.Implements +import org.robolectric.annotation.RealObject +import org.robolectric.util.ReflectionHelpers + +/** + * Custom shadow for `WindowManagerImpl` to correctly support multiple displays under Robolectric. + * + * Intercepts calls to [getMaximumWindowMetrics] on API 30+ and calculates bounds using the actual + * display associated with the context, rather than defaulting to display 0. + */ +@Suppress("DEPRECATION") +@RequiresApi(Build.VERSION_CODES.R) +@Implements(className = "android.view.WindowManagerImpl", minSdk = Build.VERSION_CODES.R) +public class TestShadowWindowManager { + @RealObject private lateinit var windowManagerImpl: Any + + @Implementation + protected fun getMaximumWindowMetrics(): WindowMetrics { + val context = ReflectionHelpers.getField(windowManagerImpl, "mContext") + val display = context.display + val displaySize = Point() + display.getRealSize(displaySize) + val rect = Rect(0, 0, displaySize.x, displaySize.y) + val windowInsets = WindowInsets.Builder().build() + return WindowMetrics(rect, windowInsets) + } +} diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/SectionedItemTemplateDemoScreen.kt b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/SectionedItemTemplateDemoScreen.kt index 2f6d98a9796dc..724d247e72458 100644 --- a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/SectionedItemTemplateDemoScreen.kt +++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/SectionedItemTemplateDemoScreen.kt @@ -29,6 +29,7 @@ import androidx.car.app.model.SectionedItemTemplate import androidx.car.app.model.Template import androidx.car.app.sample.showcase.common.R import androidx.car.app.sample.showcase.common.screens.templatelayouts.sectioneditemtemplates.AlphaJumpDemoScreen +import androidx.car.app.sample.showcase.common.screens.templatelayouts.sectioneditemtemplates.BannerDemoScreen import androidx.car.app.sample.showcase.common.screens.templatelayouts.sectioneditemtemplates.ChipDemoScreen import androidx.car.app.sample.showcase.common.screens.templatelayouts.sectioneditemtemplates.CondensedItemDemoScreen import androidx.car.app.sample.showcase.common.screens.templatelayouts.sectioneditemtemplates.EndImageAndActionsDemo @@ -98,6 +99,12 @@ class SectionedItemTemplateDemoScreen(carContext: CarContext) : Screen(carContex R.string.spotlight_section_demo_title, ) ) + addItem( + buildRowForTemplate( + BannerDemoScreen(carContext), + R.string.banner_demo_title, + ) + ) } } .build() diff --git a/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/sectioneditemtemplates/BannerDemoScreen.kt b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/sectioneditemtemplates/BannerDemoScreen.kt new file mode 100644 index 0000000000000..511c4dbdb4b2b --- /dev/null +++ b/car/app/app-samples/showcase/common/src/main/java/androidx/car/app/sample/showcase/common/screens/templatelayouts/sectioneditemtemplates/BannerDemoScreen.kt @@ -0,0 +1,328 @@ +/* + * Copyright 2026 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.car.app.sample.showcase.common.screens.templatelayouts.sectioneditemtemplates + +import android.graphics.Color +import androidx.annotation.OptIn +import androidx.car.app.CarContext +import androidx.car.app.CarToast +import androidx.car.app.Screen +import androidx.car.app.annotations.ExperimentalCarApi +import androidx.car.app.annotations.RequiresCarApi +import androidx.car.app.model.Action +import androidx.car.app.model.Background +import androidx.car.app.model.Banner +import androidx.car.app.model.BannerSection +import androidx.car.app.model.BannerStyle +import androidx.car.app.model.CarColor +import androidx.car.app.model.CarIcon +import androidx.car.app.model.Header +import androidx.car.app.model.SectionHeader +import androidx.car.app.model.SectionedItemTemplate +import androidx.car.app.model.Shape +import androidx.car.app.model.Template +import androidx.car.app.sample.showcase.common.R +import androidx.core.graphics.drawable.IconCompat + +/** A screen demonstrating [Banner] in a [SectionedItemTemplate]. */ +@RequiresCarApi(9) +@OptIn(ExperimentalCarApi::class) +class BannerDemoScreen(carContext: CarContext) : Screen(carContext) { + + private fun showToast(text: String) { + CarToast.makeText(carContext, text, CarToast.LENGTH_SHORT).show() + } + + private fun createSection(title: String?, banner: Banner): BannerSection { + return BannerSection.Builder() + .apply { + if (title != null) { + setSectionHeader(SectionHeader.Builder(title).build()) + } + } + .addItem(banner) + .build() + } + + override fun onGetTemplate(): Template { + val mediaIcon = + CarIcon.Builder( + IconCompat.createWithResource(carContext, R.drawable.test_android_media) + ) + .build() + val playIcon = CarIcon.MEDIA_PLAYBACK + val settingsIcon = + CarIcon.Builder( + IconCompat.createWithResource( + carContext, + android.R.drawable.ic_menu_preferences, + ) + ) + .build() + + val grayColor = Color.rgb(154, 160, 166) + val grayBackground = + Background.Builder().setColor(CarColor.createCustom(grayColor, grayColor)).build() + + // 0. Simple Banner Section + val simpleBanner = Banner.Builder().setTitle("Title Only").build() + val simpleSection = createSection("Simple Banners", simpleBanner) + + // 1. Standard Banner Section + val standardBanner = + Banner.Builder() + .setTitle("Standard Banner") + .setSubtitle("This is a subtitle for the banner.") + .setOnClickListener { showToast("Clicked Standard Banner") } + .build() + val standardSection = createSection(null, standardBanner) + + // 2. Banner with Leading Image + val leadingImageBanner = + Banner.Builder() + .setTitle("Leading Image") + .setSubtitle("This is a subtitle for the banner.") + .setLeadingImage(mediaIcon) + .build() + val leadingImageSection = createSection("Leading Banners", leadingImageBanner) + + // 3. Banner with Leading Icon + val leadingIconBanner = + Banner.Builder() + .setTitle("Leading Icon") + .setSubtitle("This is a subtitle for the banner.") + .setLeadingIcon(playIcon) + .build() + val leadingIconSection = createSection(null, leadingIconBanner) + + // 4. Banner with Trailing Icon + val trailingIconBanner = + Banner.Builder() + .setTitle("Trailing Icon") + .setSubtitle("This is a subtitle for the banner.") + .addTrailingIcon(playIcon) + .build() + val trailingIconSection = createSection("Trailing Banners", trailingIconBanner) + + // 5. Banner with Trailing Image + val trailingImageBanner = + Banner.Builder() + .setTitle("Trailing Image") + .setSubtitle("This is a subtitle for the banner.") + .addTrailingImage(mediaIcon) + .build() + val trailingImageSection = createSection(null, trailingImageBanner) + + // 6. Banner with Trailing Action (Settings Icon) + val trailingActionBanner = + Banner.Builder() + .setTitle("Trailing Icon Button") + .setSubtitle("This is a subtitle for the banner.") + .addTrailingAction( + Action.Builder() + .setTitle("Settings") + .setIcon(settingsIcon) + .setOnClickListener { showToast("Clicked Settings Action") } + .build() + ) + .build() + val trailingActionSection = createSection(null, trailingActionBanner) + + // 7. Banner with Trailing Button + val trailingButtonBanner = + Banner.Builder() + .setTitle("Trailing Button") + .addTrailingAction( + Action.Builder() + .setTitle("Action") + .setOnClickListener { showToast("Clicked Trailing Button") } + .build() + ) + .addTrailingIcon(playIcon) + .build() + val trailingButtonSection = createSection(null, trailingButtonBanner) + + // 8. Banner with 1 Below Action + val belowActionBanner = + Banner.Builder() + .setTitle("Below Action") + .setSubtitle("Only 1 action below.") + .addBelowAction( + Action.Builder() + .setTitle("Action") + .setOnClickListener { showToast("Clicked Below Action") } + .build() + ) + .build() + val belowActionSection = createSection("Below Actions", belowActionBanner) + + // 9. Banner with Below Actions (2 actions and subtitle) + val belowActionsBanner = + Banner.Builder() + .setTitle("Below Actions") + .setSubtitle("Actions appear below the text.") + .addBelowAction( + Action.Builder() + .setTitle("Primary") + .setOnClickListener { showToast("Clicked Primary Action") } + .build() + ) + .addBelowAction( + Action.Builder() + .setTitle("Secondary") + .setOnClickListener { showToast("Clicked Secondary Action") } + .build() + ) + .build() + val belowActionsSection = createSection(null, belowActionsBanner) + + // 10. Rich Banner Section + val richDemoBanner = + Banner.Builder() + .setTitle("Rich Banner") + .setSubtitle("All banner elements combined.") + .setLeadingImage(mediaIcon) + .addTrailingIcon(playIcon) + .addBelowAction( + Action.Builder() + .setTitle("Primary") + .setOnClickListener { showToast("Clicked Primary Action") } + .build() + ) + .addBelowAction( + Action.Builder() + .setTitle("Secondary") + .setOnClickListener { showToast("Clicked Secondary Action") } + .build() + ) + .addBelowAction( + Action.Builder() + .setIcon(settingsIcon) + .setOnClickListener { showToast("Clicked Settings Action") } + .build() + ) + .build() + val richDemoSection = createSection("Rich Banner", richDemoBanner) + + // 11. Different Shapes Section + val shapes = + listOf( + "None" to Shape.NONE, + "Small" to Shape.CORNER_SMALL, + "Medium" to Shape.CORNER_MEDIUM, + "Large" to Shape.CORNER_LARGE, + "Extra Large" to Shape.CORNER_EXTRA_LARGE, + "Full" to Shape.CORNER_FULL, + ) + + val simpleShapesSection = mutableListOf() + shapes.forEachIndexed { index, (shapeName, shape) -> + val banner = + Banner.Builder() + .setTitle("Simple : $shapeName") + .setStyle( + BannerStyle.Builder().setBackground(grayBackground).setShape(shape).build() + ) + .build() + val headerTitle = if (index == 0) "Simple Banner Shapes" else null + simpleShapesSection.add(createSection(headerTitle, banner)) + } + + val richShapesSection = mutableListOf() + shapes.forEachIndexed { index, (shapeName, shape) -> + val banner = + Banner.Builder() + .setTitle("Rich : $shapeName") + .setLeadingImage(mediaIcon) + .addTrailingIcon(playIcon) + .setStyle( + BannerStyle.Builder().setBackground(grayBackground).setShape(shape).build() + ) + .addBelowAction( + Action.Builder() + .setTitle("Primary") + .setOnClickListener { showToast("Clicked Primary Action") } + .build() + ) + .addBelowAction( + Action.Builder() + .setTitle("Secondary") + .setOnClickListener { showToast("Clicked Secondary Action") } + .build() + ) + .build() + val headerTitle = if (index == 0) "Rich Banner Shapes" else null + richShapesSection.add(createSection(headerTitle, banner)) + } + + // 12. Different Background Colors Section + // TODO: b/510071734 - Revert hex custom colors back to standard built-in CarColor constants + // (CarColor.RED, BLUE, etc.) once host BannerLayout supports + // ColorBackgroundUiModel.BuiltIn. + val colorPairs = + listOf( + "Red" to Color.parseColor("#E53935"), + "Yellow" to Color.parseColor("#FDD835"), + "Blue" to Color.parseColor("#1E88E5"), + "Green" to Color.parseColor("#43A047"), + "Purple" to Color.parseColor("#8E24AA"), + "Orange" to Color.parseColor("#FB8C00"), + "Teal" to Color.parseColor("#00897B"), + ) + val colorsSection = mutableListOf() + colorPairs.forEachIndexed { index, (colorName, colorInt) -> + val customColor = CarColor.createCustom(colorInt, colorInt) + val banner = + Banner.Builder() + .setTitle("Color: $colorName") + .addTrailingIcon(playIcon) + .setStyle( + BannerStyle.Builder() + .setBackground(Background.Builder().setColor(customColor).build()) + .setShape(Shape.CORNER_MEDIUM) + .build() + ) + .setOnClickListener { showToast("Clicked Color: $colorName") } + .build() + val headerTitle = if (index == 0) "Background Colors" else null + colorsSection.add(createSection(headerTitle, banner)) + } + + return SectionedItemTemplate.Builder() + .addSection(simpleSection) + .addSection(standardSection) + .addSection(leadingImageSection) + .addSection(leadingIconSection) + .addSection(trailingIconSection) + .addSection(trailingImageSection) + .addSection(trailingActionSection) + .addSection(trailingButtonSection) + .addSection(belowActionSection) + .addSection(belowActionsSection) + .addSection(richDemoSection) + .apply { simpleShapesSection.forEach { addSection(it) } } + .apply { richShapesSection.forEach { addSection(it) } } + .apply { colorsSection.forEach { addSection(it) } } + .setHeader( + Header.Builder() + .setTitle(carContext.getString(R.string.banner_demo_title)) + .setStartHeaderAction(Action.BACK) + .build() + ) + .build() + } +} diff --git a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml index cca5e5768ceda..3047a50be61fa 100644 --- a/car/app/app-samples/showcase/common/src/main/res/values/strings.xml +++ b/car/app/app-samples/showcase/common/src/main/res/values/strings.xml @@ -526,4 +526,5 @@ Map With Content Demos Map With Message Template Demo Map With Grid Template Demo + Banner Demo diff --git a/compose/ui/ui/api/current.txt b/compose/ui/ui/api/current.txt index 0318197ed298d..b7d58453a7c4b 100644 --- a/compose/ui/ui/api/current.txt +++ b/compose/ui/ui/api/current.txt @@ -4820,6 +4820,7 @@ package androidx.compose.ui.semantics { method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getFocused(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHeading(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHideFromAccessibility(); + method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHintText(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHorizontalScrollAxisRange(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getImeAction(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey> getIndexForKey(); @@ -4865,6 +4866,7 @@ package androidx.compose.ui.semantics { property public androidx.compose.ui.semantics.SemanticsPropertyKey Focused; property public androidx.compose.ui.semantics.SemanticsPropertyKey Heading; property public androidx.compose.ui.semantics.SemanticsPropertyKey HideFromAccessibility; + property public androidx.compose.ui.semantics.SemanticsPropertyKey HintText; property public androidx.compose.ui.semantics.SemanticsPropertyKey HorizontalScrollAxisRange; property public androidx.compose.ui.semantics.SemanticsPropertyKey ImeAction; property public androidx.compose.ui.semantics.SemanticsPropertyKey> IndexForKey; @@ -4936,6 +4938,7 @@ package androidx.compose.ui.semantics { method @InaccessibleFromKotlin public static androidx.compose.ui.text.AnnotatedString getEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static androidx.compose.ui.autofill.FillableData getFillableData(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static boolean getFocused(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method @InaccessibleFromKotlin public static String getHintText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static androidx.compose.ui.semantics.ScrollAxisRange getHorizontalScrollAxisRange(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @BytecodeOnly @Deprecated public static int getImeAction(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static androidx.compose.ui.text.AnnotatedString getInputText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); @@ -5015,6 +5018,7 @@ package androidx.compose.ui.semantics { method @InaccessibleFromKotlin public static void setEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.text.AnnotatedString); method @InaccessibleFromKotlin public static void setFillableData(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.autofill.FillableData); method @InaccessibleFromKotlin public static void setFocused(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); + method @InaccessibleFromKotlin public static void setHintText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, String); method @InaccessibleFromKotlin public static void setHorizontalScrollAxisRange(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.ScrollAxisRange); method @BytecodeOnly @Deprecated public static void setImeAction-4L7nppU(androidx.compose.ui.semantics.SemanticsPropertyReceiver, int); method @InaccessibleFromKotlin public static void setInputText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.text.AnnotatedString); @@ -5058,6 +5062,7 @@ package androidx.compose.ui.semantics { property public static androidx.compose.ui.text.AnnotatedString androidx.compose.ui.semantics.SemanticsPropertyReceiver.editableText; property public static androidx.compose.ui.autofill.FillableData androidx.compose.ui.semantics.SemanticsPropertyReceiver.fillableData; property public static boolean androidx.compose.ui.semantics.SemanticsPropertyReceiver.focused; + property public static String androidx.compose.ui.semantics.SemanticsPropertyReceiver.hintText; property public static androidx.compose.ui.semantics.ScrollAxisRange androidx.compose.ui.semantics.SemanticsPropertyReceiver.horizontalScrollAxisRange; property @Deprecated public static androidx.compose.ui.text.input.ImeAction androidx.compose.ui.semantics.SemanticsPropertyReceiver.imeAction; property public static androidx.compose.ui.text.AnnotatedString androidx.compose.ui.semantics.SemanticsPropertyReceiver.inputText; diff --git a/compose/ui/ui/api/restricted_current.txt b/compose/ui/ui/api/restricted_current.txt index 551c3b29e7f04..25443247483ed 100644 --- a/compose/ui/ui/api/restricted_current.txt +++ b/compose/ui/ui/api/restricted_current.txt @@ -4891,6 +4891,7 @@ package androidx.compose.ui.semantics { method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getFocused(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHeading(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHideFromAccessibility(); + method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHintText(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getHorizontalScrollAxisRange(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey getImeAction(); method @InaccessibleFromKotlin public androidx.compose.ui.semantics.SemanticsPropertyKey> getIndexForKey(); @@ -4936,6 +4937,7 @@ package androidx.compose.ui.semantics { property public androidx.compose.ui.semantics.SemanticsPropertyKey Focused; property public androidx.compose.ui.semantics.SemanticsPropertyKey Heading; property public androidx.compose.ui.semantics.SemanticsPropertyKey HideFromAccessibility; + property public androidx.compose.ui.semantics.SemanticsPropertyKey HintText; property public androidx.compose.ui.semantics.SemanticsPropertyKey HorizontalScrollAxisRange; property public androidx.compose.ui.semantics.SemanticsPropertyKey ImeAction; property public androidx.compose.ui.semantics.SemanticsPropertyKey> IndexForKey; @@ -5007,6 +5009,7 @@ package androidx.compose.ui.semantics { method @InaccessibleFromKotlin public static androidx.compose.ui.text.AnnotatedString getEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static androidx.compose.ui.autofill.FillableData getFillableData(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static boolean getFocused(androidx.compose.ui.semantics.SemanticsPropertyReceiver); + method @InaccessibleFromKotlin public static String getHintText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static androidx.compose.ui.semantics.ScrollAxisRange getHorizontalScrollAxisRange(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @BytecodeOnly @Deprecated public static int getImeAction(androidx.compose.ui.semantics.SemanticsPropertyReceiver); method @InaccessibleFromKotlin public static androidx.compose.ui.text.AnnotatedString getInputText(androidx.compose.ui.semantics.SemanticsPropertyReceiver); @@ -5086,6 +5089,7 @@ package androidx.compose.ui.semantics { method @InaccessibleFromKotlin public static void setEditableText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.text.AnnotatedString); method @InaccessibleFromKotlin public static void setFillableData(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.autofill.FillableData); method @InaccessibleFromKotlin public static void setFocused(androidx.compose.ui.semantics.SemanticsPropertyReceiver, boolean); + method @InaccessibleFromKotlin public static void setHintText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, String); method @InaccessibleFromKotlin public static void setHorizontalScrollAxisRange(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.semantics.ScrollAxisRange); method @BytecodeOnly @Deprecated public static void setImeAction-4L7nppU(androidx.compose.ui.semantics.SemanticsPropertyReceiver, int); method @InaccessibleFromKotlin public static void setInputText(androidx.compose.ui.semantics.SemanticsPropertyReceiver, androidx.compose.ui.text.AnnotatedString); @@ -5129,6 +5133,7 @@ package androidx.compose.ui.semantics { property public static androidx.compose.ui.text.AnnotatedString androidx.compose.ui.semantics.SemanticsPropertyReceiver.editableText; property public static androidx.compose.ui.autofill.FillableData androidx.compose.ui.semantics.SemanticsPropertyReceiver.fillableData; property public static boolean androidx.compose.ui.semantics.SemanticsPropertyReceiver.focused; + property public static String androidx.compose.ui.semantics.SemanticsPropertyReceiver.hintText; property public static androidx.compose.ui.semantics.ScrollAxisRange androidx.compose.ui.semantics.SemanticsPropertyReceiver.horizontalScrollAxisRange; property @Deprecated public static androidx.compose.ui.text.input.ImeAction androidx.compose.ui.semantics.SemanticsPropertyReceiver.imeAction; property public static androidx.compose.ui.text.AnnotatedString androidx.compose.ui.semantics.SemanticsPropertyReceiver.inputText; diff --git a/compose/ui/ui/bcv/native/current.txt b/compose/ui/ui/bcv/native/current.txt index 628d8e1c4df9e..a464938daf079 100644 --- a/compose/ui/ui/bcv/native/current.txt +++ b/compose/ui/ui/bcv/native/current.txt @@ -3583,6 +3583,8 @@ final object androidx.compose.ui.semantics/SemanticsProperties { // androidx.com final fun (): androidx.compose.ui.semantics/SemanticsPropertyKey // androidx.compose.ui.semantics/SemanticsProperties.Heading.|(){}[0] final val HideFromAccessibility // androidx.compose.ui.semantics/SemanticsProperties.HideFromAccessibility|{}HideFromAccessibility[0] final fun (): androidx.compose.ui.semantics/SemanticsPropertyKey // androidx.compose.ui.semantics/SemanticsProperties.HideFromAccessibility.|(){}[0] + final val HintText // androidx.compose.ui.semantics/SemanticsProperties.HintText|{}HintText[0] + final fun (): androidx.compose.ui.semantics/SemanticsPropertyKey // androidx.compose.ui.semantics/SemanticsProperties.HintText.|(){}[0] final val HorizontalScrollAxisRange // androidx.compose.ui.semantics/SemanticsProperties.HorizontalScrollAxisRange|{}HorizontalScrollAxisRange[0] final fun (): androidx.compose.ui.semantics/SemanticsPropertyKey // androidx.compose.ui.semantics/SemanticsProperties.HorizontalScrollAxisRange.|(){}[0] final val ImeAction // androidx.compose.ui.semantics/SemanticsProperties.ImeAction|{}ImeAction[0] @@ -3969,6 +3971,9 @@ final var androidx.compose.ui.semantics/fillableData // androidx.compose.ui.sema final var androidx.compose.ui.semantics/focused // androidx.compose.ui.semantics/focused|@androidx.compose.ui.semantics.SemanticsPropertyReceiver{}focused[0] final fun (androidx.compose.ui.semantics/SemanticsPropertyReceiver).(): kotlin/Boolean // androidx.compose.ui.semantics/focused.|@androidx.compose.ui.semantics.SemanticsPropertyReceiver(){}[0] final fun (androidx.compose.ui.semantics/SemanticsPropertyReceiver).(kotlin/Boolean) // androidx.compose.ui.semantics/focused.|@androidx.compose.ui.semantics.SemanticsPropertyReceiver(kotlin.Boolean){}[0] +final var androidx.compose.ui.semantics/hintText // androidx.compose.ui.semantics/hintText|@androidx.compose.ui.semantics.SemanticsPropertyReceiver{}hintText[0] + final fun (androidx.compose.ui.semantics/SemanticsPropertyReceiver).(): kotlin/String // androidx.compose.ui.semantics/hintText.|@androidx.compose.ui.semantics.SemanticsPropertyReceiver(){}[0] + final fun (androidx.compose.ui.semantics/SemanticsPropertyReceiver).(kotlin/String) // androidx.compose.ui.semantics/hintText.|@androidx.compose.ui.semantics.SemanticsPropertyReceiver(kotlin.String){}[0] final var androidx.compose.ui.semantics/horizontalScrollAxisRange // androidx.compose.ui.semantics/horizontalScrollAxisRange|@androidx.compose.ui.semantics.SemanticsPropertyReceiver{}horizontalScrollAxisRange[0] final fun (androidx.compose.ui.semantics/SemanticsPropertyReceiver).(): androidx.compose.ui.semantics/ScrollAxisRange // androidx.compose.ui.semantics/horizontalScrollAxisRange.|@androidx.compose.ui.semantics.SemanticsPropertyReceiver(){}[0] final fun (androidx.compose.ui.semantics/SemanticsPropertyReceiver).(androidx.compose.ui.semantics/ScrollAxisRange) // androidx.compose.ui.semantics/horizontalScrollAxisRange.|@androidx.compose.ui.semantics.SemanticsPropertyReceiver(androidx.compose.ui.semantics.ScrollAxisRange){}[0] diff --git a/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/SemanticsPropertiesSamples.kt b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/SemanticsPropertiesSamples.kt new file mode 100644 index 0000000000000..84bcb65eba490 --- /dev/null +++ b/compose/ui/ui/samples/src/main/java/androidx/compose/ui/samples/SemanticsPropertiesSamples.kt @@ -0,0 +1,53 @@ +/* + * Copyright 2026 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.compose.ui.samples + +import androidx.annotation.Sampled +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.hintText +import androidx.compose.ui.semantics.semantics + +@Sampled +@Composable +fun HintTextSample() { + val label = "Label" // In an application, this hint would typically be a localized String. + var text by remember { mutableStateOf("") } + BasicTextField( + value = text, + onValueChange = { text = it }, + modifier = Modifier.semantics { hintText = label }, + decorationBox = { innerTextField -> + Box { + innerTextField() + if (text.isEmpty()) { + // Hide this visual placeholder from talkback to prevent duplicated + // announcements, as the parent BasicTextField already provides the hintText. + Text(text = label, modifier = Modifier.semantics { hideFromAccessibility() }) + } + } + }, + ) +} diff --git a/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt b/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt index 146da55788c16..3437de9e49e24 100644 --- a/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt +++ b/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/AndroidComposeViewAccessibilityDelegateCompatTest.kt @@ -80,6 +80,7 @@ import androidx.compose.ui.semantics.ProgressBarRangeInfo import androidx.compose.ui.semantics.Role import androidx.compose.ui.semantics.RoleFakeNodeIdOffset import androidx.compose.ui.semantics.ScrollAxisRange +import androidx.compose.ui.semantics.SemanticsProperties import androidx.compose.ui.semantics.SemanticsPropertyKey import androidx.compose.ui.semantics.SemanticsPropertyReceiver import androidx.compose.ui.semantics.accessibilityClassName @@ -97,6 +98,7 @@ import androidx.compose.ui.semantics.focused import androidx.compose.ui.semantics.getTextLayoutResult import androidx.compose.ui.semantics.heading import androidx.compose.ui.semantics.hideFromAccessibility +import androidx.compose.ui.semantics.hintText import androidx.compose.ui.semantics.horizontalScrollAxisRange import androidx.compose.ui.semantics.inputTextSuggestionState import androidx.compose.ui.semantics.isEditable @@ -120,6 +122,7 @@ import androidx.compose.ui.semantics.testTagsAsResourceId import androidx.compose.ui.semantics.text import androidx.compose.ui.semantics.textCompositionRange import androidx.compose.ui.semantics.textSelectionRange +import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.TestActivity import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.v2.createAndroidComposeRule @@ -2946,6 +2949,93 @@ class AndroidComposeViewAccessibilityDelegateCompatTest { } } + @Test + fun hintText_isReadFromSemanticsConfig() { + rule.setContent { + Box( + modifier = + Modifier.semantics { + hintText = "text" + setText { true } + } + ) + } + + rule + .onNode(SemanticsMatcher.expectValue(SemanticsProperties.HintText, "text")) + .assertExists() + } + + @Test + fun hintText_mapsToAccessibilityNodeInfo_showingHint() { + val hint = "hintText" + rule.setContentWithAccessibilityEnabled { + BasicTextField( + rememberTextFieldState(""), + modifier = + Modifier.testTag(tag).semantics { + hintText = hint + isEditable = true + }, + ) + } + val virtualViewId = rule.onNodeWithTag(tag).semanticsId() + + // Act. + val info = rule.runOnIdle { androidComposeView.createAccessibilityNodeInfo(virtualViewId) } + + // Assert. + rule.runOnIdle { assertThat(info.hintText).isEqualTo(hint) } + } + + @Test + fun hintText_mapsToAccessibilityNodeInfo_notShowingHint() { + val hint = "hintText" + rule.setContentWithAccessibilityEnabled { + BasicTextField( + rememberTextFieldState("text"), + modifier = + Modifier.testTag(tag).semantics { + hintText = hint + isEditable = true + }, + ) + } + val virtualViewId = rule.onNodeWithTag(tag).semanticsId() + + // Act. + val info = rule.runOnIdle { androidComposeView.createAccessibilityNodeInfo(virtualViewId) } + + // Assert. + rule.runOnIdle { assertThat(info.hintText).isEqualTo(hint) } + } + + @Test + fun hintText_emptyTextField_hasNoStateDescription_andIsScreenReaderFocusable() { + val hint = "hintText" + rule.setContentWithAccessibilityEnabled { + BasicTextField( + rememberTextFieldState(""), + modifier = + Modifier.testTag(tag).semantics { + hintText = hint + isEditable = true + }, + ) + } + val virtualViewId = rule.onNodeWithTag(tag).semanticsId() + + // Act. + val info = rule.runOnIdle { androidComposeView.createAccessibilityNodeInfo(virtualViewId) } + + // Assert. + rule.runOnIdle { + assertThat(info.stateDescription).isNull() + + assertThat(info.isScreenReaderFocusable).isTrue() + } + } + private fun Int.toDp(): Dp = with(rule.density) { this@toDp.toDp() } private fun ComposeContentTestRule.setContentWithAccessibilityEnabled( diff --git a/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt b/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt index 83412c716e6e9..7463e20e19a68 100644 --- a/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt +++ b/compose/ui/ui/src/androidDeviceTest/kotlin/androidx/compose/ui/semantics/SemanticsTests.kt @@ -1883,6 +1883,23 @@ class SemanticsTests { } } + @Test + fun hintText_mergePolicy_prefersParentValue() { + val merged = + SemanticsProperties.HintText.merge( + parentValue = "Parent hint", + childValue = "Child hint", + ) + assertThat(merged).isEqualTo("Parent hint") + } + + @Test + fun hintText_mergePolicy_fallsBackToChildWhenNoParent() { + val merged = + SemanticsProperties.HintText.merge(parentValue = null, childValue = "Child hint") + assertThat(merged).isEqualTo("Child hint") + } + private fun SemanticsNode.isTestTag(testTag: String) = this.unmergedConfig.getOrNull(SemanticsProperties.TestTag) == testTag } diff --git a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt index 152d4dff5b542..c661e6f863e5d 100644 --- a/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt +++ b/compose/ui/ui/src/androidMain/kotlin/androidx/compose/ui/platform/AndroidComposeViewAccessibilityDelegateCompat.android.kt @@ -782,6 +782,11 @@ internal class AndroidComposeViewAccessibilityDelegateCompat(val view: AndroidCo } } + val hintText = semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.HintText) + if (hintText != null) { + info.hintText = hintText + } + semanticsNode.unmergedConfig.getOrNull(SemanticsProperties.Heading)?.let { info.isHeading = true } @@ -3638,7 +3643,8 @@ private fun createStateDescriptionForTextField(node: SemanticsNode, resources: R val mergedNodeIsUnspeakable = mergedConfig.getOrNull(SemanticsProperties.ContentDescription).isNullOrEmpty() && mergedConfig.getOrNull(SemanticsProperties.Text).isNullOrEmpty() && - mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty() + mergedConfig.getOrNull(SemanticsProperties.EditableText).isNullOrEmpty() && + mergedConfig.getOrNull(SemanticsProperties.HintText).isNullOrEmpty() return if (mergedNodeIsUnspeakable) resources.getString(R.string.state_empty) else null } diff --git a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt index c6b79218c32c0..b8ee241d5501b 100644 --- a/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt +++ b/compose/ui/ui/src/commonMain/kotlin/androidx/compose/ui/semantics/SemanticsProperties.kt @@ -158,6 +158,9 @@ object SemanticsProperties { }, ) + /** @see SemanticsPropertyReceiver.hintText */ + val HintText = AccessibilityKey(name = "HintText") + /** @see SemanticsPropertyReceiver.horizontalScrollAxisRange */ val HorizontalScrollAxisRange = AccessibilityKey("HorizontalScrollAxisRange") @@ -920,6 +923,14 @@ var SemanticsPropertyReceiver.contentDescription: String set(SemanticsProperties.ContentDescription, listOf(value)) } +/** + * The hint text for an editable text field. This is typically used to provide guidance to the user + * about what to enter in the text field. + * + * @sample androidx.compose.ui.samples.HintTextSample + */ +var SemanticsPropertyReceiver.hintText by SemanticsProperties.HintText + /** * Developer-set state description of the semantics node. *