Merge "Introduce TV opinionated material cards" into androidx-main
diff --git a/tv/tv-material/api/public_plus_experimental_current.txt b/tv/tv-material/api/public_plus_experimental_current.txt
index 72025d1..26b688f 100644
--- a/tv/tv-material/api/public_plus_experimental_current.txt
+++ b/tv/tv-material/api/public_plus_experimental_current.txt
@@ -70,10 +70,16 @@
@androidx.tv.material3.ExperimentalTvMaterial3Api public final class CardDefaults {
method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.CardBorder border(optional androidx.tv.material3.Border border, optional androidx.tv.material3.Border focusedBorder, optional androidx.tv.material3.Border pressedBorder);
method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.CardColors colors(optional long containerColor, optional long contentColor, optional long focusedContainerColor, optional long focusedContentColor, optional long pressedContainerColor, optional long pressedContentColor);
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.CardColors compactCardColors(optional long containerColor, optional long contentColor, optional long focusedContainerColor, optional long focusedContentColor, optional long pressedContainerColor, optional long pressedContentColor);
+ method public androidx.compose.ui.graphics.Brush getContainerGradient();
method public androidx.tv.material3.CardGlow glow(optional androidx.tv.material3.Glow glow, optional androidx.tv.material3.Glow focusedGlow, optional androidx.tv.material3.Glow pressedGlow);
method public androidx.tv.material3.CardScale scale(optional @FloatRange(from=0.0) float scale, optional @FloatRange(from=0.0) float focusedScale, optional @FloatRange(from=0.0) float pressedScale);
method public androidx.tv.material3.CardShape shape(optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.ui.graphics.Shape focusedShape, optional androidx.compose.ui.graphics.Shape pressedShape);
+ property public final androidx.compose.ui.graphics.Brush ContainerGradient;
+ field public static final float HorizontalImageAspectRatio = 1.7777778f;
field public static final androidx.tv.material3.CardDefaults INSTANCE;
+ field public static final float SquareImageAspectRatio = 1.0f;
+ field public static final float VerticalImageAspectRatio = 0.6666667f;
}
@androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class CardGlow {
@@ -81,6 +87,9 @@
public final class CardKt {
method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void Card(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, 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, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.ColumnScope,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void ClassicCard(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);
+ method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void CompactCard(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.ui.graphics.Brush scrimBrush, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
+ 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 CardScale {
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardScreenshotTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardScreenshotTest.kt
new file mode 100644
index 0000000..f613158
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/CardScreenshotTest.kt
@@ -0,0 +1,393 @@
+/*
+ * 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.PaddingValues
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+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.Companion.Center
+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.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 CardScreenshotTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(TV_GOLDEN_MATERIAL3)
+
+ private val boxSizeModifier = Modifier.size(220.dp, 180.dp)
+ private val verticalCardSizeModifier = Modifier.size(150.dp, 120.dp)
+ private val horizontalCardSizeModifier = Modifier.size(180.dp, 100.dp)
+
+ @Test
+ fun card_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ Card(
+ modifier = verticalCardSizeModifier,
+ onClick = { }
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Text("Card", Modifier.align(Center))
+ }
+ }
+ }
+ }
+ }
+
+ assertAgainstGolden("card_lightTheme")
+ }
+
+ @Test
+ fun card_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ Card(
+ modifier = verticalCardSizeModifier,
+ onClick = { }
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Text("Card", Modifier.align(Center))
+ }
+ }
+ }
+ }
+ }
+
+ assertAgainstGolden("card_darkTheme")
+ }
+
+ @Test
+ fun card_focused() {
+ rule.setContent {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ Card(
+ modifier = verticalCardSizeModifier,
+ onClick = { }
+ ) {
+ Box(Modifier.fillMaxSize()) {
+ Text("Card", Modifier.align(Center))
+ }
+ }
+ }
+ }
+
+ rule.onNodeWithTag(CardWrapperTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ assertAgainstGolden("card_focused")
+ }
+
+ @Test
+ fun classicCard_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ ClassicCard(
+ modifier = verticalCardSizeModifier,
+ image = {
+ SampleImage(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ )
+ },
+ title = { Text("Classic Card") },
+ contentPadding = PaddingValues(8.dp),
+ onClick = { }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("classicCard_lightTheme")
+ }
+
+ @Test
+ fun classicCard_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ ClassicCard(
+ modifier = verticalCardSizeModifier,
+ image = {
+ SampleImage(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ )
+ },
+ title = { Text("Classic Card") },
+ contentPadding = PaddingValues(8.dp),
+ onClick = { }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("classicCard_darkTheme")
+ }
+
+ @Test
+ fun classicCard_focused() {
+ rule.setContent {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ ClassicCard(
+ modifier = verticalCardSizeModifier,
+ image = {
+ SampleImage(
+ Modifier
+ .fillMaxWidth()
+ .height(80.dp)
+ )
+ },
+ title = { Text("Classic Card") },
+ contentPadding = PaddingValues(8.dp),
+ onClick = { }
+ )
+ }
+ }
+
+ rule.onNodeWithTag(CardWrapperTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ assertAgainstGolden("classicCard_focused")
+ }
+
+ @Test
+ fun compactCard_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ CompactCard(
+ modifier = verticalCardSizeModifier,
+ image = { SampleImage(Modifier.fillMaxSize()) },
+ title = { Text("Compact Card", Modifier.padding(8.dp)) },
+ onClick = { }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("compactCard_lightTheme")
+ }
+
+ @Test
+ fun compactCard_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ CompactCard(
+ modifier = verticalCardSizeModifier,
+ image = { SampleImage(Modifier.fillMaxSize()) },
+ title = { Text("Compact Card", Modifier.padding(8.dp)) },
+ onClick = { }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("compactCard_darkTheme")
+ }
+
+ @Test
+ fun compactCard_focused() {
+ rule.setContent {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ CompactCard(
+ modifier = verticalCardSizeModifier,
+ image = { SampleImage(Modifier.fillMaxSize()) },
+ title = { Text("Compact Card", Modifier.padding(8.dp)) },
+ onClick = { }
+ )
+ }
+ }
+
+ rule.onNodeWithTag(CardWrapperTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ assertAgainstGolden("compactCard_focused")
+ }
+
+ @Test
+ fun wideClassicCard_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ WideClassicCard(
+ modifier = horizontalCardSizeModifier,
+ image = {
+ SampleImage(
+ Modifier
+ .fillMaxHeight()
+ .width(80.dp)
+ )
+ },
+ title = { Text("Wide Classic Card", Modifier.padding(start = 8.dp)) },
+ contentPadding = PaddingValues(8.dp),
+ onClick = { }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("wideClassicCard_lightTheme")
+ }
+
+ @Test
+ fun wideClassicCard_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ WideClassicCard(
+ modifier = horizontalCardSizeModifier,
+ image = {
+ SampleImage(
+ Modifier
+ .fillMaxHeight()
+ .width(80.dp)
+ )
+ },
+ title = { Text("Wide Classic Card", Modifier.padding(start = 8.dp)) },
+ contentPadding = PaddingValues(8.dp),
+ onClick = { }
+ )
+ }
+ }
+ }
+
+ assertAgainstGolden("wideClassicCard_darkTheme")
+ }
+
+ @Test
+ fun wideClassicCard_focused() {
+ rule.setContent {
+ Box(
+ modifier = boxSizeModifier.testTag(CardWrapperTag),
+ contentAlignment = Center
+ ) {
+ WideClassicCard(
+ modifier = horizontalCardSizeModifier,
+ image = {
+ SampleImage(
+ Modifier
+ .fillMaxHeight()
+ .width(80.dp)
+ )
+ },
+ title = { Text("Wide Classic Card", Modifier.padding(start = 8.dp)) },
+ contentPadding = PaddingValues(8.dp),
+ onClick = { }
+ )
+ }
+ }
+
+ rule.onNodeWithTag(CardWrapperTag)
+ .onChild()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.waitForIdle()
+
+ assertAgainstGolden("wideClassicCard_focused")
+ }
+
+ @Composable
+ fun SampleImage(modifier: Modifier = Modifier) {
+ Box(
+ modifier = modifier
+ .background(Color.Blue)
+ )
+ }
+
+ private fun assertAgainstGolden(goldenName: String) {
+ rule.onNodeWithTag(CardWrapperTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, goldenName)
+ }
+}
+
+private const val CardWrapperTag = "card_wrapper"
\ 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 d4437c7..3ea23a8 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
@@ -23,11 +23,14 @@
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.PressInteraction
import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CutCornerShape
+import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.testutils.assertIsEqualTo
import androidx.compose.testutils.assertShape
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
@@ -44,6 +47,7 @@
import androidx.compose.ui.test.assertIsEnabled
import androidx.compose.ui.test.assertTextEquals
import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.performKeyInput
@@ -51,7 +55,7 @@
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 androidx.test.filters.LargeTest
import androidx.test.filters.SdkSuppress
import com.google.common.truth.Truth
import kotlinx.coroutines.CoroutineScope
@@ -65,7 +69,7 @@
ExperimentalComposeUiApi::class,
ExperimentalTvMaterial3Api::class
)
-@MediumTest
+@LargeTest
@RunWith(AndroidJUnit4::class)
class CardTest {
@get:Rule
@@ -82,7 +86,7 @@
Card(
modifier = Modifier
.semantics(mergeDescendants = true) {}
- .testTag("card"),
+ .testTag(CardTag),
shape = CardDefaults.shape(shape = shape),
colors = CardDefaults.colors(containerColor = cardColor),
onClick = {}
@@ -93,7 +97,7 @@
}
rule
- .onNodeWithTag("card")
+ .onNodeWithTag(CardTag)
.captureToImage()
.assertShape(
density = rule.density,
@@ -109,14 +113,15 @@
val count = mutableStateOf(0)
rule.setContent {
Card(
- modifier = Modifier.testTag("card"),
+ modifier = Modifier.testTag(CardTag),
onClick = { count.value += 1 },
) {
Text("${count.value}")
Spacer(Modifier.size(30.dp))
}
}
- rule.onNodeWithTag("card")
+
+ rule.onNodeWithTag(CardTag)
.assertHasClickAction()
.assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
.performSemanticsAction(SemanticsActions.RequestFocus)
@@ -131,7 +136,7 @@
val count = mutableStateOf(0f)
rule.setContent {
Card(
- modifier = Modifier.testTag("card"),
+ modifier = Modifier.testTag(CardTag),
onClick = { count.value += 1 },
) {
Text("${count.value}")
@@ -139,12 +144,12 @@
}
}
- rule.onNodeWithTag("card")
+ rule.onNodeWithTag(CardTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput { pressKey(Key.DirectionCenter) }
Truth.assertThat(count.value).isEqualTo(1)
- rule.onNodeWithTag("card")
+ rule.onNodeWithTag(CardTag)
.performSemanticsAction(SemanticsActions.RequestFocus)
.performKeyInput { pressKey(Key.DirectionCenter) }
.performKeyInput { pressKey(Key.DirectionCenter) }
@@ -161,7 +166,7 @@
scope = rememberCoroutineScope()
Card(
onClick = {},
- modifier = Modifier.testTag("card"),
+ modifier = Modifier.testTag(CardTag),
interactionSource = interactionSource
) {
Spacer(Modifier.size(30.dp))
@@ -174,14 +179,14 @@
rule.runOnIdle { Truth.assertThat(interactions).isEmpty() }
- rule.onNodeWithTag("card").performSemanticsAction(SemanticsActions.RequestFocus)
+ rule.onNodeWithTag(CardTag).performSemanticsAction(SemanticsActions.RequestFocus)
rule.runOnIdle {
Truth.assertThat(interactions).hasSize(1)
Truth.assertThat(interactions.first()).isInstanceOf(FocusInteraction.Focus::class.java)
}
- rule.onNodeWithTag("card").performKeyInput { pressKey(Key.DirectionCenter) }
+ rule.onNodeWithTag(CardTag).performKeyInput { pressKey(Key.DirectionCenter) }
rule.runOnIdle {
Truth.assertThat(interactions).hasSize(3)
@@ -190,4 +195,286 @@
Truth.assertThat(interactions[2]).isInstanceOf(PressInteraction.Release::class.java)
}
}
-}
\ No newline at end of file
+
+ @Test
+ fun classicCard_semantics() {
+ val count = mutableStateOf(0)
+ rule.setContent {
+ ClassicCard(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(ClassicCardTag),
+ image = { SampleImage() },
+ title = { Text("${count.value}") },
+ onClick = { count.value += 1 }
+ )
+ }
+
+ rule.onNodeWithTag(ClassicCardTag)
+ .assertHasClickAction()
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .assertIsEnabled()
+ .assertTextEquals("0")
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .assertTextEquals("1")
+ }
+
+ @Test
+ fun classicCard_clickAction() {
+ val count = mutableStateOf(0f)
+ rule.setContent {
+ ClassicCard(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(ClassicCardTag),
+ image = { SampleImage() },
+ title = { Text("${count.value}") },
+ onClick = { count.value += 1 }
+ )
+ }
+
+ rule.onNodeWithTag(ClassicCardTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(1)
+
+ rule.onNodeWithTag(ClassicCardTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(3)
+ }
+
+ @Test
+ fun classicCard_contentPadding() {
+ val contentPadding = PaddingValues(8.dp, 10.dp, 12.dp, 14.dp)
+ val cardTitleTag = "classic_card_title"
+
+ rule.setContent {
+ ClassicCard(
+ modifier = Modifier.testTag(ClassicCardTag),
+ image = { SampleImage() },
+ title = {
+ Text(
+ text = "Classic Card",
+ modifier = Modifier.testTag(cardTitleTag)
+ )
+ },
+ onClick = {},
+ contentPadding = contentPadding
+ )
+ }
+
+ val cardBounds = rule
+ .onNodeWithTag(ClassicCardTag)
+ .getUnclippedBoundsInRoot()
+
+ val imageBounds = rule
+ .onNodeWithTag(SampleImageTag, true)
+ .getUnclippedBoundsInRoot()
+
+ val titleBounds = rule
+ .onNodeWithTag(cardTitleTag, true)
+ .getUnclippedBoundsInRoot()
+
+ // Check top padding
+ (imageBounds.top - cardBounds.top).assertIsEqualTo(
+ 10.dp,
+ "padding between top of the image and top of the card."
+ )
+
+ // Check bottom padding
+ (cardBounds.bottom - titleBounds.bottom).assertIsEqualTo(
+ 14.dp,
+ "padding between bottom of the text and bottom of the card."
+ )
+
+ // Check start padding
+ (imageBounds.left - cardBounds.left).assertIsEqualTo(
+ 8.dp,
+ "padding between left of the image and left of the card."
+ )
+
+ // Check end padding
+ (cardBounds.right - imageBounds.right).assertIsEqualTo(
+ 12.dp,
+ "padding between right of the text and right of the card."
+ )
+ }
+
+ @Test
+ fun compactCard_semantics() {
+ val count = mutableStateOf(0)
+ rule.setContent {
+ CompactCard(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(CompactCardTag),
+ image = { SampleImage() },
+ title = { Text("${count.value}") },
+ onClick = { count.value += 1 }
+ )
+ }
+
+ rule.onNodeWithTag(CompactCardTag)
+ .assertHasClickAction()
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .assertIsEnabled()
+ .assertTextEquals("0")
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .assertTextEquals("1")
+ }
+
+ @Test
+ fun compactCard_clickAction() {
+ val count = mutableStateOf(0f)
+ rule.setContent {
+ CompactCard(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(CompactCardTag),
+ image = { SampleImage() },
+ title = { Text("${count.value}") },
+ onClick = { count.value += 1 }
+ )
+ }
+
+ rule.onNodeWithTag(CompactCardTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(1)
+
+ rule.onNodeWithTag(CompactCardTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(3)
+ }
+
+ @Test
+ fun wideClassicCard_semantics() {
+ val count = mutableStateOf(0)
+ rule.setContent {
+ WideClassicCard(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(WideClassicCardTag),
+ image = { SampleImage() },
+ title = { Text("${count.value}") },
+ onClick = { count.value += 1 }
+ )
+ }
+
+ rule.onNodeWithTag(WideClassicCardTag)
+ .assertHasClickAction()
+ .assert(SemanticsMatcher.keyNotDefined(SemanticsProperties.Role))
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .assertIsEnabled()
+ .assertTextEquals("0")
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .assertTextEquals("1")
+ }
+
+ @Test
+ fun wideClassicCard_clickAction() {
+ val count = mutableStateOf(0f)
+ rule.setContent {
+ WideClassicCard(
+ modifier = Modifier
+ .semantics(mergeDescendants = true) {}
+ .testTag(WideClassicCardTag),
+ image = { SampleImage() },
+ title = { Text("${count.value}") },
+ onClick = { count.value += 1 }
+ )
+ }
+
+ rule.onNodeWithTag(WideClassicCardTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(1)
+
+ rule.onNodeWithTag(WideClassicCardTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ Truth.assertThat(count.value).isEqualTo(3)
+ }
+
+ @Test
+ fun wideClassicCard_contentPadding() {
+ val contentPadding = PaddingValues(8.dp, 10.dp, 12.dp, 14.dp)
+ val cardTitleTag = "wide_classic_card_title"
+
+ rule.setContent {
+ WideClassicCard(
+ modifier = Modifier.testTag(WideClassicCardTag),
+ image = { SampleImage() },
+ title = {
+ Text(
+ text = "Wide Classic Card",
+ modifier = Modifier.testTag(cardTitleTag)
+ )
+ },
+ onClick = {},
+ contentPadding = contentPadding
+ )
+ }
+
+ val cardBounds = rule
+ .onNodeWithTag(WideClassicCardTag)
+ .getUnclippedBoundsInRoot()
+
+ val imageBounds = rule
+ .onNodeWithTag(SampleImageTag, true)
+ .getUnclippedBoundsInRoot()
+
+ val titleBounds = rule
+ .onNodeWithTag(cardTitleTag, true)
+ .getUnclippedBoundsInRoot()
+
+ // Check top padding
+ (imageBounds.top - cardBounds.top).assertIsEqualTo(
+ 10.dp,
+ "padding between top of the image and top of the card."
+ )
+
+ // Check bottom padding
+ (cardBounds.bottom - imageBounds.bottom).assertIsEqualTo(
+ 14.dp,
+ "padding between bottom of the text and bottom of the card."
+ )
+
+ // Check start padding
+ (imageBounds.left - cardBounds.left).assertIsEqualTo(
+ 8.dp,
+ "padding between left of the image and left of the card."
+ )
+
+ // Check end padding
+ (cardBounds.right - titleBounds.right).assertIsEqualTo(
+ 12.dp,
+ "padding between right of the text and right of the card."
+ )
+ }
+
+ @Composable
+ fun SampleImage() {
+ Box(
+ Modifier
+ .size(180.dp, 150.dp)
+ .testTag(SampleImageTag)
+ )
+ }
+}
+
+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 1a5d6c7..53add8d 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
@@ -20,15 +20,26 @@
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.interaction.Interaction
import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.aspectRatio
+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
import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.drawWithCache
+import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.unit.dp
/**
@@ -82,16 +93,328 @@
}
/**
+ * [ClassicCard] is an opinionated TV Material card that offers a 4 slot layout to show
+ * information about a subject.
+ *
+ * This card has a vertical layout with the interactive surface [Surface], which provides the image
+ * slot at the top, followed by the title, subtitle, and description slots.
+ *
+ * This Card handles click events, calling its [onClick] lambda.
+ *
+ * @param onClick called when this card is clicked
+ * @param image defines the [Composable] image to be displayed on top of the Card.
+ * @param title defines the [Composable] title placed below the image in the Card.
+ * @param modifier the [Modifier] to be applied to this card.
+ * @param subtitle defines the [Composable] supporting text placed below the title of the Card.
+ * @param description defines the [Composable] description placed below the subtitle of the 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 contentPadding [PaddingValues] defines the inner padding applied to the card's content.
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this card. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this card in different states.
+ */
+@ExperimentalTvMaterial3Api
+@Composable
+fun ClassicCard(
+ onClick: () -> Unit,
+ image: @Composable BoxScope.() -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {},
+ shape: CardShape = CardDefaults.shape(),
+ colors: CardColors = CardDefaults.colors(),
+ scale: CardScale = CardDefaults.scale(),
+ border: CardBorder = CardDefaults.border(),
+ glow: CardGlow = CardDefaults.glow(),
+ contentPadding: PaddingValues = PaddingValues(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+ shape = shape,
+ colors = colors,
+ scale = scale,
+ border = border,
+ glow = glow
+ ) {
+ Column(
+ modifier = Modifier.padding(contentPadding)
+ ) {
+ Box(
+ contentAlignment = CardDefaults.ContentImageAlignment,
+ content = image
+ )
+ Column {
+ CardContent(
+ title = title,
+ subtitle = subtitle,
+ description = description
+ )
+ }
+ }
+ }
+}
+
+/**
+ * [CompactCard] is an opinionated TV Material card that offers a 4 slot layout to show
+ * information about a subject.
+ *
+ * This card provides the interactive surface [Surface] with the image slot as the background
+ * (with an overlay scrim gradient). Other slots for the title, subtitle, and description are
+ * placed over it.
+ *
+ * This Card handles click events, calling its [onClick] lambda.
+ *
+ * @param onClick called when this card is clicked
+ * @param image defines the [Composable] image to be displayed on top of the Card.
+ * @param title defines the [Composable] title placed below the image in the Card.
+ * @param modifier the [Modifier] to be applied to this card.
+ * @param subtitle defines the [Composable] supporting text placed below the title of the Card.
+ * @param description defines the [Composable] description placed below the subtitle of the 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.compactCardColors].
+ * @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 scrimBrush [Brush] defines a brush/gradient to be used to draw the scrim over the image
+ * in the background. See [CardDefaults.ContainerGradient].
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this card. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this card in different states.
+ */
+@ExperimentalTvMaterial3Api
+@Composable
+fun CompactCard(
+ onClick: () -> Unit,
+ image: @Composable BoxScope.() -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {},
+ shape: CardShape = CardDefaults.shape(),
+ colors: CardColors = CardDefaults.compactCardColors(),
+ scale: CardScale = CardDefaults.scale(),
+ border: CardBorder = CardDefaults.border(),
+ glow: CardGlow = CardDefaults.glow(),
+ scrimBrush: Brush = CardDefaults.ContainerGradient,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+ shape = shape,
+ colors = colors,
+ scale = scale,
+ border = border,
+ glow = glow
+ ) {
+ Box(contentAlignment = Alignment.BottomStart) {
+ Box(
+ modifier = Modifier
+ .drawWithCache {
+ onDrawWithContent {
+ drawContent()
+ drawRect(brush = scrimBrush)
+ }
+ },
+ contentAlignment = CardDefaults.ContentImageAlignment,
+ content = image
+ )
+ Column {
+ CardContent(
+ title = title,
+ subtitle = subtitle,
+ description = description
+ )
+ }
+ }
+ }
+}
+
+/**
+ * [WideClassicCard] is an opinionated TV Material card that offers a 4 slot layout to show
+ * information about a subject.
+ *
+ * This card has a horizontal layout with the interactive surface [Surface], which provides the
+ * image slot at the start, followed by the title, subtitle, and description slots at the end.
+ *
+ * This Card handles click events, calling its [onClick] lambda.
+ *
+ * @param onClick called when this card is clicked
+ * @param image defines the [Composable] image to be displayed on top of the Card.
+ * @param title defines the [Composable] title placed below the image in the Card.
+ * @param modifier the [Modifier] to be applied to this card.
+ * @param subtitle defines the [Composable] supporting text placed below the title of the Card.
+ * @param description defines the [Composable] description placed below the subtitle of the 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 contentPadding [PaddingValues] defines the inner padding applied to the card's content.
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this card. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this card in different states.
+ */
+@ExperimentalTvMaterial3Api
+@Composable
+fun WideClassicCard(
+ onClick: () -> Unit,
+ image: @Composable BoxScope.() -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {},
+ shape: CardShape = CardDefaults.shape(),
+ colors: CardColors = CardDefaults.colors(),
+ scale: CardScale = CardDefaults.scale(),
+ border: CardBorder = CardDefaults.border(),
+ glow: CardGlow = CardDefaults.glow(),
+ contentPadding: PaddingValues = PaddingValues(),
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }
+) {
+ Card(
+ onClick = onClick,
+ modifier = modifier,
+ interactionSource = interactionSource,
+ shape = shape,
+ colors = colors,
+ scale = scale,
+ border = border,
+ glow = glow
+ ) {
+ Row(
+ modifier = Modifier.padding(contentPadding)
+ ) {
+ Box(
+ contentAlignment = CardDefaults.ContentImageAlignment,
+ content = image
+ )
+ Column {
+ CardContent(
+ title = title,
+ subtitle = subtitle,
+ description = description
+ )
+ }
+ }
+ }
+}
+
+@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(
+ title: @Composable () -> Unit,
+ subtitle: @Composable () -> Unit = {},
+ description: @Composable () -> Unit = {}
+) {
+ ProvideTextStyle(MaterialTheme.typography.titleMedium) {
+ title.invoke()
+ }
+ ProvideTextStyle(MaterialTheme.typography.bodySmall) {
+ Box(Modifier.graphicsLayer { alpha = SubtitleAlpha }) {
+ subtitle.invoke()
+ }
+ }
+ ProvideTextStyle(MaterialTheme.typography.bodySmall) {
+ Box(Modifier.graphicsLayer { alpha = DescriptionAlpha }) {
+ description.invoke()
+ }
+ }
+}
+
+/**
* Contains the default values used by all card types.
*/
@ExperimentalTvMaterial3Api
object CardDefaults {
+ internal val ContentImageAlignment = Alignment.Center
+
/**
- * The default [Shape] used by Cards.
- */
+ * The default [Shape] used by Cards.
+ */
private val ContainerShape = RoundedCornerShape(8.dp)
/**
+ * Recommended aspect ratio [Float] to get square images, can be applied using the modifier
+ * [Modifier.aspectRatio].
+ */
+ const val SquareImageAspectRatio = 1f
+
+ /**
+ * Recommended aspect ratio [Float] for vertical images, can be applied using the modifier
+ * [Modifier.aspectRatio].
+ */
+ const val VerticalImageAspectRatio = 2f / 3
+
+ /**
+ * Recommended aspect ratio [Float] for horizontal images, can be applied using the modifier
+ * [Modifier.aspectRatio].
+ */
+ const val HorizontalImageAspectRatio = 16f / 9
+
+ /**
+ * Gradient used in cards to give more emphasis to the textual content that is generally
+ * displayed above an image.
+ */
+ val ContainerGradient = Brush.verticalGradient(
+ listOf(
+ Color(red = 28, green = 27, blue = 31, alpha = 0),
+ Color(red = 28, green = 27, blue = 31, alpha = 204)
+ )
+ )
+
+ /**
+ * 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.
@@ -121,7 +444,7 @@
@ReadOnlyComposable
@Composable
fun colors(
- containerColor: Color = MaterialTheme.colorScheme.surface,
+ containerColor: Color = MaterialTheme.colorScheme.surfaceVariant,
contentColor: Color = contentColorFor(containerColor),
focusedContainerColor: Color = containerColor,
focusedContentColor: Color = contentColorFor(focusedContainerColor),
@@ -137,6 +460,34 @@
)
/**
+ * Creates [CardColors] that represents the default colors used in a Compact Card.
+ *
+ * @param containerColor the default container color of this Card.
+ * @param contentColor the default content color of this Card.
+ * @param focusedContainerColor the container color of this Card when focused.
+ * @param focusedContentColor the content color of this Card when focused.
+ * @param pressedContainerColor the container color of this Card when pressed.
+ * @param pressedContentColor the content color of this Card when pressed.
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun compactCardColors(
+ containerColor: Color = MaterialTheme.colorScheme.surfaceVariant,
+ contentColor: Color = Color.White,
+ focusedContainerColor: Color = containerColor,
+ focusedContentColor: Color = contentColor,
+ pressedContainerColor: Color = focusedContainerColor,
+ pressedContentColor: Color = focusedContentColor
+ ) = CardColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ focusedContainerColor = focusedContainerColor,
+ focusedContentColor = focusedContentColor,
+ pressedContainerColor = pressedContainerColor,
+ pressedContentColor = pressedContentColor
+ )
+
+ /**
* Creates a [CardScale] that represents the default scales used in a Card.
* Scales are used to modify the size of a composable in different [Interaction] states
* e.g. 1f (original) in default state, 1.1f (scaled up) in focused state, 0.8f (scaled down)
@@ -200,6 +551,9 @@
)
}
+private const val SubtitleAlpha = 0.6f
+private const val DescriptionAlpha = 0.8f
+
@OptIn(ExperimentalTvMaterial3Api::class)
private fun CardColors.toClickableSurfaceContainerColor() =
ClickableSurfaceColor(