[go: nahoru, domu]

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