Introduce StandardCardLayout & WideCardLayout in TV Material
Test: Instrumentation & screenshot tests added
Relnote: "Add TV opinionated material StandardCardLayout and
WideCardLayout"
Change-Id: I33faedd7fcf0792b0378e855803e19f33788628c
diff --git a/tv/tv-material/api/public_plus_experimental_current.txt b/tv/tv-material/api/public_plus_experimental_current.txt
index 44ffb2b..3f5fc27 100644
--- a/tv/tv-material/api/public_plus_experimental_current.txt
+++ b/tv/tv-material/api/public_plus_experimental_current.txt
@@ -92,6 +92,20 @@
method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void WideClassicCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.BoxScope,kotlin.Unit> image, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> subtitle, optional kotlin.jvm.functions.Function0<kotlin.Unit> description, optional androidx.tv.material3.CardShape shape, optional androidx.tv.material3.CardColors colors, optional androidx.tv.material3.CardScale scale, optional androidx.tv.material3.CardBorder border, optional androidx.tv.material3.CardGlow glow, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
}
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class CardLayoutColors {
+ }
+
+ @androidx.tv.material3.ExperimentalTvMaterial3Api public final class CardLayoutDefaults {
+ method @androidx.compose.runtime.Composable public void ImageCard(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional androidx.compose.ui.Modifier modifier, optional androidx.tv.material3.CardShape shape, optional androidx.tv.material3.CardColors colors, optional androidx.tv.material3.CardScale scale, optional androidx.tv.material3.CardBorder border, optional androidx.tv.material3.CardGlow glow, kotlin.jvm.functions.Function0<kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.CardLayoutColors contentColor(optional long contentColor, optional long focusedContentColor, optional long pressedContentColor);
+ field public static final androidx.tv.material3.CardLayoutDefaults INSTANCE;
+ }
+
+ public final class CardLayoutKt {
+ method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void StandardCardLayout(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.Unit> imageCard, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> subtitle, optional kotlin.jvm.functions.Function0<kotlin.Unit> description, optional androidx.tv.material3.CardLayoutColors contentColor, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
+ method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void WideCardLayout(kotlin.jvm.functions.Function1<? super androidx.compose.foundation.interaction.MutableInteractionSource,kotlin.Unit> imageCard, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional kotlin.jvm.functions.Function0<kotlin.Unit> subtitle, optional kotlin.jvm.functions.Function0<kotlin.Unit> description, optional androidx.tv.material3.CardLayoutColors contentColor, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
+ }
+
@androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class CardScale {
}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardLayoutScreenshotTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardLayoutScreenshotTest.kt
new file mode 100644
index 0000000..5c6a7ff
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardLayoutScreenshotTest.kt
@@ -0,0 +1,276 @@
+/*
+ * Copyright 2023 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.tv.material3
+
+import android.os.Build
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.runtime.Composable
+import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+class CardLayoutScreenshotTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(TV_GOLDEN_MATERIAL3)
+
+ private val boxSizeModifier = Modifier.size(220.dp, 180.dp)
+ private val standardCardLayoutSizeModifier = Modifier.size(150.dp, 120.dp)
+ private val wideCardLayoutSizeModifier = Modifier.size(180.dp, 100.dp)
+
+ @Test
+ fun standardCardLayout_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardLayoutWrapperTag),
+ contentAlignment = Alignment.Center
+ ) {
+ StandardCardLayout(
+ modifier = standardCardLayoutSizeModifier,
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { },
+ interactionSource = interactionSource
+ ) {
+ SampleImage(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ )
+ }
+ },
+ title = { Text("Standard Card") }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("standardCardLayout_lightTheme")
+ }
+
+ @Test
+ fun standardCardLayout_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardLayoutWrapperTag),
+ contentAlignment = Alignment.Center
+ ) {
+ StandardCardLayout(
+ modifier = standardCardLayoutSizeModifier,
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { },
+ interactionSource = interactionSource
+ ) {
+ SampleImage(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ )
+ }
+ },
+ title = { Text("Standard Card") }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("standardCardLayout_darkTheme")
+ }
+
+ @Test
+ fun standardCardLayout_focused() {
+ rule.setContent {
+ Box(
+ modifier = boxSizeModifier
+ .testTag(CardLayoutWrapperTag)
+ .semantics(mergeDescendants = true) {},
+ contentAlignment = Alignment.Center
+ ) {
+ StandardCardLayout(
+ modifier = standardCardLayoutSizeModifier,
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { },
+ interactionSource = interactionSource
+ ) {
+ SampleImage(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ )
+ }
+ },
+ title = { Text("Standard Card", Modifier.padding(top = 5.dp)) }
+ )
+ }
+ }
+
+ rule.onNodeWithTag(CardLayoutWrapperTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ assertAgainstGolden("standardCardLayout_focused")
+ }
+
+ @Test
+ fun wideCardLayout_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardLayoutWrapperTag),
+ contentAlignment = Alignment.Center
+ ) {
+ WideCardLayout(
+ modifier = wideCardLayoutSizeModifier,
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { },
+ interactionSource = interactionSource
+ ) {
+ SampleImage(
+ Modifier
+ .fillMaxHeight()
+ .width(90.dp)
+ )
+ }
+ },
+ title = { Text("Wide Card", Modifier.padding(start = 8.dp)) },
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("wideCardLayout_lightTheme")
+ }
+
+ @Test
+ fun wideCardLayout_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardLayoutWrapperTag),
+ contentAlignment = Alignment.Center
+ ) {
+ WideCardLayout(
+ modifier = wideCardLayoutSizeModifier,
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { },
+ interactionSource = interactionSource
+ ) {
+ SampleImage(
+ Modifier
+ .fillMaxHeight()
+ .width(90.dp)
+ )
+ }
+ },
+ title = { Text("Wide Card", Modifier.padding(start = 8.dp)) },
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("wideCardLayout_darkTheme")
+ }
+
+ @Test
+ fun wideCardLayout_focused() {
+ rule.setContent {
+ Box(
+ modifier = boxSizeModifier
+ .testTag(CardLayoutWrapperTag)
+ .semantics(mergeDescendants = true) {},
+ contentAlignment = Alignment.Center
+ ) {
+ WideCardLayout(
+ modifier = wideCardLayoutSizeModifier,
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { },
+ interactionSource = interactionSource
+ ) {
+ SampleImage(
+ Modifier
+ .fillMaxHeight()
+ .width(90.dp)
+ )
+ }
+ },
+ title = { Text("Wide Card", Modifier.padding(start = 8.dp)) },
+ )
+ }
+ }
+
+ rule.onNodeWithTag(CardLayoutWrapperTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ assertAgainstGolden("wideCardLayout_focused")
+ }
+
+ @Composable
+ fun SampleImage(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .background(Color.Blue)
+ )
+ }
+
+ private fun assertAgainstGolden(goldenName: String) {
+ rule.onNodeWithTag(CardLayoutWrapperTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, goldenName)
+ }
+}
+
+private const val CardLayoutWrapperTag = "card_layout_wrapper"
\ No newline at end of file
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardLayoutTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardLayoutTest.kt
new file mode 100644
index 0000000..f180509
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardLayoutTest.kt
@@ -0,0 +1,200 @@
+/*
+ * Copyright 2023 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.tv.material3
+
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.ui.ExperimentalComposeUiApi
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.input.key.Key
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.SemanticsActions
+import androidx.compose.ui.semantics.SemanticsProperties
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.SemanticsMatcher
+import androidx.compose.ui.test.assert
+import androidx.compose.ui.test.assertHasClickAction
+import androidx.compose.ui.test.assertIsEnabled
+import androidx.compose.ui.test.assertTextEquals
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onChild
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.performKeyInput
+import androidx.compose.ui.test.performSemanticsAction
+import androidx.compose.ui.test.pressKey
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.MediumTest
+import com.google.common.truth.Truth
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(
+ ExperimentalTestApi::class,
+ ExperimentalComposeUiApi::class,
+ ExperimentalTvMaterial3Api::class
+)
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class CardLayoutTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun standardCardLayout_semantics() {
+ val count = mutableStateOf(0)
+ rule.setContent {
+ StandardCardLayout(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(StandardCardLayoutTag),
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { count.value += 1 },
+ interactionSource = interactionSource
+ ) { SampleImage() }
+ },
+ title = { Text("${count.value}") }
+ )
+ }
+
+ rule.onNodeWithTag(StandardCardLayoutTag)
+ .onChild()
+ .assertHasClickAction()
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .assertIsEnabled()
+
+ rule.onNodeWithTag(StandardCardLayoutTag)
+ .assertTextEquals("0")
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .assertTextEquals("1")
+ }
+
+ @Test
+ fun standardCardLayout_clickAction() {
+ val count = mutableStateOf(0f)
+ rule.setContent {
+ StandardCardLayout(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(StandardCardLayoutTag),
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { count.value += 1 },
+ interactionSource = interactionSource
+ ) { SampleImage() }
+ },
+ title = { Text("${count.value}") }
+ )
+ }
+
+ rule.onNodeWithTag(StandardCardLayoutTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(1)
+
+ rule.onNodeWithTag(StandardCardLayoutTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(3)
+ }
+
+ @Test
+ fun wideCardLayout_semantics() {
+ val count = mutableStateOf(0)
+ rule.setContent {
+ WideCardLayout(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(WideCardLayoutTag),
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { count.value += 1 },
+ interactionSource = interactionSource
+ ) { SampleImage() }
+ },
+ title = { Text("${count.value}") }
+ )
+ }
+
+ rule.onNodeWithTag(WideCardLayoutTag)
+ .onChild()
+ .assertHasClickAction()
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .assertIsEnabled()
+
+ rule.onNodeWithTag(WideCardLayoutTag)
+ .assertTextEquals("0")
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .assertTextEquals("1")
+ }
+
+ @Test
+ fun wideCardLayout_clickAction() {
+ val count = mutableStateOf(0f)
+ rule.setContent {
+ WideCardLayout(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(WideCardLayoutTag),
+ imageCard = { interactionSource ->
+ CardLayoutDefaults.ImageCard(
+ onClick = { count.value += 1 },
+ interactionSource = interactionSource
+ ) { SampleImage() }
+ },
+ title = { Text("${count.value}") }
+ )
+ }
+
+ rule.onNodeWithTag(WideCardLayoutTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(1)
+
+ rule.onNodeWithTag(WideCardLayoutTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(3)
+ }
+
+ @Composable
+ fun SampleImage() {
+ Box(
+ Modifier
+ .size(180.dp, 150.dp)
+ .testTag(SampleImageTag)
+ )
+ }
+}
+
+private const val StandardCardLayoutTag = "standard-card-layout"
+private const val WideCardLayoutTag = "wide-card-layout"
+
+private const val SampleImageTag = "sample-image"
\ No newline at end of file
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardTest.kt
index 3ea23a8..68be7c4 100644
--- a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardTest.kt
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardTest.kt
@@ -471,10 +471,8 @@
}
private const val CardTag = "card"
-private const val StandardCardTag = "standard-card"
private const val CompactCardTag = "compact-card"
private const val ClassicCardTag = "classic-card"
-private const val WideCardTag = "wide-card"
private const val WideClassicCardTag = "wide-classic-card"
private const val SampleImageTag = "sample-image"
\ No newline at end of file
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/Card.kt b/tv/tv-material/src/main/java/androidx/tv/material3/Card.kt
index 53add8d..1c1fc72 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/Card.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/Card.kt
@@ -30,7 +30,6 @@
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
-import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
@@ -325,19 +324,7 @@
}
@Composable
-private fun CardContent(
- title: @Composable () -> Unit,
- subtitle: @Composable () -> Unit = {},
- description: @Composable () -> Unit = {},
- contentColor: Color
-) {
- CompositionLocalProvider(LocalContentColor provides contentColor) {
- CardContent(title, subtitle, description)
- }
-}
-
-@Composable
-private fun CardContent(
+internal fun CardContent(
title: @Composable () -> Unit,
subtitle: @Composable () -> Unit = {},
description: @Composable () -> Unit = {}
@@ -399,22 +386,6 @@
)
/**
- * Returns the content color [Color] from the colors [CardColors] for different
- * interaction states.
- */
- internal fun contentColor(
- focused: Boolean,
- pressed: Boolean,
- colors: CardColors
- ): Color {
- return when {
- focused -> colors.focusedContentColor
- pressed -> colors.pressedContentColor
- else -> colors.contentColor
- }
- }
-
- /**
* Creates a [CardShape] that represents the default container shapes used in a Card.
*
* @param shape the default shape used when the Card has no other [Interaction]s.
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/CardLayout.kt b/tv/tv-material/src/main/java/androidx/tv/material3/CardLayout.kt
new file mode 100644
index 0000000..92aa581
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/CardLayout.kt
@@ -0,0 +1,288 @@
+/*
+ * Copyright 2023 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.tv.material3
+
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.runtime.Immutable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+
+/**
+ * [StandardCardLayout] is an opinionated TV Material Card layout with an image and text content
+ * to show information about a subject.
+ *
+ * It provides a vertical layout with an image card slot at the top. And below that, there are
+ * slots for the title, subtitle and description.
+ *
+ * @param imageCard defines the [Composable] to be used for the image card. See
+ * [CardLayoutDefaults.ImageCard] to create an image card. The `interactionSource` param provided
+ * in the lambda function should be forwarded and used with the image card composable.
+ * @param title defines the [Composable] title placed below the image card in the CardLayout.
+ * @param modifier the [Modifier] to be applied to this CardLayout.
+ * @param subtitle defines the [Composable] supporting text placed below the title in CardLayout.
+ * @param description defines the [Composable] description placed below the subtitle in CardLayout.
+ * @param contentColor [CardLayoutColors] defines the content color used in the CardLayout
+ * for different interaction states. See [CardLayoutDefaults.contentColor].
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this CardLayout. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this card layout in different states.
+ * This interaction source param would also be forwarded to be used with the `imageCard` composable.
+ */
+@ExperimentalTvMaterial3Api
+@Composable
+fun StandardCardLayout(
+ imageCard: @Composable (interactionSource: MutableInteractionSource) -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {},
+ contentColor: CardLayoutColors = CardLayoutDefaults.contentColor(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val pressed by interactionSource.collectIsPressedAsState()
+
+ Column(
+ modifier = modifier
+ ) {
+ Box(
+ contentAlignment = CardDefaults.ContentImageAlignment,
+ ) {
+ imageCard(interactionSource)
+ }
+ Column(
+ modifier = Modifier
+ .align(Alignment.CenterHorizontally),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ CardLayoutContent(
+ title = title,
+ subtitle = subtitle,
+ description = description,
+ contentColor = contentColor.color(
+ focused = focused,
+ pressed = pressed
+ )
+ )
+ }
+ }
+}
+
+/**
+ * [WideCardLayout] is an opinionated TV Material Card layout with an image and text content
+ * to show information about a subject.
+ *
+ * It provides a horizontal layout with an image card slot at the start, followed by the title,
+ * subtitle and description at the end.
+ *
+ * @param imageCard defines the [Composable] to be used for the image card. See
+ * [CardLayoutDefaults.ImageCard] to create an image card. The `interactionSource` param provided
+ * in the lambda function should to be forwarded and used with the image card composable.
+ * @param title defines the [Composable] title placed below the image card in the CardLayout.
+ * @param modifier the [Modifier] to be applied to this CardLayout.
+ * @param subtitle defines the [Composable] supporting text placed below the title in CardLayout.
+ * @param description defines the [Composable] description placed below the subtitle in CardLayout.
+ * @param contentColor [CardLayoutColors] defines the content color used in the CardLayout
+ * for different interaction states. See [CardLayoutDefaults.contentColor].
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this CardLayout. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this card layout in different states.
+ * This interaction source param would also be forwarded to be used with the `imageCard` composable.
+ */
+@ExperimentalTvMaterial3Api
+@Composable
+fun WideCardLayout(
+ imageCard: @Composable (interactionSource: MutableInteractionSource) -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {},
+ contentColor: CardLayoutColors = CardLayoutDefaults.contentColor(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ val focused by interactionSource.collectIsFocusedAsState()
+ val pressed by interactionSource.collectIsPressedAsState()
+
+ Row(
+ modifier = modifier
+ ) {
+ Box(
+ contentAlignment = CardDefaults.ContentImageAlignment
+ ) {
+ imageCard(interactionSource)
+ }
+ Column {
+ CardLayoutContent(
+ title = title,
+ subtitle = subtitle,
+ description = description,
+ contentColor = contentColor.color(
+ focused = focused,
+ pressed = pressed
+ )
+ )
+ }
+ }
+}
+
+@Composable
+internal fun CardLayoutContent(
+ title: @Composable () -> Unit,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {},
+ contentColor: Color
+) {
+ CompositionLocalProvider(LocalContentColor provides contentColor) {
+ CardContent(title, subtitle, description)
+ }
+}
+
+@ExperimentalTvMaterial3Api
+object CardLayoutDefaults {
+ /**
+ * Creates [CardLayoutColors] that represents the default content colors used in a
+ * CardLayout.
+ *
+ * @param contentColor the default content color of this CardLayout.
+ * @param focusedContentColor the content color of this CardLayout when focused.
+ * @param pressedContentColor the content color of this CardLayout when pressed.
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun contentColor(
+ contentColor: Color = MaterialTheme.colorScheme.onSurface,
+ focusedContentColor: Color = contentColor,
+ pressedContentColor: Color = focusedContentColor
+ ) = CardLayoutColors(
+ contentColor = contentColor,
+ focusedContentColor = focusedContentColor,
+ pressedContentColor = pressedContentColor
+ )
+
+ /**
+ * [ImageCard] is basically a [Card] composable with an image as the content. It is recommended
+ * to be used with the different CardLayout(s).
+ *
+ * This Card handles click events, calling its [onClick] lambda.
+ *
+ * @param onClick called when this card is clicked
+ * @param interactionSource the [MutableInteractionSource] representing the stream of
+ * [Interaction]s for this card. When using with the CardLayout(s), it is recommended to
+ * pass in the interaction state obtained from the parent lambda.
+ * @param modifier the [Modifier] to be applied to this card
+ * @param shape [CardShape] defines the shape of this card's container in different interaction
+ * states. See [CardDefaults.shape].
+ * @param colors [CardColors] defines the background & content colors used in this card for
+ * different interaction states. See [CardDefaults.colors].
+ * @param scale [CardScale] defines size of the card relative to its original size for different
+ * interaction states. See [CardDefaults.scale].
+ * @param border [CardBorder] defines a border around the card for different interaction states.
+ * See [CardDefaults.border].
+ * @param glow [CardGlow] defines a shadow to be shown behind the card for different interaction
+ * states. See [CardDefaults.glow].
+ * @param content defines the image content [Composable] to be displayed inside the Card.
+ */
+ @Composable
+ fun ImageCard(
+ onClick: () -> Unit,
+ interactionSource: MutableInteractionSource,
+ modifier: Modifier = Modifier,
+ shape: CardShape = CardDefaults.shape(),
+ colors: CardColors = CardDefaults.colors(),
+ scale: CardScale = CardDefaults.scale(),
+ border: CardBorder = CardDefaults.border(),
+ glow: CardGlow = CardDefaults.glow(),
+ content: @Composable () -> Unit
+ ) {
+ Card(
+ onClick = onClick,
+ modifier = modifier,
+ shape = shape,
+ colors = colors,
+ scale = scale,
+ border = border,
+ glow = glow,
+ interactionSource = interactionSource
+ ) {
+ content()
+ }
+ }
+}
+
+/**
+ * Represents the [Color] of content in a CardLayout for different interaction states.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class CardLayoutColors internal constructor(
+ internal val contentColor: Color,
+ internal val focusedContentColor: Color,
+ internal val pressedContentColor: Color,
+) {
+ /**
+ * Returns the content color [Color] for different interaction states.
+ */
+ internal fun color(
+ focused: Boolean,
+ pressed: Boolean
+ ): Color {
+ return when {
+ focused -> focusedContentColor
+ pressed -> pressedContentColor
+ else -> contentColor
+ }
+ }
+
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as CardLayoutColors
+
+ if (contentColor != other.contentColor) return false
+ if (focusedContentColor != other.focusedContentColor) return false
+ if (pressedContentColor != other.pressedContentColor) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = contentColor.hashCode()
+ result = 31 * result + focusedContentColor.hashCode()
+ result = 31 * result + pressedContentColor.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "CardLayoutContentColor(" +
+ "contentColor=$contentColor, " +
+ "focusedContentColor=$focusedContentColor, " +
+ "pressedContentColor=$pressedContentColor)"
+ }
+}