feat: introduce wide-button composables in tv material
Test: added instrumentation and snapshot tests
Relnote: "Introduce 2 WideButton composables in 'Compose for TV' material package"
Change-Id: I4cecf3405f62f7798bcc00a48e48165c1d8a5d66
diff --git a/tv/tv-material/api/public_plus_experimental_current.txt b/tv/tv-material/api/public_plus_experimental_current.txt
index 72025d1..23e7a539 100644
--- a/tv/tv-material/api/public_plus_experimental_current.txt
+++ b/tv/tv-material/api/public_plus_experimental_current.txt
@@ -529,5 +529,23 @@
property public final androidx.compose.ui.text.TextStyle titleSmall;
}
+ @androidx.compose.runtime.Immutable @androidx.tv.material3.ExperimentalTvMaterial3Api public final class WideButtonContentColor {
+ }
+
+ @androidx.tv.material3.ExperimentalTvMaterial3Api public final class WideButtonDefaults {
+ method @androidx.compose.runtime.Composable public void Background(boolean enabled, androidx.compose.foundation.interaction.MutableInteractionSource interactionSource);
+ 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.WideButtonContentColor contentColor(optional long color, optional long focusedColor, optional long pressedColor, optional long disabledColor);
+ 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);
+ field public static final androidx.tv.material3.WideButtonDefaults INSTANCE;
+ }
+
+ public final class WideButtonKt {
+ method @androidx.compose.runtime.Composable @androidx.compose.runtime.NonRestartableComposable @androidx.tv.material3.ExperimentalTvMaterial3Api public static void WideButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional kotlin.jvm.functions.Function0<kotlin.Unit> background, optional androidx.tv.material3.ButtonScale scale, optional androidx.tv.material3.ButtonGlow glow, optional androidx.tv.material3.ButtonShape shape, optional androidx.tv.material3.WideButtonContentColor contentColor, optional float tonalElevation, optional androidx.tv.material3.ButtonBorder border, optional androidx.compose.foundation.layout.PaddingValues contentPadding, 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 WideButton(kotlin.jvm.functions.Function0<kotlin.Unit> onClick, kotlin.jvm.functions.Function0<kotlin.Unit> title, optional androidx.compose.ui.Modifier modifier, optional boolean enabled, optional kotlin.jvm.functions.Function0<kotlin.Unit>? icon, optional kotlin.jvm.functions.Function0<kotlin.Unit>? subtitle, optional androidx.compose.foundation.interaction.MutableInteractionSource interactionSource, optional kotlin.jvm.functions.Function0<kotlin.Unit> background, optional androidx.tv.material3.ButtonScale scale, optional androidx.tv.material3.ButtonGlow glow, optional androidx.tv.material3.ButtonShape shape, optional androidx.tv.material3.WideButtonContentColor contentColor, optional float tonalElevation, optional androidx.tv.material3.ButtonBorder border, optional androidx.compose.foundation.layout.PaddingValues contentPadding);
+ }
+
}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/WideButtonScreenshotTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/WideButtonScreenshotTest.kt
new file mode 100644
index 0000000..1ee53ca
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/WideButtonScreenshotTest.kt
@@ -0,0 +1,365 @@
+/*
+ * 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.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+import androidx.compose.testutils.assertAgainstGolden
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.hasClickAction
+import androidx.compose.ui.test.junit4.createComposeRule
+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 WideButtonScreenshotTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(TV_GOLDEN_MATERIAL3)
+
+ @Test
+ fun defaultWideButton_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(onClick = { }) {
+ Text("Settings")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_light_theme")
+ }
+
+ @Test
+ fun defaultWideButton_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(onClick = { }) {
+ Text("Settings")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_dark_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(onClick = { }, enabled = false) {
+ Text("Settings")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_disabled_light_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(onClick = { }, enabled = false) {
+ Text("Settings")
+ }
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_disabled_dark_theme")
+ }
+
+ @Test
+ fun wideButton_withSubtitle_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(
+ onClick = { },
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_with_subtitle_light_theme")
+ }
+
+ @Test
+ fun wideButton_withSubtitle_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(
+ onClick = { },
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_with_subtitle_dark_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_withSubtitle_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(
+ onClick = { },
+ enabled = false,
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "disabled_wide_button_with_subtitle_light_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_withSubtitle_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(
+ onClick = { },
+ enabled = false,
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "disabled_wide_button_with_subtitle_dark_theme")
+ }
+
+ @Test
+ fun wideButton_withIcon_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(
+ onClick = { },
+ title = { Text("Settings") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_with_icon_light_theme")
+ }
+
+ @Test
+ fun wideButton_withIcon_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(
+ onClick = { },
+ title = { Text("Settings") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_with_icon_dark_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_withIcon_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(
+ onClick = { },
+ enabled = false,
+ title = { Text("Settings") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "disabled_wide_button_with_icon_light_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_withIcon_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(
+ onClick = { },
+ enabled = false,
+ title = { Text("Settings") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "disabled_wide_button_with_icon_dark_theme")
+ }
+
+ @Test
+ fun wideButton_withSubtitleAndIcon_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(
+ onClick = { },
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_with_subtitle_and_icon_light_theme")
+ }
+
+ @Test
+ fun wideButton_withSubtitleAndIcon_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(
+ onClick = { },
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, "wide_button_with_subtitle_and_icon_dark_theme")
+ }
+
+ @Test
+ fun disabled_wideButton_withSubtitleAndIcon_lightTheme() {
+ rule.setContent {
+ LightMaterialTheme {
+ WideButton(
+ onClick = { },
+ enabled = false,
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "disabled_wide_button_with_subtitle_and_icon_light_theme"
+ )
+ }
+
+ @Test
+ fun disabled_wideButton_withSubtitleAndIcon_darkTheme() {
+ rule.setContent {
+ DarkMaterialTheme {
+ WideButton(
+ onClick = { },
+ enabled = false,
+ title = { Text("Settings") },
+ subtitle = { Text(text = "Update device preferences") },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "Settings"
+ )
+ }
+ )
+ }
+ }
+
+ rule.onNode(hasClickAction())
+ .captureToImage()
+ .assertAgainstGolden(
+ screenshotRule,
+ "disabled_wide_button_with_subtitle_and_icon_dark_theme"
+ )
+ }
+}
diff --git a/tv/tv-material/src/androidTest/java/androidx/tv/material3/WideButtonTest.kt b/tv/tv-material/src/androidTest/java/androidx/tv/material3/WideButtonTest.kt
new file mode 100644
index 0000000..d7d9625
--- /dev/null
+++ b/tv/tv-material/src/androidTest/java/androidx/tv/material3/WideButtonTest.kt
@@ -0,0 +1,260 @@
+/*
+ * 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.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Settings
+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.MediumTest
+import com.google.common.truth.Truth
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@OptIn(
+ ExperimentalComposeUiApi::class,
+ ExperimentalTestApi::class,
+ ExperimentalTvMaterial3Api::class
+)
+@MediumTest
+@RunWith(AndroidJUnit4::class)
+class WideButtonTest {
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun wideButton_defaultSemantics() {
+ rule.setContent {
+ Box {
+ WideButton(
+ onClick = { },
+ modifier = Modifier
+ .testTag(WideButtonTag),
+ title = { Text(text = "Settings") },
+ icon = {
+ Icon(imageVector = Icons.Default.Settings, contentDescription = "")
+ },
+ subtitle = { Text(text = "Update device preferences") }
+ )
+ }
+ }
+
+ rule.onNodeWithTag(WideButtonTag)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+ .assertIsEnabled()
+ }
+
+ @Test
+ fun wideButton_disabledSemantics() {
+ rule.setContent {
+ Box {
+ WideButton(
+ onClick = { },
+ modifier = Modifier
+ .testTag(WideButtonTag),
+ enabled = false,
+ title = { Text(text = "Settings") },
+ icon = {
+ Icon(imageVector = Icons.Default.Settings, contentDescription = "")
+ },
+ subtitle = { Text(text = "Update device preferences") }
+ )
+ }
+ }
+
+ rule.onNodeWithTag(WideButtonTag)
+ .assert(SemanticsMatcher.expectValue(SemanticsProperties.Role, Role.Button))
+ .assertIsNotEnabled()
+ }
+
+ @Test
+ fun wideButton_findByTagAndClick() {
+ var counter = 0
+ val onClick: () -> Unit = { ++counter }
+
+ rule.setContent {
+ Box {
+ WideButton(
+ onClick = onClick,
+ modifier = Modifier
+ .testTag(WideButtonTag),
+ title = { Text(text = "Settings") },
+ icon = {
+ Icon(imageVector = Icons.Default.Settings, contentDescription = "")
+ },
+ subtitle = { Text(text = "Update device preferences") }
+ )
+ }
+ }
+ rule.onNodeWithTag(WideButtonTag)
+ .performSemanticsAction(SemanticsActions.RequestFocus)
+ .performKeyInput { pressKey(Key.DirectionCenter) }
+ rule.runOnIdle {
+ Truth.assertThat(counter).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun wideButton_canBeDisabled() {
+ rule.setContent {
+ var enabled by remember { mutableStateOf(true) }
+ Box {
+ WideButton(
+ onClick = { enabled = false },
+ modifier = Modifier
+ .testTag(WideButtonTag),
+ enabled = enabled,
+ title = { Text(text = "Settings") },
+ icon = {
+ Icon(imageVector = Icons.Default.Settings, contentDescription = "")
+ },
+ subtitle = { Text(text = "Update device preferences") }
+ )
+ }
+ }
+ rule.onNodeWithTag(WideButtonTag)
+ // 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 wideButton_clickIsIndependentBetweenButtons() {
+ var watchButtonCounter = 0
+ val watchButtonOnClick: () -> Unit = { ++watchButtonCounter }
+ val watchButtonTag = "WatchButton"
+
+ var playButtonCounter = 0
+ val playButtonOnClick: () -> Unit = { ++playButtonCounter }
+ val playButtonTag = "PlayButton"
+
+ rule.setContent {
+ Column {
+ WideButton(
+ onClick = watchButtonOnClick,
+ modifier = Modifier.testTag(watchButtonTag),
+ ) {
+ Text(text = "Watch")
+ }
+ WideButton(
+ onClick = playButtonOnClick,
+ modifier = Modifier.testTag(playButtonTag),
+ ) {
+ Text(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 wideButton_buttonPositioning() {
+ rule.setContent {
+ Box {
+ WideButton(
+ onClick = { },
+ modifier = Modifier
+ .testTag(WideButtonTag),
+ contentPadding = WideButtonDefaults.ContentPadding,
+ title = {
+ Text(
+ text = "Email",
+ modifier = Modifier
+ .testTag(WideButtonTextTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ },
+ icon = {
+ Icon(
+ imageVector = Icons.Default.Settings,
+ contentDescription = "",
+ modifier = Modifier
+ .size(WideButtonIconSize)
+ .testTag(WideButtonIconTag)
+ .semantics(mergeDescendants = true) {}
+ )
+ }
+ )
+ }
+ }
+
+ val buttonBounds = rule.onNodeWithTag(WideButtonTag).getUnclippedBoundsInRoot()
+ val leadingIconBounds = rule.onNodeWithTag(WideButtonIconTag).getUnclippedBoundsInRoot()
+
+ (leadingIconBounds.left - buttonBounds.left).assertIsEqualTo(
+ 16.dp,
+ "padding between the start of the button and the start of the leading icon."
+ )
+ }
+}
+
+private val WideButtonIconSize = 18.0.dp
+private const val WideButtonTag = "WideButtonTag"
+private const val WideButtonTextTag = "WideButtonText"
+private const val WideButtonIconTag = "WideButtonIcon"
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
index 75e56dd..560ad85 100644
--- a/tv/tv-material/src/main/java/androidx/tv/material3/ButtonStyles.kt
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/ButtonStyles.kt
@@ -122,6 +122,47 @@
}
/**
+ * Defines [Color]s for all TV [Interaction] states of a WideButton
+ */
+@ExperimentalTvMaterial3Api
+@Immutable
+class WideButtonContentColor internal constructor(
+ internal val contentColor: Color,
+ internal val focusedContentColor: Color,
+ internal val pressedContentColor: 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 WideButtonContentColor
+
+ if (contentColor != other.contentColor) return false
+ if (focusedContentColor != other.focusedContentColor) return false
+ if (pressedContentColor != other.pressedContentColor) return false
+ if (disabledContentColor != other.disabledContentColor) return false
+
+ return true
+ }
+
+ override fun hashCode(): Int {
+ var result = contentColor.hashCode()
+ result = 31 * result + focusedContentColor.hashCode()
+ result = 31 * result + pressedContentColor.hashCode()
+ result = 31 * result + disabledContentColor.hashCode()
+ return result
+ }
+
+ override fun toString(): String {
+ return "WideButtonContentColor(contentColor=$contentColor, " +
+ "focusedContentColor=$focusedContentColor, " +
+ "pressedContentColor=$pressedContentColor, " +
+ "disabledContentColor=$disabledContentColor)"
+ }
+}
+
+/**
* Defines the scale for all TV [Interaction] states of Button.
*/
@ExperimentalTvMaterial3Api
@@ -272,6 +313,15 @@
)
@OptIn(ExperimentalTvMaterial3Api::class)
+internal fun WideButtonContentColor.toClickableSurfaceContentColor(): ClickableSurfaceColor =
+ ClickableSurfaceColor(
+ color = contentColor,
+ focusedColor = focusedContentColor,
+ pressedColor = pressedContentColor,
+ disabledColor = disabledContentColor,
+ )
+
+@OptIn(ExperimentalTvMaterial3Api::class)
internal fun ButtonScale.toClickableSurfaceScale() = ClickableSurfaceScale(
scale = scale,
focusedScale = focusedScale,
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/WideButton.kt b/tv/tv-material/src/main/java/androidx/tv/material3/WideButton.kt
new file mode 100644
index 0000000..30f4d67
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/WideButton.kt
@@ -0,0 +1,284 @@
+/*
+ * 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.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.RowScope
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.defaultMinSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.NonRestartableComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.onPlaced
+import androidx.compose.ui.platform.LocalDensity
+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.compose.ui.unit.dp
+import androidx.tv.material3.tokens.Elevation
+
+/**
+ * Material Design wide button for TV.
+ *
+ * @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 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 background the background to be applied to the [WideButton]
+ * @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 contentColor Color to be used for the text 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 content the content of the button
+ */
+@ExperimentalTvMaterial3Api
+@NonRestartableComposable
+@Composable
+fun WideButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ background: @Composable () -> Unit = {
+ WideButtonDefaults.Background(
+ enabled = enabled,
+ interactionSource = interactionSource,
+ )
+ },
+ scale: ButtonScale = WideButtonDefaults.scale(),
+ glow: ButtonGlow = WideButtonDefaults.glow(),
+ shape: ButtonShape = WideButtonDefaults.shape(),
+ contentColor: WideButtonContentColor = WideButtonDefaults.contentColor(),
+ tonalElevation: Dp = Elevation.Level0,
+ border: ButtonBorder = WideButtonDefaults.border(),
+ contentPadding: PaddingValues = WideButtonDefaults.ContentPadding,
+ content: @Composable RowScope.() -> Unit
+) {
+ WideButtonImpl(
+ onClick = onClick,
+ enabled = enabled,
+ scale = scale,
+ glow = glow,
+ shape = shape,
+ contentColor = contentColor,
+ tonalElevation = tonalElevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ modifier = modifier,
+ background = background,
+ content = content
+ )
+}
+
+/**
+ * Material Design wide button for TV.
+ *
+ * @param onClick called when this button is clicked
+ * @param title the title content of the button, typically a [Text]
+ * @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 icon the leading icon content of the button, typically an [Icon]
+ * @param subtitle the subtitle content of the button, typically a [Text]
+ * @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 background the background to be applied to the [WideButton]
+ * @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 contentColor Color to be used for the text 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
+ */
+@ExperimentalTvMaterial3Api
+@NonRestartableComposable
+@Composable
+fun WideButton(
+ onClick: () -> Unit,
+ title: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ enabled: Boolean = true,
+ icon: (@Composable () -> Unit)? = null,
+ subtitle: (@Composable () -> Unit)? = null,
+ interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
+ background: @Composable () -> Unit = {
+ WideButtonDefaults.Background(
+ enabled = enabled,
+ interactionSource = interactionSource
+ )
+ },
+ scale: ButtonScale = WideButtonDefaults.scale(),
+ glow: ButtonGlow = WideButtonDefaults.glow(),
+ shape: ButtonShape = WideButtonDefaults.shape(),
+ contentColor: WideButtonContentColor = WideButtonDefaults.contentColor(),
+ tonalElevation: Dp = Elevation.Level0,
+ border: ButtonBorder = WideButtonDefaults.border(),
+ contentPadding: PaddingValues = WideButtonDefaults.ContentPadding,
+) {
+
+ WideButtonImpl(
+ onClick = onClick,
+ enabled = enabled,
+ scale = scale,
+ glow = glow,
+ shape = shape,
+ contentColor = contentColor,
+ tonalElevation = tonalElevation,
+ border = border,
+ contentPadding = contentPadding,
+ interactionSource = interactionSource,
+ modifier = modifier,
+ minHeight = if (subtitle == null)
+ BaseWideButtonDefaults.MinHeight
+ else
+ BaseWideButtonDefaults.MinHeightWithSubtitle,
+ background = background
+ ) {
+ if (icon != null) {
+ icon()
+ Spacer(
+ modifier = Modifier.padding(end = BaseWideButtonDefaults.HorizontalContentGap)
+ )
+ }
+ Column {
+ ProvideTextStyle(
+ value = MaterialTheme.typography.titleMedium,
+ content = {
+ Box(
+ modifier = Modifier
+ .padding(vertical = BaseWideButtonDefaults.VerticalContentGap)
+ ) {
+ title()
+ }
+ }
+ )
+ if (subtitle != null) {
+ ProvideTextStyle(
+ value = MaterialTheme.typography.bodySmall.copy(
+ color = LocalContentColor.current.copy(
+ alpha = BaseWideButtonDefaults.SubtitleAlpha
+ )
+ ),
+ content = subtitle
+ )
+ }
+ }
+ }
+}
+
+@ExperimentalTvMaterial3Api
+@Composable
+private fun WideButtonImpl(
+ onClick: () -> Unit,
+ enabled: Boolean,
+ scale: ButtonScale,
+ glow: ButtonGlow,
+ shape: ButtonShape,
+ contentColor: WideButtonContentColor,
+ tonalElevation: Dp,
+ border: ButtonBorder,
+ contentPadding: PaddingValues,
+ interactionSource: MutableInteractionSource,
+ background: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ minHeight: Dp = BaseWideButtonDefaults.MinHeight,
+ content: @Composable RowScope.() -> Unit
+) {
+ val density = LocalDensity.current
+ var buttonWidth by remember { mutableStateOf(0.dp) }
+ var buttonHeight by remember { mutableStateOf(0.dp) }
+
+ Surface(
+ modifier = modifier.semantics { role = Role.Button },
+ onClick = onClick,
+ enabled = enabled,
+ scale = scale.toClickableSurfaceScale(),
+ glow = glow.toClickableSurfaceGlow(),
+ shape = shape.toClickableSurfaceShape(),
+ color = wideButtonContainerColor(),
+ contentColor = contentColor.toClickableSurfaceContentColor(),
+ tonalElevation = tonalElevation,
+ border = border.toClickableSurfaceBorder(),
+ interactionSource = interactionSource
+ ) {
+ ProvideTextStyle(value = MaterialTheme.typography.labelLarge) {
+ Box(
+ modifier = Modifier
+ .defaultMinSize(
+ minWidth = BaseWideButtonDefaults.MinWidth,
+ minHeight = minHeight,
+ )
+ .onPlaced {
+ with(density) {
+ buttonWidth = it.size.width.toDp()
+ buttonHeight = it.size.height.toDp()
+ }
+ }
+ ) {
+ Box(modifier = Modifier.size(buttonWidth, buttonHeight)) {
+ background()
+ }
+
+ Row(
+ modifier = Modifier
+ .size(buttonWidth, buttonHeight)
+ .padding(contentPadding),
+ verticalAlignment = Alignment.CenterVertically,
+ content = content
+ )
+ }
+ }
+ }
+}
+
+@OptIn(ExperimentalTvMaterial3Api::class)
+@Composable
+private fun wideButtonContainerColor() = ClickableSurfaceDefaults.color(
+ color = Color.Transparent,
+ focusedColor = Color.Transparent,
+ pressedColor = Color.Transparent,
+ disabledColor = Color.Transparent,
+)
diff --git a/tv/tv-material/src/main/java/androidx/tv/material3/WideButtonDefaults.kt b/tv/tv-material/src/main/java/androidx/tv/material3/WideButtonDefaults.kt
new file mode 100644
index 0000000..07402ff
--- /dev/null
+++ b/tv/tv-material/src/main/java/androidx/tv/material3/WideButtonDefaults.kt
@@ -0,0 +1,207 @@
+/*
+ * 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.background
+import androidx.compose.foundation.interaction.Interaction
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.interaction.collectIsFocusedAsState
+import androidx.compose.foundation.interaction.collectIsPressedAsState
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.PaddingValues
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.Shape
+import androidx.compose.ui.unit.dp
+
+internal object BaseWideButtonDefaults {
+ const val SubtitleAlpha = 0.8f
+ val MinWidth = 240.dp
+ val MinHeight = 48.dp
+ val MinHeightWithSubtitle = 64.dp
+ val HorizontalContentGap = 12.dp
+ val VerticalContentGap = 4.dp
+}
+
+@ExperimentalTvMaterial3Api
+object WideButtonDefaults {
+ private val HorizontalPadding = 16.dp
+ private val VerticalPadding = 10.dp
+
+ /** The default content padding used by [WideButton] */
+ internal val ContentPadding = PaddingValues(
+ start = HorizontalPadding,
+ top = VerticalPadding,
+ end = HorizontalPadding,
+ bottom = VerticalPadding
+ )
+
+ private val ContainerShape = RoundedCornerShape(12.dp)
+
+ /**
+ * Default background for a [WideButton]
+ */
+ @Composable
+ fun Background(
+ enabled: Boolean,
+ interactionSource: MutableInteractionSource,
+ ) {
+ val isFocused = interactionSource.collectIsFocusedAsState().value
+ val isPressed = interactionSource.collectIsPressedAsState().value
+
+ val backgroundColor = when {
+ !enabled -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ isPressed -> MaterialTheme.colorScheme.onSurface
+ isFocused -> MaterialTheme.colorScheme.onSurface
+ else -> MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.4f)
+ }
+
+ Box(modifier = Modifier
+ .fillMaxSize()
+ .background(backgroundColor))
+ }
+
+ /**
+ * Creates a [ButtonShape] that represents the default container shapes used in a [WideButton]
+ *
+ * @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 [WideButtonContentColor] that represents the default content colors used in a
+ * [WideButton]
+ *
+ * @param color the content color of this Button when enabled
+ * @param focusedColor the content color of this Button when enabled and focused
+ * @param pressedColor the content color of this Button when enabled and pressed
+ * @param disabledColor the content color of this Button when not enabled
+ */
+ @ReadOnlyComposable
+ @Composable
+ fun contentColor(
+ color: Color = MaterialTheme.colorScheme.onSurface,
+ focusedColor: Color = MaterialTheme.colorScheme.inverseOnSurface,
+ pressedColor: Color = focusedColor,
+ disabledColor: Color = color
+ ) = WideButtonContentColor(
+ contentColor = color,
+ focusedContentColor = focusedColor,
+ pressedContentColor = pressedColor,
+ disabledContentColor = disabledColor
+ )
+
+ /**
+ * Creates a [ButtonScale] that represents the default scales used in a [WideButton].
+ * Scale is 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
+ * [WideButton] 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 = 2.dp,
+ color = MaterialTheme.colorScheme.border
+ ),
+ inset = 0.dp,
+ 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 [WideButton]
+ *
+ * @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
+ )
+}