Add badge component for Material 3.
Same API as in compose.material.
Bug: 201587068
Test: BadgeTest, BadgeScreenshotTest.
Relnote: Add badge component for Material 3.
Change-Id: I89163fb93552c61db8f8d5c18e876af96b79fbd2
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/BadgeScreenshotTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/BadgeScreenshotTest.kt
new file mode 100644
index 0000000..dc2f4c2
--- /dev/null
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/BadgeScreenshotTest.kt
@@ -0,0 +1,141 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+package androidx.compose.material3
+
+import android.os.Build
+import androidx.compose.foundation.layout.Box
+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.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.test.ExperimentalTestApi
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.unit.dp
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import androidx.test.screenshot.AndroidXScreenshotTestRule
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+@SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+@OptIn(ExperimentalTestApi::class)
+class BadgeScreenshotTest {
+
+ @get:Rule
+ val composeTestRule = createComposeRule()
+
+ @get:Rule
+ val screenshotRule = AndroidXScreenshotTestRule(GOLDEN_MATERIAL3)
+
+ @Test
+ fun lightTheme_noContent() {
+ composeTestRule.setContent {
+ MaterialTheme(lightColorScheme()) {
+ Box(
+ Modifier.size(56.dp).semantics(mergeDescendants = true) {}.testTag(TestTag),
+ contentAlignment = Alignment.Center
+ ) {
+ BadgedBox(badge = { Badge() }) {
+ Icon(Icons.Filled.Favorite, null)
+ }
+ }
+ }
+ }
+
+ assertBadgeAgainstGolden(
+ goldenIdentifier = "badge_lightTheme_noContent"
+ )
+ }
+
+ @Test
+ fun darkTheme_noContent() {
+ composeTestRule.setContent {
+ MaterialTheme(darkColorScheme()) {
+ Box(
+ Modifier.size(56.dp).semantics(mergeDescendants = true) {}.testTag(TestTag),
+ contentAlignment = Alignment.Center
+ ) {
+ BadgedBox(badge = { Badge() }) {
+ Icon(Icons.Filled.Favorite, null)
+ }
+ }
+ }
+ }
+
+ assertBadgeAgainstGolden(
+ goldenIdentifier = "badge_darkTheme_noContent"
+ )
+ }
+
+ @Test
+ fun lightTheme_withContent() {
+ composeTestRule.setContent {
+ MaterialTheme(lightColorScheme()) {
+ Box(
+ Modifier.size(56.dp).semantics(mergeDescendants = true) {}.testTag(TestTag),
+ contentAlignment = Alignment.Center
+ ) {
+ BadgedBox(badge = { Badge { Text("8") } }) {
+ Icon(Icons.Filled.Favorite, null)
+ }
+ }
+ }
+ }
+
+ assertBadgeAgainstGolden(
+ goldenIdentifier = "badge_lightTheme_withContent"
+ )
+ }
+
+ @Test
+ fun darkTheme_withContent() {
+ composeTestRule.setContent {
+ MaterialTheme(darkColorScheme()) {
+ Box(
+ Modifier.size(56.dp).semantics(mergeDescendants = true) {}.testTag(TestTag),
+ contentAlignment = Alignment.Center
+ ) {
+ BadgedBox(badge = { Badge { Text("8") } }) {
+ Icon(Icons.Filled.Favorite, null)
+ }
+ }
+ }
+ }
+
+ assertBadgeAgainstGolden(
+ goldenIdentifier = "badge_darkTheme_withContent"
+ )
+ }
+
+ private fun assertBadgeAgainstGolden(goldenIdentifier: String) {
+ composeTestRule.onNodeWithTag(TestTag)
+ .captureToImage()
+ .assertAgainstGolden(screenshotRule, goldenIdentifier)
+ }
+}
+
+private const val TestTag = "badge"
diff --git a/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/BadgeTest.kt b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/BadgeTest.kt
new file mode 100644
index 0000000..4714915
--- /dev/null
+++ b/compose/material3/material3/src/androidAndroidTest/kotlin/androidx/compose/material3/BadgeTest.kt
@@ -0,0 +1,234 @@
+/*
+ * Copyright 2021 The Android Open Source Project
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package androidx.compose.material3
+
+import android.os.Build
+import androidx.compose.foundation.layout.size
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Favorite
+import androidx.compose.material3.tokens.NavigationBar
+import androidx.compose.testutils.assertShape
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.platform.testTag
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.test.assertContentDescriptionEquals
+import androidx.compose.ui.test.assertHeightIsEqualTo
+import androidx.compose.ui.test.assertPositionInRootIsEqualTo
+import androidx.compose.ui.test.assertWidthIsAtLeast
+import androidx.compose.ui.test.assertWidthIsEqualTo
+import androidx.compose.ui.test.captureToImage
+import androidx.compose.ui.test.getUnclippedBoundsInRoot
+import androidx.compose.ui.test.junit4.createComposeRule
+import androidx.compose.ui.test.onNodeWithTag
+import androidx.compose.ui.test.onSibling
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.height
+import androidx.compose.ui.unit.max
+import androidx.compose.ui.unit.width
+import androidx.test.ext.junit.runners.AndroidJUnit4
+import androidx.test.filters.LargeTest
+import androidx.test.filters.SdkSuppress
+import org.junit.Rule
+import org.junit.Test
+import org.junit.runner.RunWith
+
+@LargeTest
+@RunWith(AndroidJUnit4::class)
+class BadgeTest {
+
+ private val icon = Icons.Filled.Favorite
+
+ @get:Rule
+ val rule = createComposeRule()
+
+ @Test
+ fun badge_noContent_size() {
+ rule
+ .setMaterialContentForSizeAssertions {
+ Badge()
+ }
+ .assertHeightIsEqualTo(NavigationBar.BadgeSize)
+ .assertWidthIsEqualTo(NavigationBar.BadgeSize)
+ }
+
+ @Test
+ fun badge_shortContent_size() {
+ rule
+ .setMaterialContentForSizeAssertions {
+ Badge { Text("1") }
+ }
+ .assertHeightIsEqualTo(NavigationBar.LargeBadgeSize)
+ .assertWidthIsEqualTo(NavigationBar.LargeBadgeSize)
+ }
+
+ @Test
+ fun badge_longContent_size() {
+ rule
+ .setMaterialContentForSizeAssertions {
+ Badge { Text("999+") }
+ }
+ .assertHeightIsEqualTo(NavigationBar.LargeBadgeSize)
+ .assertWidthIsAtLeast(NavigationBar.LargeBadgeSize)
+ }
+
+ @Test
+ fun badge_shortContent_customSizeModifier_size() {
+ val customWidth = 24.dp
+ val customHeight = 6.dp
+ rule
+ .setMaterialContentForSizeAssertions {
+ Badge(modifier = Modifier.size(customWidth, customHeight)) {
+ Text("1")
+ }
+ }
+ .assertHeightIsEqualTo(customHeight)
+ .assertWidthIsEqualTo(customWidth)
+ }
+
+ @SdkSuppress(minSdkVersion = Build.VERSION_CODES.O)
+ @Test
+ fun badge_noContent_shape() {
+ var errorColor = Color.Unspecified
+ rule.setMaterialContent {
+ errorColor = MaterialTheme.colorScheme.fromToken(NavigationBar.BadgeColor)
+ Badge(modifier = Modifier.testTag(TestBadgeTag))
+ }
+
+ rule.onNodeWithTag(TestBadgeTag)
+ .captureToImage()
+ .assertShape(
+ density = rule.density,
+ shape = NavigationBar.BadgeShape,
+ shapeColor = errorColor,
+ backgroundColor = Color.White,
+ shapeOverlapPixelCount = with(rule.density) { 1.dp.toPx() }
+ )
+ }
+
+ @Test
+ fun badgeBox_noContent_position() {
+ rule
+ .setMaterialContent {
+ BadgedBox(badge = { Badge(Modifier.testTag(TestBadgeTag)) }) {
+ Icon(
+ icon,
+ null,
+ modifier = Modifier.testTag(TestAnchorTag)
+ )
+ }
+ }
+ val badge = rule.onNodeWithTag(TestBadgeTag)
+ val anchorBounds = rule.onNodeWithTag(TestAnchorTag).getUnclippedBoundsInRoot()
+ val badgeBounds = badge.getUnclippedBoundsInRoot()
+ badge.assertPositionInRootIsEqualTo(
+ expectedLeft =
+ anchorBounds.right + BadgeOffset +
+ max((NavigationBar.BadgeSize - badgeBounds.width) / 2, 0.dp),
+ expectedTop = -badgeBounds.height / 2
+ )
+ }
+
+ @Test
+ fun badgeBox_shortContent_position() {
+ rule
+ .setMaterialContent {
+ BadgedBox(badge = { Badge { Text("8") } }) {
+ Icon(
+ icon,
+ null,
+ modifier = Modifier.testTag(TestAnchorTag)
+ )
+ }
+ }
+ val badge = rule.onNodeWithTag(TestAnchorTag).onSibling()
+ val anchorBounds = rule.onNodeWithTag(TestAnchorTag).getUnclippedBoundsInRoot()
+ val badgeBounds = badge.getUnclippedBoundsInRoot()
+ badge.assertPositionInRootIsEqualTo(
+ expectedLeft = anchorBounds.right + BadgeWithContentHorizontalOffset + max
+ (
+ (
+ NavigationBar.LargeBadgeSize - badgeBounds.width
+ ) / 2,
+ 0.dp
+ ),
+ expectedTop = -badgeBounds.height / 2 + BadgeWithContentVerticalOffset
+ )
+ }
+
+ @Test
+ fun badgeBox_longContent_position() {
+ rule
+ .setMaterialContent {
+ BadgedBox(badge = { Badge { Text("999+") } }) {
+ Icon(
+ icon,
+ null,
+ modifier = Modifier.testTag(TestAnchorTag)
+ )
+ }
+ }
+ val badge = rule.onNodeWithTag(TestAnchorTag).onSibling()
+ val anchorBounds = rule.onNodeWithTag(TestAnchorTag).getUnclippedBoundsInRoot()
+ val badgeBounds = badge.getUnclippedBoundsInRoot()
+
+ val totalBadgeHorizontalOffset = BadgeWithContentHorizontalOffset +
+ BadgeWithContentHorizontalPadding
+ badge.assertPositionInRootIsEqualTo(
+ expectedLeft = anchorBounds.right + totalBadgeHorizontalOffset,
+ expectedTop = -badgeBounds.height / 2 + BadgeWithContentVerticalOffset
+ )
+ }
+
+ @Test
+ fun badge_notMergingDescendants_withOwnContentDescription() {
+ rule.setMaterialContent {
+ BadgedBox(
+ badge = {
+ Badge { Text("99+") }
+ },
+ modifier = Modifier.testTag(TestBadgeTag).semantics {
+ this.contentDescription = "more than 99 new email"
+ }
+ ) {
+ Text(
+ "inbox",
+ Modifier.semantics {
+ this.contentDescription = "inbox"
+ }.testTag(TestAnchorTag)
+ )
+ }
+ }
+
+ rule.onNodeWithTag(TestBadgeTag).assertContentDescriptionEquals("more than 99 new email")
+ rule.onNodeWithTag(TestAnchorTag).assertContentDescriptionEquals("inbox")
+ }
+
+ @Test
+ fun badgeBox_size() {
+ rule.setMaterialContentForSizeAssertions {
+ BadgedBox(badge = { Badge { Text("999+") } }) {
+ Icon(icon, null)
+ }
+ }
+ .assertWidthIsEqualTo(icon.defaultWidth)
+ .assertHeightIsEqualTo(icon.defaultHeight)
+ }
+}
+
+private const val TestBadgeTag = "badge"
+private const val TestAnchorTag = "anchor"