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"
diff --git a/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Badge.kt b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Badge.kt
new file mode 100644
index 0000000..2426b37
--- /dev/null
+++ b/compose/material3/material3/src/commonMain/kotlin/androidx/compose/material3/Badge.kt
@@ -0,0 +1,183 @@
+/*
+ * 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 androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+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.material3.tokens.NavigationBar
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.layout.FirstBaseline
+import androidx.compose.ui.layout.LastBaseline
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.layout.layoutId
+import androidx.compose.ui.unit.dp
+
+// TODO(b/197880751): Update spec link.
+/**
+ * A BadgeBox is used to decorate [content] with a [badge] that can contain dynamic information,
+ * such
+ * as the presence of a new notification or a number of pending requests. Badges can be icon only
+ * or contain short text.
+ *
+ * A common use case is to display a badge with navigation bar items.
+ * For more information, see [Navigation Bar](https://material.io/components/bottom-navigation#behavior)
+ *
+ * A simple icon with badge example looks like:
+ * @sample androidx.compose.material3.samples.NavigationBarItemWithBadge
+ *
+ * @param badge the badge to be displayed - typically a [Badge]
+ * @param modifier optional [Modifier] for this item
+ * @param content the anchor to which this badge will be positioned
+ *
+ */
+@Composable
+fun BadgedBox(
+ badge: @Composable BoxScope.() -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ Layout(
+ {
+ Box(
+ modifier = Modifier.layoutId("anchor"),
+ contentAlignment = Alignment.Center,
+ content = content
+ )
+ Box(
+ modifier = Modifier.layoutId("badge"),
+ content = badge
+ )
+ },
+ modifier = modifier
+ ) { measurables, constraints ->
+
+ val badgePlaceable = measurables.first { it.layoutId == "badge" }.measure(
+ // Measure with loose constraints for height as we don't want the text to take up more
+ // space than it needs.
+ constraints.copy(minHeight = 0)
+ )
+
+ val anchorPlaceable = measurables.first { it.layoutId == "anchor" }.measure(constraints)
+
+ val firstBaseline = anchorPlaceable[FirstBaseline]
+ val lastBaseline = anchorPlaceable[LastBaseline]
+ val totalWidth = anchorPlaceable.width
+ val totalHeight = anchorPlaceable.height
+
+ layout(
+ totalWidth,
+ totalHeight,
+ // Provide custom baselines based only on the anchor content to avoid default baseline
+ // calculations from including by any badge content.
+ mapOf(
+ FirstBaseline to firstBaseline,
+ LastBaseline to lastBaseline
+ )
+ ) {
+ // Use the width of the badge to infer whether it has any content (based on radius used
+ // in [Badge]) and determine its horizontal offset.
+ val hasContent = badgePlaceable.width > (NavigationBar.BadgeSize.roundToPx())
+ val badgeHorizontalOffset =
+ if (hasContent) BadgeWithContentHorizontalOffset else BadgeOffset
+ val badgeVerticalOffset =
+ if (hasContent) BadgeWithContentVerticalOffset else BadgeOffset
+
+ anchorPlaceable.placeRelative(0, 0)
+ val badgeX = anchorPlaceable.width + badgeHorizontalOffset.roundToPx()
+ val badgeY = -badgePlaceable.height / 2 + badgeVerticalOffset.roundToPx()
+ badgePlaceable.placeRelative(badgeX, badgeY)
+ }
+ }
+}
+
+/**
+ * Badge is a component that can contain dynamic information, such as the presence of a new
+ * notification or a number of pending requests. Badges can be icon only or contain short text.
+ *
+ * See [BadgedBox] for a top level layout that will properly place the badge relative to content
+ * such as text or an icon.
+ *
+ * @param modifier optional [Modifier] for this item
+ * @param containerColor the background color for the badge
+ * @param contentColor the color of label text rendered in the badge
+ * @param content optional content to be rendered inside the badge
+ */
+@Composable
+fun Badge(
+ modifier: Modifier = Modifier,
+ containerColor: Color = MaterialTheme.colorScheme.fromToken(NavigationBar.BadgeColor),
+ contentColor: Color = contentColorFor(containerColor),
+ content: @Composable (RowScope.() -> Unit)? = null,
+) {
+ val size = if (content != null) NavigationBar.LargeBadgeSize else NavigationBar.BadgeSize
+ val shape = if (content != null) NavigationBar.LargeBadgeShape else NavigationBar.BadgeShape
+
+ // Draw badge container.
+ Row(
+ modifier = modifier
+ .defaultMinSize(minWidth = size, minHeight = size)
+ .background(
+ color = containerColor,
+ shape = shape
+ )
+ .clip(shape)
+ .then(
+ if (content != null)
+ Modifier.padding(horizontal = BadgeWithContentHorizontalPadding) else Modifier),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.Center
+ ) {
+ if (content != null) {
+ // Not using Surface composable because it blocks touch propagation behind it.
+ CompositionLocalProvider(
+ LocalContentColor provides contentColor
+ ) {
+ val style =
+ MaterialTheme.typography.fromToken(NavigationBar.LargeBadgeLabelFontFamily)
+ ProvideTextStyle(
+ value = style,
+ content = { content() }
+ )
+ }
+ }
+ }
+}
+
+/*@VisibleForTesting*/
+// Leading and trailing text padding when a badge is displaying text that is too long to fit in
+// a circular badge, e.g. if badge number is greater than 9.
+internal val BadgeWithContentHorizontalPadding = 4.dp
+
+/*@VisibleForTesting*/
+// Horizontally align start/end of text badge 4dp from the top end corner of its anchor
+internal val BadgeWithContentHorizontalOffset = -4.dp
+internal val BadgeWithContentVerticalOffset = -4.dp
+
+/*@VisibleForTesting*/
+// Horizontally align start/end of icon only badge 0.dp from the end/start edge of anchor
+internal val BadgeOffset = 0.dp