feat: introduce buttons composables in tv material
Patchset-1: Direct fork of compose.material3 Button and OutlinedButton
Latest-Patchset: TV related style changes
Test: added instrumentation and snapshot tests
Relnote: "Introduce Buttons in TV Material"
Change-Id: I69c114f31dd374675276629659ac40ec8c2ebbfa
diff --git a/tv/tv-material/api/public_plus_experimental_current.txt b/tv/tv-material/api/public_plus_experimental_current.txt
index 067c9ae..beb7c7f 100644
--- a/tv/tv-material/api/public_plus_experimental_current.txt
+++ b/tv/tv-material/api/public_plus_experimental_current.txt
@@ -24,6 +24,43 @@
method @androidx.compose.runtime.Composable public androidx.compose.foundation.IndicationInstance rememberUpdatedInstance(androidx.compose.foundation.interaction.InteractionSource interactionSource);
}
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ButtonBorder {
+ }
+
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ButtonColors {
+ }
+
+ @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ButtonDefaults {
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ButtonBorder border(optional androidx.tv.material3.Border border, optional androidx.tv.material3.Border focusedBorder, optional androidx.tv.material3.Border pressedBorder, optional androidx.tv.material3.Border disabledBorder, optional androidx.tv.material3.Border focusedDisabledBorder);
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ButtonColors colors(optional long containerColor, optional long contentColor, optional long focusedContainerColor, optional long focusedContentColor, optional long pressedContainerColor, optional long pressedContentColor, optional long disabledContainerColor, optional long disabledContentColor);
+ method public androidx.compose.foundation.layout.PaddingValues getButtonWithIconContentPadding();
+ method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
+ method public float getIconSize();
+ method public float getIconSpacing();
+ method public androidx.tv.material3.ButtonGlow glow(optional androidx.tv.material3.Glow glow, optional androidx.tv.material3.Glow focusedGlow, optional androidx.tv.material3.Glow pressedGlow);
+ method public androidx.tv.material3.ButtonScale scale(optional @FloatRange(from=0.0) float scale, optional @FloatRange(from=0.0) float focusedScale, optional @FloatRange(from=0.0) float pressedScale, optional @FloatRange(from=0.0) float disabledScale, optional @FloatRange(from=0.0) float focusedDisabledScale);
+ method public androidx.tv.material3.ButtonShape shape(optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.ui.graphics.Shape focusedShape, optional androidx.compose.ui.graphics.Shape pressedShape, optional androidx.compose.ui.graphics.Shape disabledShape, optional androidx.compose.ui.graphics.Shape focusedDisabledShape);
+ property public final androidx.compose.foundation.layout.PaddingValues ButtonWithIconContentPadding;
+ property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
+ property public final float IconSize;
+ property public final float IconSpacing;
+ field public static final androidx.tv.material3.ButtonDefaults INSTANCE;
+ }
+
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ButtonGlow {
+ }
+
+ public final class ButtonKt {
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void Button(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.tv.material3.ButtonScale scale, optional androidx.tv.material3.ButtonGlow glow, optional androidx.tv.material3.ButtonShape shape, optional androidx.tv.material3.ButtonColors colors, optional float tonalElevation, optional androidx.tv.material3.ButtonBorder border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void OutlinedButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.tv.material3.ButtonScale scale, optional androidx.tv.material3.ButtonGlow glow, optional androidx.tv.material3.ButtonShape shape, optional androidx.tv.material3.ButtonColors colors, optional float tonalElevation, optional androidx.tv.material3.ButtonBorder border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, kotlin.jvm.functions.Function1<? super androidx.compose.foundation.layout.RowScope,kotlin.Unit> content);
+ }
+
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ButtonScale {
+ }
+
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ButtonShape {
+ }
+
@androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class CardBorder {
}
@@ -288,6 +325,23 @@
method @androidx.compose.runtime.Composable @androidx.tv.material3.ExperimentalTvMaterial3Api public static androidx.tv.material3.DrawerState rememberDrawerState(androidx.tv.material3.DrawerValue initialValue);
}
+ @androidx.tv.material3.ExperimentalTvMaterial3Api public final class OutlinedButtonDefaults {
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ButtonBorder border(optional androidx.tv.material3.Border border, optional androidx.tv.material3.Border focusedBorder, optional androidx.tv.material3.Border pressedBorder, optional androidx.tv.material3.Border disabledBorder, optional androidx.tv.material3.Border focusedDisabledBorder);
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.ReadOnlyComposable public androidx.tv.material3.ButtonColors colors(optional long containerColor, optional long contentColor, optional long focusedContainerColor, optional long focusedContentColor, optional long pressedContainerColor, optional long pressedContentColor, optional long disabledContainerColor, optional long disabledContentColor);
+ method public androidx.compose.foundation.layout.PaddingValues getButtonWithIconContentPadding();
+ method public androidx.compose.foundation.layout.PaddingValues getContentPadding();
+ method public float getIconSize();
+ method public float getIconSpacing();
+ method public androidx.tv.material3.ButtonGlow glow(optional androidx.tv.material3.Glow glow, optional androidx.tv.material3.Glow focusedGlow, optional androidx.tv.material3.Glow pressedGlow);
+ method public androidx.tv.material3.ButtonScale scale(optional @FloatRange(from=0.0) float scale, optional @FloatRange(from=0.0) float focusedScale, optional @FloatRange(from=0.0) float pressedScale, optional @FloatRange(from=0.0) float disabledScale, optional @FloatRange(from=0.0) float focusedDisabledScale);
+ method public androidx.tv.material3.ButtonShape shape(optional androidx.compose.ui.graphics.Shape shape, optional androidx.compose.ui.graphics.Shape focusedShape, optional androidx.compose.ui.graphics.Shape pressedShape, optional androidx.compose.ui.graphics.Shape disabledShape, optional androidx.compose.ui.graphics.Shape focusedDisabledShape);
+ property public final androidx.compose.foundation.layout.PaddingValues ButtonWithIconContentPadding;
+ property public final androidx.compose.foundation.layout.PaddingValues ContentPadding;
+ property public final float IconSize;
+ property public final float IconSpacing;
+ field public static final androidx.tv.material3.OutlinedButtonDefaults INSTANCE;
+ }
+
@androidx.compose.runtime.Stable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class ScaleIndication implements androidx.compose.foundation.Indication {
ctor public ScaleIndication(float scale);
method @androidx.compose.runtime.Composable public androidx.compose.foundation.IndicationInstance rememberUpdatedInstance(androidx.compose.foundation.interaction.InteractionSource interactionSource);
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/ButtonScreenshotTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/ButtonScreenshotTest.kt
new file mode 100644
index 0000000..35873cd
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/ButtonScreenshotTest.kt
@@ -0,0 +1,279 @@
+/*
+ * 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.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onNodeWithText
+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 ButtonScreenshotTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(TV_GOLDEN_MATERIAL3)
+
+ @Test
+ fun default_button_light_theme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Button(onClick = { }) {
+ Text("Button")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_light_theme")
+ }
+
+ @Test
+ fun default_button_dark_theme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Button(onClick = { }) {
+ Text("Button")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_dark_theme")
+ }
+
+ @Test
+ fun disabled_button_light_theme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Button(onClick = { }, enabled = false) {
+ Text("Button")
+ }
+ }
+ }
+
+ rule.onNodeWithText("Button")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_disabled_light_theme")
+ }
+
+ @Test
+ fun disabled_button_dark_theme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Button(onClick = { }, enabled = false) {
+ Text("Button")
+ }
+ }
+ }
+
+ rule.onNodeWithText("Button")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_disabled_dark_theme")
+ }
+
+ @Test
+ fun outlined_button_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ OutlinedButton(onClick = {}) {
+ Text("Outlined Button")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "outlined_button_light_theme")
+ }
+
+ @Test
+ fun outlined_button_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ OutlinedButton(onClick = {}) {
+ Text("Outlined Button")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "outlined_button_dark_theme")
+ }
+
+ @Test
+ fun disabled_outlined_button_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ OutlinedButton(
+ onClick = {},
+ enabled = false,
+ modifier = Modifier.testTag("button")
+ ) {
+ Text("Outlined Button")
+ }
+ }
+ }
+
+ rule.onNodeWithTag("button")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "outlined_button_disabled_light_theme")
+ }
+
+ @Test
+ fun disabled_outlined_button_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ OutlinedButton(
+ onClick = {},
+ enabled = false,
+ modifier = Modifier.testTag("button")
+ ) {
+ Text("Outlined Button")
+ }
+ }
+ }
+
+ rule.onNodeWithTag("button")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "outlined_button_disabled_dark_theme")
+ }
+
+ @Test
+ fun button_withIcon_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Button(
+ onClick = { /* Do something! */ },
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding
+ ) {
+ Icon(
+ Icons.Filled.Favorite,
+ contentDescription = "Localized description",
+ modifier = Modifier.size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text("Like")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withIcon_lightTheme")
+ }
+
+ @Test
+ fun button_withIcon_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Button(
+ onClick = { /* Do something! */ },
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding
+ ) {
+ Icon(
+ Icons.Filled.Favorite,
+ contentDescription = "Localized description",
+ modifier = Modifier.size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text("Like")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withIcon_darkTheme")
+ }
+
+ @Test
+ fun disabled_button_withIcon_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ Button(
+ onClick = { /* Do something! */ },
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
+ enabled = false,
+ modifier = Modifier.testTag("button")
+ ) {
+ Icon(
+ Icons.Filled.Favorite,
+ contentDescription = "Localized description",
+ modifier = Modifier.size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text("Like")
+ }
+ }
+ }
+
+ rule.onNodeWithTag("button")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withIcon_disabled_lightTheme")
+ }
+
+ @Test
+ fun disabled_button_withIcon_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ Button(
+ onClick = { /* Do something! */ },
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
+ enabled = false,
+ modifier = Modifier.testTag("button")
+ ) {
+ Icon(
+ Icons.Filled.Favorite,
+ contentDescription = "Localized description",
+ modifier = Modifier.size(ButtonDefaults.IconSize)
+ )
+ Spacer(Modifier.size(ButtonDefaults.IconSpacing))
+ Text("Like")
+ }
+ }
+ }
+
+ rule.onNodeWithTag("button")
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "button_withIcon_disabled_darkTheme")
+ }
+}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/ButtonTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/ButtonTest.kt
new file mode 100644
index 0000000..70ba16f
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/ButtonTest.kt
@@ -0,0 +1,480 @@
+/*
+ * 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.Column
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+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.Role
+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.assertIsEqualTo
+import androidx.compose.ui.test.assertIsNotEnabled
+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
+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.LargeTest
+import com.google.common.truth.Truth
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@OptIn(
+ ExperimentalTestApi::class,
+ ExperimentalComposeUiApi::class,
+ ExperimentalTvMaterial3Api::class
+)
+@RunWith(AndroidJUnit4::class)
+class ButtonTest {
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun filledButton_defaultSemantics() {
+ rule.setContent {
+ Box {
+ Button(modifier = Modifier.testTag(FilledButtonTag), onClick = {}) {
+ Text("FilledButton")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(FilledButtonTag)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+ .assertIsEnabled()
+ }
+
+ @Test
+ fun filledButton_disabledSemantics() {
+ rule.setContent {
+ Box {
+ Button(
+ modifier = Modifier.testTag(FilledButtonTag),
+ onClick = {},
+ enabled = false
+ ) {
+ Text("FilledButton")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(FilledButtonTag)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+ .assertIsNotEnabled()
+ }
+
+ @Test
+ fun filledButton_findByTag_andClick() {
+ var counter = 0
+ val onClick: () -> Unit = { ++counter }
+ val text = "FilledButtonText"
+
+ rule.setContent {
+ Box {
+ Button(modifier = Modifier.testTag(FilledButtonTag), onClick = onClick) {
+ Text(text)
+ }
+ }
+ }
+ rule.onNodeWithTag(FilledButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ rule.runOnIdle {
+ Truth.assertThat(counter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun filledButton_canBeDisabled() {
+ rule.setContent {
+ var enabled by remember { mutableStateOf(true) }
+ Box {
+ Button(
+ modifier = Modifier.testTag(FilledButtonTag),
+ onClick = { enabled = false },
+ enabled = enabled
+ ) {
+ Text("Hello")
+ }
+ }
+ }
+ rule.onNodeWithTag(FilledButtonTag)
+ // Confirm the button starts off enabled, with a click action
+ .assertHasClickAction()
+ .assertIsEnabled()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ // Then confirm it's disabled with click action after clicking it
+ .assertHasClickAction()
+ .assertIsNotEnabled()
+ }
+
+ @Test
+ fun filledButton_clickIs_independent_betweenButtons() {
+ var watchButtonCounter = 0
+ val watchButtonOnClick: () -> Unit = { ++watchButtonCounter }
+ val watchButtonTag = "WatchButton"
+
+ var playButtonCounter = 0
+ val playButtonOnClick: () -> Unit = { ++playButtonCounter }
+ val playButtonTag = "PlayButton"
+
+ rule.setContent {
+ Column {
+ Button(
+ modifier = Modifier.testTag(watchButtonTag),
+ onClick = watchButtonOnClick
+ ) {
+ Text("Watch")
+ }
+ Button(
+ modifier = Modifier.testTag(playButtonTag),
+ onClick = playButtonOnClick
+ ) {
+ Text("Play")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(watchButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+
+ rule.runOnIdle {
+ Truth.assertThat(watchButtonCounter).isEqualTo(1)
+ Truth.assertThat(playButtonCounter).isEqualTo(0)
+ }
+
+ rule.onNodeWithTag(playButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+
+ rule.runOnIdle {
+ Truth.assertThat(watchButtonCounter).isEqualTo(1)
+ Truth.assertThat(playButtonCounter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun filledButton_buttonPositioning() {
+ rule.setContent {
+ Button(
+ onClick = {},
+ modifier = Modifier.testTag(FilledButtonTag)
+ ) {
+ Text(
+ "FilledButton",
+ modifier = Modifier
+ .testTag(FilledButtonTextTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ }
+ }
+
+ val buttonBounds = rule.onNodeWithTag(FilledButtonTag).getUnclippedBoundsInRoot()
+ val textBounds = rule.onNodeWithTag(FilledButtonTextTag).getUnclippedBoundsInRoot()
+
+ (textBounds.left - buttonBounds.left).assertIsEqualTo(
+ 16.dp,
+ "padding between the start of the button and the start of the text."
+ )
+
+ (buttonBounds.right - textBounds.right).assertIsEqualTo(
+ 16.dp,
+ "padding between the end of the text and the end of the button."
+ )
+ }
+
+ @Test
+ fun filledButtonWithIcon_positioning() {
+ rule.setContent {
+ Button(
+ onClick = {},
+ contentPadding = ButtonDefaults.ButtonWithIconContentPadding,
+ modifier = Modifier
+ .testTag(FilledButtonTag)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(FilledButtonIconSize)
+ .testTag(FilledButtonIconTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ Spacer(Modifier.size(FilledButtonIconSpacing))
+ Text(
+ "Liked it",
+ modifier = Modifier
+ .testTag(FilledButtonTextTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ }
+ }
+
+ val textBounds = rule.onNodeWithTag(FilledButtonTextTag).getUnclippedBoundsInRoot()
+ val iconBounds = rule.onNodeWithTag(FilledButtonIconTag).getUnclippedBoundsInRoot()
+ val buttonBounds = rule.onNodeWithTag(FilledButtonTag).getUnclippedBoundsInRoot()
+
+ (iconBounds.left - buttonBounds.left).assertIsEqualTo(
+ expected = 12.dp,
+ subject = "Padding between start of button and start of icon."
+ )
+
+ (textBounds.left - iconBounds.right).assertIsEqualTo(
+ expected = FilledButtonIconSpacing,
+ subject = "Padding between end of icon and start of text."
+ )
+
+ (buttonBounds.right - textBounds.right).assertIsEqualTo(
+ expected = 16.dp,
+ subject = "padding between end of text and end of button."
+ )
+ }
+
+ @Test
+ fun outlinedButton_defaultSemantics() {
+ rule.setContent {
+ Box {
+ OutlinedButton(modifier = Modifier.testTag(OutlinedButtonTag), onClick = {}) {
+ Text("OutlinedButton")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(OutlinedButtonTag)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+ .assertIsEnabled()
+ }
+
+ @Test
+ fun outlinedButton_disabledSemantics() {
+ rule.setContent {
+ Box {
+ OutlinedButton(
+ modifier = Modifier.testTag(OutlinedButtonTag),
+ onClick = {},
+ enabled = false
+ ) {
+ Text("OutlinedButton")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(OutlinedButtonTag)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+ .assertIsNotEnabled()
+ }
+
+ @Test
+ fun outlinedButton_findByTag_andClick() {
+ var counter = 0
+ val onClick: () -> Unit = { ++counter }
+ val text = "OutlinedButtonText"
+
+ rule.setContent {
+ Box {
+ OutlinedButton(modifier = Modifier.testTag(OutlinedButtonTag), onClick = onClick) {
+ Text(text)
+ }
+ }
+ }
+ rule.onNodeWithTag(OutlinedButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ rule.runOnIdle {
+ Truth.assertThat(counter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun outlinedButton_canBeDisabled() {
+ rule.setContent {
+ var enabled by remember { mutableStateOf(true) }
+ Box {
+ OutlinedButton(
+ modifier = Modifier.testTag(OutlinedButtonTag),
+ onClick = { enabled = false },
+ enabled = enabled
+ ) {
+ Text("Hello")
+ }
+ }
+ }
+ rule.onNodeWithTag(OutlinedButtonTag)
+ // Confirm the button starts off enabled, with a click action
+ .assertHasClickAction()
+ .assertIsEnabled()
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ // Then confirm it's disabled with click action after clicking it
+ .assertHasClickAction()
+ .assertIsNotEnabled()
+ }
+
+ @Test
+ fun outlinedButton_clickIs_independent_betweenButtons() {
+ var watchButtonCounter = 0
+ val watchButtonOnClick: () -> Unit = { ++watchButtonCounter }
+ val watchButtonTag = "WatchButton"
+
+ var playButtonCounter = 0
+ val playButtonOnClick: () -> Unit = { ++playButtonCounter }
+ val playButtonTag = "PlayButton"
+
+ rule.setContent {
+ Column {
+ OutlinedButton(
+ modifier = Modifier.testTag(watchButtonTag),
+ onClick = watchButtonOnClick
+ ) {
+ Text("Watch")
+ }
+ OutlinedButton(
+ modifier = Modifier.testTag(playButtonTag),
+ onClick = playButtonOnClick
+ ) {
+ Text("Play")
+ }
+ }
+ }
+
+ rule.onNodeWithTag(watchButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+
+ rule.runOnIdle {
+ Truth.assertThat(watchButtonCounter).isEqualTo(1)
+ Truth.assertThat(playButtonCounter).isEqualTo(0)
+ }
+
+ rule.onNodeWithTag(playButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+
+ rule.runOnIdle {
+ Truth.assertThat(watchButtonCounter).isEqualTo(1)
+ Truth.assertThat(playButtonCounter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun outlinedButton_buttonPositioning() {
+ rule.setContent {
+ OutlinedButton(
+ onClick = {},
+ modifier = Modifier.testTag(OutlinedButtonTag)
+ ) {
+ Text(
+ "OutlinedButton",
+ modifier = Modifier
+ .testTag(OutlinedButtonTextTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ }
+ }
+
+ val buttonBounds = rule.onNodeWithTag(OutlinedButtonTag).getUnclippedBoundsInRoot()
+ val textBounds = rule.onNodeWithTag(OutlinedButtonTextTag).getUnclippedBoundsInRoot()
+
+ (textBounds.left - buttonBounds.left).assertIsEqualTo(
+ 16.dp,
+ "padding between the start of the button and the start of the text."
+ )
+
+ (buttonBounds.right - textBounds.right).assertIsEqualTo(
+ 16.dp,
+ "padding between the end of the text and the end of the button."
+ )
+ }
+
+ @Test
+ fun outlinedButton_buttonWithIcon_positioning() {
+ rule.setContent {
+ OutlinedButton(
+ onClick = {},
+ contentPadding = OutlinedButtonDefaults.ButtonWithIconContentPadding,
+ modifier = Modifier
+ .testTag(OutlinedButtonTag)
+ ) {
+ Box(
+ modifier = Modifier
+ .size(OutlinedButtonIconSize)
+ .testTag(OutlinedButtonIconTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ Spacer(Modifier.size(OutlinedButtonIconSpacing))
+ Text(
+ "Liked it",
+ modifier = Modifier
+ .testTag(OutlinedButtonTextTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ }
+ }
+
+ val textBounds = rule.onNodeWithTag(OutlinedButtonTextTag).getUnclippedBoundsInRoot()
+ val iconBounds = rule.onNodeWithTag(OutlinedButtonIconTag).getUnclippedBoundsInRoot()
+ val buttonBounds = rule.onNodeWithTag(OutlinedButtonTag).getUnclippedBoundsInRoot()
+
+ (iconBounds.left - buttonBounds.left).assertIsEqualTo(
+ expected = 12.dp,
+ subject = "Padding between start of button and start of icon."
+ )
+
+ (textBounds.left - iconBounds.right).assertIsEqualTo(
+ expected = OutlinedButtonIconSpacing,
+ subject = "Padding between end of icon and start of text."
+ )
+
+ (buttonBounds.right - textBounds.right).assertIsEqualTo(
+ expected = 16.dp,
+ subject = "padding between end of text and end of button."
+ )
+ }
+}
+
+private const val FilledButtonTag = "FilledButton"
+private const val FilledButtonTextTag = "FilledButtonText"
+private const val FilledButtonIconTag = "FilledButtonIcon"
+private val FilledButtonIconSize = 18.0.dp
+private val FilledButtonIconSpacing = 8.dp
+
+private const val OutlinedButtonTag = "OutlinedButton"
+private const val OutlinedButtonTextTag = "OutlinedButtonText"
+private const val OutlinedButtonIconTag = "OutlinedButtonIcon"
+private val OutlinedButtonIconSize = 18.0.dp
+private val OutlinedButtonIconSpacing = 8.dp
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/MaterialThemeCommon.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/MaterialThemeCommon.kt
new file mode 100644
index 0000000..9267ce5
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/MaterialThemeCommon.kt
@@ -0,0 +1,35 @@
+/*
+ * 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.runtime.Composable
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun LightMaterialTheme(content: @Composable () -> Unit) {
+ MaterialTheme(lightColorScheme()) {
+ content()
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+fun DarkMaterialTheme(content: @Composable () -> Unit) {
+ MaterialTheme(darkColorScheme()) {
+ content()
+ }
+}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/Button.kt b/tv/tv-material/src/main/java/androidx/tv/material3/Button.kt
new file mode 100644
index 0000000..cf59b79
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/Button.kt
@@ -0,0 +1,265 @@
+/*
+ * 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.layout.Arrangement
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.semantics.Role
+import androidx.compose.ui.semantics.role
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.unit.Dp
+import androidx.tv.material3.tokens.Elevation
+
+/**
+ * Material Design filled button for TV.
+ *
+ * Filled buttons are for high emphasis (important, final actions that complete a flow).
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ *
+ * - See [Button] for high emphasis (important, final actions that complete a flow).
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param scale Defines size of the Button relative to its original size.
+ * @param glow Shadow to be shown behind the Button.
+ * @param shape Defines the Button's shape.
+ * @param colors Color to be used for background and content of the Button
+ * @param tonalElevation tonal elevation used to apply a color shift to the button to give the it
+ * higher emphasis
+ * @param border Defines a border around the Button.
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this button. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this button in different states.
+ * @param content the content of the button
+ */
+@ExperimentalTvMaterial3Api
+@NonRestartableComposable
+@Composable
+fun Button(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ scale: ButtonScale = ButtonDefaults.scale(),
+ glow: ButtonGlow = ButtonDefaults.glow(),
+ shape: ButtonShape = ButtonDefaults.shape(),
+ colors: ButtonColors = ButtonDefaults.colors(),
+ tonalElevation: Dp = Elevation.Level0,
+ border: ButtonBorder = ButtonDefaults.border(),
+ contentPadding: PaddingValues = ButtonDefaults.ContentPadding,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ content: @Composable RowScope.() -> Unit
+) {
+ ButtonImpl(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ scale = scale,
+ glow = glow,
+ shape = shape,
+ colors = colors,
+ tonalElevation = tonalElevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ content = content
+ )
+}
+
+/**
+ * Material Design outlined button for TV.
+ *
+ * Outlined buttons are medium-emphasis buttons. They contain actions that are important, but are
+ * not the primary action in an app. Outlined buttons pair well with [Button]s to indicate an
+ * alternative, secondary action.
+ *
+ * Choose the best button for an action based on the amount of emphasis it needs. The more important
+ * an action is, the higher emphasis its button should be.
+ *
+ * - See [Button] for high emphasis (important, final actions that complete a flow).
+ * - See [OutlinedButton] for a medium-emphasis button with a border.
+ *
+ * The default text style for internal [Text] components will be set to [Typography.labelLarge].
+ *
+ * @param onClick called when this button is clicked
+ * @param modifier the [Modifier] to be applied to this button
+ * @param enabled controls the enabled state of this button. When `false`, this component will not
+ * respond to user input, and it will appear visually disabled and disabled to accessibility
+ * services.
+ * @param scale Defines size of the Button relative to its original size.
+ * @param glow Shadow to be shown behind the Button.
+ * @param shape Defines the Button's shape.
+ * @param colors Color to be used for background and content of the Button
+ * @param tonalElevation tonal elevation used to apply a color shift to the button to give the it
+ * higher emphasis
+ * @param border Defines a border around the Button.
+ * @param contentPadding the spacing values to apply internally between the container and the
+ * content
+ * @param interactionSource the [MutableInteractionSource] representing the stream of [Interaction]s
+ * for this button. You can create and pass in your own `remember`ed instance to observe
+ * [Interaction]s and customize the appearance / behavior of this button in different states.
+ * @param content the content of the button
+ */
+@ExperimentalTvMaterial3Api
+@NonRestartableComposable
+@Composable
+fun OutlinedButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ scale: ButtonScale = OutlinedButtonDefaults.scale(),
+ glow: ButtonGlow = OutlinedButtonDefaults.glow(),
+ shape: ButtonShape = OutlinedButtonDefaults.shape(),
+ colors: ButtonColors = OutlinedButtonDefaults.colors(),
+ tonalElevation: Dp = Elevation.Level0,
+ border: ButtonBorder = OutlinedButtonDefaults.border(),
+ contentPadding: PaddingValues = OutlinedButtonDefaults.ContentPadding,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ content: @Composable RowScope.() -> Unit
+) {
+ ButtonImpl(
+ onClick = onClick,
+ modifier = modifier,
+ enabled = enabled,
+ scale = scale,
+ glow = glow,
+ shape = shape,
+ colors = colors,
+ tonalElevation = tonalElevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ content = content
+ )
+}
+
+@ExperimentalTvMaterial3Api
+@Composable
+private fun ButtonImpl(
+ onClick: () -> Unit,
+ modifier: Modifier,
+ enabled: Boolean,
+ scale: ButtonScale,
+ glow: ButtonGlow,
+ shape: ButtonShape,
+ colors: ButtonColors,
+ tonalElevation: Dp,
+ border: ButtonBorder,
+ contentPadding: PaddingValues,
+ interactionSource: MutableInteractionSource,
+ content: @Composable RowScope.() -> Unit
+) {
+ Surface(
+ modifier = modifier.semantics { role = Role.Button },
+ onClick = onClick,
+ enabled = enabled,
+ scale = scale.toClickableSurfaceScale(),
+ glow = glow.toClickableSurfaceGlow(),
+ shape = shape.toClickableSurfaceShape(),
+ color = colors.toClickableSurfaceContainerColor(),
+ contentColor = colors.toClickableSurfaceContentColor(),
+ tonalElevation = tonalElevation,
+ border = border.toClickableSurfaceBorder(),
+ interactionSource = interactionSource
+ ) {
+ ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
+ Row(
+ modifier = Modifier
+ .defaultMinSize(
+ minWidth = BaseButtonDefaults.MinWidth,
+ minHeight = BaseButtonDefaults.MinHeight
+ )
+ .padding(contentPadding),
+ horizontalArrangement = Arrangement.Center,
+ verticalAlignment = Alignment.CenterVertically,
+ content = content
+ )
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+private fun ButtonShape.toClickableSurfaceShape(): ClickableSurfaceShape = ClickableSurfaceShape(
+ shape = shape,
+ focusedShape = focusedShape,
+ pressedShape = pressedShape,
+ disabledShape = disabledShape,
+ focusedDisabledShape = focusedDisabledShape
+)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+private fun ButtonColors.toClickableSurfaceContainerColor(): ClickableSurfaceColor =
+ ClickableSurfaceColor(
+ color = containerColor,
+ focusedColor = focusedContainerColor,
+ pressedColor = pressedContainerColor,
+ disabledColor = disabledContainerColor,
+ )
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+private fun ButtonColors.toClickableSurfaceContentColor(): ClickableSurfaceColor =
+ ClickableSurfaceColor(
+ color = contentColor,
+ focusedColor = focusedContentColor,
+ pressedColor = pressedContentColor,
+ disabledColor = disabledContentColor,
+ )
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+private fun ButtonScale.toClickableSurfaceScale() = ClickableSurfaceScale(
+ scale = scale,
+ focusedScale = focusedScale,
+ pressedScale = pressedScale,
+ disabledScale = disabledScale,
+ focusedDisabledScale = focusedDisabledScale
+)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+private fun ButtonBorder.toClickableSurfaceBorder() = ClickableSurfaceBorder(
+ border = border,
+ focusedBorder = focusedBorder,
+ pressedBorder = pressedBorder,
+ disabledBorder = disabledBorder,
+ focusedDisabledBorder = focusedDisabledBorder
+)
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+private fun ButtonGlow.toClickableSurfaceGlow() = ClickableSurfaceGlow(
+ glow = glow,
+ focusedGlow = focusedGlow,
+ pressedGlow = pressedGlow
+)
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/ButtonDefaults.kt b/tv/tv-material/src/main/java/androidx/tv/material3/ButtonDefaults.kt
new file mode 100644
index 0000000..e147851
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/ButtonDefaults.kt
@@ -0,0 +1,381 @@
+/*
+ * 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.annotation.FloatRange
+import androidx.compose.foundation.BorderStroke
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.dp
+
+internal object BaseButtonDefaults {
+ val MinWidth = 58.dp
+ val MinHeight = 40.dp
+}
+
+@ExperimentalTvMaterial3Api
+object ButtonDefaults {
+ private val ContainerShape = CircleShape
+ private val ButtonHorizontalPadding = 16.dp
+ private val ButtonVerticalPadding = 10.dp
+ private val ButtonWithIconHorizontalStartPadding = 12.dp
+
+ val ContentPadding = PaddingValues(
+ start = ButtonHorizontalPadding,
+ top = ButtonVerticalPadding,
+ end = ButtonHorizontalPadding,
+ bottom = ButtonVerticalPadding
+ )
+
+ val ButtonWithIconContentPadding = PaddingValues(
+ start = ButtonWithIconHorizontalStartPadding,
+ top = ButtonVerticalPadding,
+ end = ButtonHorizontalPadding,
+ bottom = ButtonVerticalPadding
+ )
+
+ /** The default size of the icon when used inside any button. */
+ val IconSize = 18.dp
+
+ /**
+ * The default size of the spacing between an icon and a text when they used inside any button.
+ */
+ val IconSpacing = 8.dp
+
+ /**
+ * Creates a [ButtonShape] that represents the default container shapes used in a FilledButton.
+ *
+ * @param shape the shape used when the Button is enabled, and has no other [Interaction]s.
+ * @param focusedShape the shape used when the Button is enabled and focused.
+ * @param pressedShape the shape used when the Button is enabled pressed.
+ * @param disabledShape the shape used when the Button is not enabled.
+ * @param focusedDisabledShape the shape used when the Button is not enabled and focused.
+ */
+ fun shape(
+ shape: Shape = ContainerShape,
+ focusedShape: Shape = shape,
+ pressedShape: Shape = shape,
+ disabledShape: Shape = shape,
+ focusedDisabledShape: Shape = disabledShape
+ ) = ButtonShape(
+ shape = shape,
+ focusedShape = focusedShape,
+ pressedShape = pressedShape,
+ disabledShape = disabledShape,
+ focusedDisabledShape = focusedDisabledShape
+ )
+
+ /**
+ * Creates a [ButtonColors] that represents the default colors used in a FilledButton.
+ *
+ * @param containerColor the container color of this Button when enabled
+ * @param contentColor the content color of this Button when enabled
+ * @param focusedContainerColor the container color of this Button when enabled and focused
+ * @param focusedContentColor the content color of this Button when enabled and focused
+ * @param pressedContainerColor the container color of this Button when enabled and pressed
+ * @param pressedContentColor the content color of this Button when enabled and pressed
+ * @param disabledContainerColor the container color of this Button when not enabled
+ * @param disabledContentColor the content color of this Button when not enabled
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun colors(
+ containerColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.8f),
+ contentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ focusedContainerColor: Color = MaterialTheme.colorScheme.onSurface,
+ focusedContentColor: Color = MaterialTheme.colorScheme.inverseOnSurface,
+ pressedContainerColor: Color = focusedContainerColor,
+ pressedContentColor: Color = focusedContentColor,
+ disabledContainerColor: Color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f),
+ disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
+ ) = ButtonColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ focusedContainerColor = focusedContainerColor,
+ focusedContentColor = focusedContentColor,
+ pressedContainerColor = pressedContainerColor,
+ pressedContentColor = pressedContentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
+ )
+
+ /**
+ * Creates a [ButtonScale] that represents the default scales used in a FilledButton.
+ * scales are used to modify the size of a composable in different [Interaction]
+ * states e.g. 1f (original) in default state, 1.2f (scaled up) in focused state,
+ * 0.8f (scaled down) in pressed state, etc.
+ *
+ * @param scale the scale to be used for this Button when enabled
+ * @param focusedScale the scale to be used for this Button when focused
+ * @param pressedScale the scale to be used for this Button when pressed
+ * @param disabledScale the scale to be used for this Button when disabled
+ * @param focusedDisabledScale the scale to be used for this Button when disabled and
+ * focused
+ */
+ fun scale(
+ @FloatRange(from = 0.0) scale: Float = 1f,
+ @FloatRange(from = 0.0) focusedScale: Float = 1.1f,
+ @FloatRange(from = 0.0) pressedScale: Float = scale,
+ @FloatRange(from = 0.0) disabledScale: Float = scale,
+ @FloatRange(from = 0.0) focusedDisabledScale: Float = disabledScale
+ ) = ButtonScale(
+ scale = scale,
+ focusedScale = focusedScale,
+ pressedScale = pressedScale,
+ disabledScale = disabledScale,
+ focusedDisabledScale = focusedDisabledScale
+ )
+
+ /**
+ * Creates a [ButtonBorder] that represents the default [Border]s applied on a
+ * FilledButton in different [Interaction] states.
+ *
+ * @param border the [Border] to be used for this Button when enabled
+ * @param focusedBorder the [Border] to be used for this Button when focused
+ * @param pressedBorder the [Border] to be used for this Button when pressed
+ * @param disabledBorder the [Border] to be used for this Button when disabled
+ * @param focusedDisabledBorder the [Border] to be used for this Button when disabled and
+ * focused
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun border(
+ border: Border = Border.None,
+ focusedBorder: Border = border,
+ pressedBorder: Border = focusedBorder,
+ disabledBorder: Border = border,
+ focusedDisabledBorder: Border = Border(
+ border = BorderStroke(
+ width = 1.5.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)
+ ),
+ shape = ContainerShape
+ )
+ ) = ButtonBorder(
+ border = border,
+ focusedBorder = focusedBorder,
+ pressedBorder = pressedBorder,
+ disabledBorder = disabledBorder,
+ focusedDisabledBorder = focusedDisabledBorder
+ )
+
+ /**
+ * Creates a [ButtonGlow] that represents the default [Glow]s used in a FilledButton.
+ *
+ * @param glow the Glow behind this Button when enabled
+ * @param focusedGlow the Glow behind this Button when focused
+ * @param pressedGlow the Glow behind this Button when pressed
+ */
+ fun glow(
+ glow: Glow = Glow.None,
+ focusedGlow: Glow = glow,
+ pressedGlow: Glow = glow
+ ) = ButtonGlow(
+ glow = glow,
+ focusedGlow = focusedGlow,
+ pressedGlow = pressedGlow
+ )
+}
+
+@ExperimentalTvMaterial3Api
+object OutlinedButtonDefaults {
+ private val ContainerShape = CircleShape
+ private val ButtonHorizontalPadding = 16.dp
+ private val ButtonVerticalPadding = 10.dp
+ private val ButtonWithIconHorizontalStartPadding = 12.dp
+
+ val ContentPadding = PaddingValues(
+ start = ButtonHorizontalPadding,
+ top = ButtonVerticalPadding,
+ end = ButtonHorizontalPadding,
+ bottom = ButtonVerticalPadding
+ )
+
+ /** The default size of the icon when used inside any button. */
+ val IconSize = 18.dp
+
+ /**
+ * The default size of the spacing between an icon and a text when they used inside any button.
+ */
+ val IconSpacing = 8.dp
+
+ /** The default content padding used by [OutlinedButton] that contains an [Icon]. */
+ val ButtonWithIconContentPadding = PaddingValues(
+ start = ButtonWithIconHorizontalStartPadding,
+ top = ButtonVerticalPadding,
+ end = ButtonHorizontalPadding,
+ bottom = ButtonVerticalPadding
+ )
+
+ /**
+ * Creates a [ButtonShape] that represents the default container shapes used in an
+ * OutlinedButton.
+ *
+ * @param shape the shape used when the Button is enabled, and has no other [Interaction]s.
+ * @param focusedShape the shape used when the Button is enabled and focused.
+ * @param pressedShape the shape used when the Button is enabled pressed.
+ * @param disabledShape the shape used when the Button is not enabled.
+ * @param focusedDisabledShape the shape used when the Button is not enabled and focused.
+ */
+ fun shape(
+ shape: Shape = ContainerShape,
+ focusedShape: Shape = shape,
+ pressedShape: Shape = shape,
+ disabledShape: Shape = shape,
+ focusedDisabledShape: Shape = disabledShape
+ ) = ButtonShape(
+ shape = shape,
+ focusedShape = focusedShape,
+ pressedShape = pressedShape,
+ disabledShape = disabledShape,
+ focusedDisabledShape = focusedDisabledShape
+ )
+
+ /**
+ * Creates a [ButtonColors] that represents the default colors used in a OutlinedButton.
+ *
+ * @param containerColor the container color of this Button when enabled
+ * @param contentColor the content color of this Button when enabled
+ * @param focusedContainerColor the container color of this Button when enabled and focused
+ * @param focusedContentColor the content color of this Button when enabled and focused
+ * @param pressedContainerColor the container color of this Button when enabled and pressed
+ * @param pressedContentColor the content color of this Button when enabled and pressed
+ * @param disabledContainerColor the container color of this Button when not enabled
+ * @param disabledContentColor the content color of this Button when not enabled
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun colors(
+ containerColor: Color = Color.Transparent,
+ contentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.8f),
+ focusedContainerColor: Color = MaterialTheme.colorScheme.onSurface,
+ focusedContentColor: Color = MaterialTheme.colorScheme.inverseOnSurface,
+ pressedContainerColor: Color = focusedContainerColor,
+ pressedContentColor: Color = focusedContentColor,
+ disabledContainerColor: Color = containerColor,
+ disabledContentColor: Color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.4f),
+ ) = ButtonColors(
+ containerColor = containerColor,
+ contentColor = contentColor,
+ focusedContainerColor = focusedContainerColor,
+ focusedContentColor = focusedContentColor,
+ pressedContainerColor = pressedContainerColor,
+ pressedContentColor = pressedContentColor,
+ disabledContainerColor = disabledContainerColor,
+ disabledContentColor = disabledContentColor,
+ )
+
+ /**
+ * Creates a [ButtonScale] that represents the default scales used in an OutlinedButton.
+ * scales are used to modify the size of a composable in different [Interaction]
+ * states e.g. 1f (original) in default state, 1.2f (scaled up) in focused state,
+ * 0.8f (scaled down) in pressed state, etc.
+ *
+ * @param scale the scale to be used for this Button when enabled
+ * @param focusedScale the scale to be used for this Button when focused
+ * @param pressedScale the scale to be used for this Button when pressed
+ * @param disabledScale the scale to be used for this Button when disabled
+ * @param focusedDisabledScale the scale to be used for this Button when disabled and
+ * focused
+ */
+ fun scale(
+ @FloatRange(from = 0.0) scale: Float = 1f,
+ @FloatRange(from = 0.0) focusedScale: Float = 1.1f,
+ @FloatRange(from = 0.0) pressedScale: Float = scale,
+ @FloatRange(from = 0.0) disabledScale: Float = scale,
+ @FloatRange(from = 0.0) focusedDisabledScale: Float = disabledScale
+ ) = ButtonScale(
+ scale = scale,
+ focusedScale = focusedScale,
+ pressedScale = pressedScale,
+ disabledScale = disabledScale,
+ focusedDisabledScale = focusedDisabledScale
+ )
+
+ /**
+ * Creates a [ButtonBorder] that represents the default [Border]s applied on an
+ * OutlinedButton in different [Interaction] states.
+ *
+ * @param border the [Border] to be used for this Button when enabled
+ * @param focusedBorder the [Border] to be used for this Button when focused
+ * @param pressedBorder the [Border] to be used for this Button when pressed
+ * @param disabledBorder the [Border] to be used for this Button when disabled
+ * @param focusedDisabledBorder the [Border] to be used for this Button when disabled and
+ * focused
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun border(
+ border: Border = Border(
+ border = BorderStroke(
+ width = 1.5.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.4f)
+ ),
+ shape = ContainerShape
+ ),
+ focusedBorder: Border = Border(
+ border = BorderStroke(
+ width = 1.65.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = ContainerShape
+ ),
+ pressedBorder: Border = Border(
+ border = BorderStroke(
+ width = 1.5.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ ),
+ shape = ContainerShape
+ ),
+ disabledBorder: Border = Border(
+ border = BorderStroke(
+ width = 1.5.dp,
+ color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f)
+ ),
+ shape = ContainerShape
+ ),
+ focusedDisabledBorder: Border = disabledBorder
+ ) = ButtonBorder(
+ border = border,
+ focusedBorder = focusedBorder,
+ pressedBorder = pressedBorder,
+ disabledBorder = disabledBorder,
+ focusedDisabledBorder = focusedDisabledBorder
+ )
+
+ /**
+ * Creates a [ButtonGlow] that represents the default [Glow]s used in an OutlinedButton.
+ *
+ * @param glow the Glow behind this Button when enabled
+ * @param focusedGlow the Glow behind this Button when focused
+ * @param pressedGlow the Glow behind this Button when pressed
+ */
+ fun glow(
+ glow: Glow = Glow.None,
+ focusedGlow: Glow = glow,
+ pressedGlow: Glow = glow
+ ) = ButtonGlow(
+ glow = glow,
+ focusedGlow = focusedGlow,
+ pressedGlow = pressedGlow
+ )
+}
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/ButtonStyles.kt b/tv/tv-material/src/main/java/androidx/tv/material3/ButtonStyles.kt
new file mode 100644
index 0000000..89fe0a7
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/ButtonStyles.kt
@@ -0,0 +1,245 @@
+/*
+ * 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.annotation.FloatRange
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.runtime.Immutable
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+
+/**
+ * Defines [Shape] for all TV [Interaction] states of Button.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class ButtonShape internal constructor(
+ internal val shape: Shape,
+ internal val focusedShape: Shape,
+ internal val pressedShape: Shape,
+ internal val disabledShape: Shape,
+ internal val focusedDisabledShape: Shape
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as ButtonShape
+
+ if (shape != other.shape) return false
+ if (focusedShape != other.focusedShape) return false
+ if (pressedShape != other.pressedShape) return false
+ if (disabledShape != other.disabledShape) return false
+ if (focusedDisabledShape != other.focusedDisabledShape) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = shape.hashCode()
+ result = 31 * result + focusedShape.hashCode()
+ result = 31 * result + pressedShape.hashCode()
+ result = 31 * result + disabledShape.hashCode()
+ result = 31 * result + focusedDisabledShape.hashCode()
+
+ return result
+ }
+
+ override fun toString(): String {
+ return "ButtonShape(shape=$shape, focusedShape=$focusedShape, pressedShape=$pressedShape," +
+ " disabledShape=$disabledShape, focusedDisabledShape=$focusedDisabledShape)"
+ }
+}
+
+/**
+ * Defines [Color]s for all TV [Interaction] states of Button.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class ButtonColors internal constructor(
+ internal val containerColor: Color,
+ internal val contentColor: Color,
+ internal val focusedContainerColor: Color,
+ internal val focusedContentColor: Color,
+ internal val pressedContainerColor: Color,
+ internal val pressedContentColor: Color,
+ internal val disabledContainerColor: Color,
+ internal val disabledContentColor: Color,
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as ButtonColors
+
+ if (containerColor != other.containerColor) return false
+ if (contentColor != other.contentColor) return false
+ if (focusedContainerColor != other.focusedContainerColor) return false
+ if (focusedContentColor != other.focusedContentColor) return false
+ if (pressedContainerColor != other.pressedContainerColor) return false
+ if (pressedContentColor != other.pressedContentColor) return false
+ if (disabledContainerColor != other.disabledContainerColor) return false
+ if (disabledContentColor != other.disabledContentColor) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = containerColor.hashCode()
+ result = 31 * result + contentColor.hashCode()
+ result = 31 * result + focusedContainerColor.hashCode()
+ result = 31 * result + focusedContentColor.hashCode()
+ result = 31 * result + pressedContainerColor.hashCode()
+ result = 31 * result + pressedContentColor.hashCode()
+ result = 31 * result + disabledContainerColor.hashCode()
+ result = 31 * result + disabledContentColor.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "ButtonColors(containerColor=$containerColor, contentColor=$contentColor, " +
+ "focusedContainerColor=$focusedContainerColor, " +
+ "focusedContentColor=$focusedContentColor, " +
+ "pressedContainerColor=$pressedContainerColor, " +
+ "pressedContentColor=$pressedContentColor, " +
+ "disabledContainerColor=$disabledContainerColor, " +
+ "disabledContentColor=$disabledContentColor)"
+ }
+}
+
+/**
+ * Defines the scale for all TV [Interaction] states of Button.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class ButtonScale internal constructor(
+ @FloatRange(from = 0.0) internal val scale: Float,
+ @FloatRange(from = 0.0) internal val focusedScale: Float,
+ @FloatRange(from = 0.0) internal val pressedScale: Float,
+ @FloatRange(from = 0.0) internal val disabledScale: Float,
+ @FloatRange(from = 0.0) internal val focusedDisabledScale: Float
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as ButtonScale
+
+ if (scale != other.scale) return false
+ if (focusedScale != other.focusedScale) return false
+ if (pressedScale != other.pressedScale) return false
+ if (disabledScale != other.disabledScale) return false
+ if (focusedDisabledScale != other.focusedDisabledScale) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = scale.hashCode()
+ result = 31 * result + focusedScale.hashCode()
+ result = 31 * result + pressedScale.hashCode()
+ result = 31 * result + disabledScale.hashCode()
+ result = 31 * result + focusedDisabledScale.hashCode()
+
+ return result
+ }
+
+ override fun toString(): String {
+ return "ButtonScale(scale=$scale, focusedScale=$focusedScale, pressedScale=$pressedScale," +
+ " disabledScale=$disabledScale, focusedDisabledScale=$focusedDisabledScale)"
+ }
+}
+
+/**
+ * Defines [Border] for all TV [Interaction] states of Button.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class ButtonBorder internal constructor(
+ internal val border: Border,
+ internal val focusedBorder: Border,
+ internal val pressedBorder: Border,
+ internal val disabledBorder: Border,
+ internal val focusedDisabledBorder: Border
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as ButtonBorder
+
+ if (border != other.border) return false
+ if (focusedBorder != other.focusedBorder) return false
+ if (pressedBorder != other.pressedBorder) return false
+ if (disabledBorder != other.disabledBorder) return false
+ if (focusedDisabledBorder != other.focusedDisabledBorder) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = border.hashCode()
+ result = 31 * result + focusedBorder.hashCode()
+ result = 31 * result + pressedBorder.hashCode()
+ result = 31 * result + disabledBorder.hashCode()
+ result = 31 * result + focusedDisabledBorder.hashCode()
+
+ return result
+ }
+
+ override fun toString(): String {
+ return "ButtonBorder(border=$border, focusedBorder=$focusedBorder," +
+ "pressedBorder=$pressedBorder, disabledBorder=$disabledBorder, " +
+ "focusedDisabledBorder=$focusedDisabledBorder)"
+ }
+}
+
+/**
+ * Defines [Glow] for all TV [Interaction] states of Button.
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class ButtonGlow internal constructor(
+ internal val glow: Glow,
+ internal val focusedGlow: Glow,
+ internal val pressedGlow: Glow
+) {
+ override fun equals(other: Any?): Boolean {
+ if (this === other) return true
+ if (other == null || this::class != other::class) return false
+
+ other as ButtonGlow
+
+ if (glow != other.glow) return false
+ if (focusedGlow != other.focusedGlow) return false
+ if (pressedGlow != other.pressedGlow) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = glow.hashCode()
+ result = 31 * result + focusedGlow.hashCode()
+ result = 31 * result + pressedGlow.hashCode()
+
+ return result
+ }
+
+ override fun toString(): String {
+ return "ButtonGlow(glow=$glow, focusedGlow=$focusedGlow, pressedGlow=$pressedGlow)"
+ }
+}