Implement Bundle Header in Compose
First compose version of BundleHeader that will be used inside of a
ComposeView within `NotificationChildrenContainer`.
Test: Manual tests in Gallery app, as well as screenshot test added for
all current variants.
Flag: com.android.systemui.notification_bundle_ui
Bug: b/389839492 b/394476643 b/394476670 b/394478037 b/394478046
Change-Id: Ibaf1ddff3d1224f6d31e9cef663a6fdbcb2b1ce2
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
new file mode 100644
index 0000000..d7740a4
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt
@@ -0,0 +1,193 @@
+/*
+ * Copyright (C) 2025 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 com.android.systemui.notifications.ui.composable.row
+
+import android.content.Context
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.layout.Layout
+import androidx.compose.ui.platform.ComposeView
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.constrainHeight
+import androidx.compose.ui.unit.constrainWidth
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.util.fastForEach
+import androidx.compose.ui.util.fastMap
+import androidx.compose.ui.util.fastMaxOfOrDefault
+import androidx.compose.ui.util.fastSumBy
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.SceneKey
+import com.android.compose.animation.scene.SceneTransitionLayout
+import com.android.compose.theme.PlatformTheme
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+import com.android.systemui.statusbar.notification.row.ui.viewmodel.BundleHeaderViewModel
+
+object BundleHeader {
+ object Scenes {
+ val Collapsed = SceneKey("Collapsed")
+ val Expanded = SceneKey("Expanded")
+ }
+
+ object Elements {
+ val PreviewIcon1 = ElementKey("PreviewIcon1")
+ val PreviewIcon2 = ElementKey("PreviewIcon2")
+ val PreviewIcon3 = ElementKey("PreviewIcon3")
+ val TitleText = ElementKey("TitleText")
+ }
+}
+
+fun createComposeView(viewModel: BundleHeaderViewModel, context: Context): ComposeView {
+ // TODO(b/399588047): Check if we can init PlatformTheme once instead of once per ComposeView
+ return ComposeView(context).apply { setContent { PlatformTheme { BundleHeader(viewModel) } } }
+}
+
+@Composable
+fun BundleHeader(viewModel: BundleHeaderViewModel, modifier: Modifier = Modifier) {
+ Box(modifier) {
+ Background(background = viewModel.backgroundDrawable, modifier = Modifier.matchParentSize())
+ val scope = rememberCoroutineScope()
+ SceneTransitionLayout(
+ state = viewModel.state,
+ modifier =
+ Modifier.clickable(
+ onClick = { viewModel.onHeaderClicked(scope) },
+ interactionSource = null,
+ indication = null,
+ ),
+ ) {
+ scene(BundleHeader.Scenes.Collapsed) {
+ BundleHeaderContent(viewModel, collapsed = true)
+ }
+ scene(BundleHeader.Scenes.Expanded) {
+ BundleHeaderContent(viewModel, collapsed = false)
+ }
+ }
+ }
+}
+
+@Composable
+private fun Background(background: Drawable?, modifier: Modifier = Modifier) {
+ if (background != null) {
+ val painter = rememberDrawablePainter(drawable = background)
+ Image(
+ painter = painter,
+ contentDescription = null,
+ contentScale = ContentScale.Crop,
+ modifier = modifier,
+ )
+ }
+}
+
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun ContentScope.BundleHeaderContent(
+ viewModel: BundleHeaderViewModel,
+ collapsed: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = modifier.padding(vertical = 16.dp),
+ ) {
+ BundleIcon(viewModel.bundleIcon, modifier = Modifier.padding(horizontal = 16.dp))
+ Text(
+ text = viewModel.titleText,
+ style = MaterialTheme.typography.titleMediumEmphasized,
+ color = MaterialTheme.colorScheme.primary,
+ overflow = TextOverflow.Ellipsis,
+ maxLines = 1,
+ modifier = Modifier.element(BundleHeader.Elements.TitleText).weight(1f),
+ )
+
+ if (collapsed && viewModel.previewIcons.isNotEmpty()) {
+ BundlePreviewIcons(
+ previewDrawables = viewModel.previewIcons,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+
+ ExpansionControl(
+ collapsed = collapsed,
+ hasUnread = viewModel.hasUnreadMessages,
+ numberToShow = viewModel.numberOfChildren,
+ modifier = Modifier.padding(start = 8.dp, end = 16.dp),
+ )
+ }
+}
+
+@Composable
+private fun ContentScope.BundlePreviewIcons(
+ previewDrawables: List<Drawable>,
+ modifier: Modifier = Modifier,
+) {
+ check(previewDrawables.isNotEmpty())
+ val iconSize = 32.dp
+ HalfOverlappingReversedRow(modifier = modifier) {
+ PreviewIcon(
+ drawable = previewDrawables[0],
+ modifier = Modifier.element(BundleHeader.Elements.PreviewIcon1).size(iconSize),
+ )
+ if (previewDrawables.size < 2) return@HalfOverlappingReversedRow
+ PreviewIcon(
+ drawable = previewDrawables[1],
+ modifier = Modifier.element(BundleHeader.Elements.PreviewIcon2).size(iconSize),
+ )
+ if (previewDrawables.size < 3) return@HalfOverlappingReversedRow
+ PreviewIcon(
+ drawable = previewDrawables[2],
+ modifier = Modifier.element(BundleHeader.Elements.PreviewIcon3).size(iconSize),
+ )
+ }
+}
+
+@Composable
+private fun HalfOverlappingReversedRow(
+ modifier: Modifier = Modifier,
+ content: @Composable () -> Unit,
+) {
+ Layout(modifier = modifier, content = content) { measurables, constraints ->
+ val placeables = measurables.fastMap { measurable -> measurable.measure(constraints) }
+
+ if (placeables.isEmpty())
+ return@Layout layout(constraints.minWidth, constraints.minHeight) {}
+ val width = placeables.fastSumBy { it.width / 2 } + placeables.first().width / 2
+ val childHeight = placeables.fastMaxOfOrDefault(0) { it.height }
+
+ layout(constraints.constrainWidth(width), constraints.constrainHeight(childHeight)) {
+ // Start in the middle of the right-most placeable
+ var currentXPosition = placeables.fastSumBy { it.width / 2 }
+ placeables.fastForEach { placeable ->
+ currentXPosition -= placeable.width / 2
+ placeable.placeRelative(x = currentXPosition, y = 0)
+ }
+ }
+ }
+}
diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
new file mode 100644
index 0000000..c9ffa40
--- /dev/null
+++ b/packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt
@@ -0,0 +1,201 @@
+/*
+ * Copyright (C) 2025 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 com.android.systemui.notifications.ui.composable.row
+
+import android.graphics.drawable.Drawable
+import androidx.compose.foundation.Image
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.isSystemInDarkTheme
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.ExpandMore
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.Icon
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.ReadOnlyComposable
+import androidx.compose.runtime.getValue
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.draw.drawBehind
+import androidx.compose.ui.geometry.CornerRadius
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.graphics.ColorFilter
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.ContentScale
+import androidx.compose.ui.platform.LocalDensity
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import com.android.compose.animation.scene.ContentScope
+import com.android.compose.animation.scene.ElementKey
+import com.android.compose.animation.scene.LowestZIndexContentPicker
+import com.android.compose.animation.scene.ValueKey
+import com.android.compose.animation.scene.animateElementColorAsState
+import com.android.compose.animation.scene.animateElementFloatAsState
+import com.android.compose.ui.graphics.painter.rememberDrawablePainter
+
+object NotificationRowPrimitives {
+ object Elements {
+ val PillBackground = ElementKey("PillBackground", contentPicker = LowestZIndexContentPicker)
+ val NotificationIconBackground = ElementKey("NotificationIconBackground")
+ val Chevron = ElementKey("Chevron")
+ }
+
+ object Values {
+ val ChevronRotation = ValueKey("NotificationChevronRotation")
+ val PillBackgroundColor = ValueKey("PillBackgroundColor")
+ }
+}
+
+/** The Icon displayed at the start of any notification row. */
+@Composable
+fun ContentScope.BundleIcon(drawable: Drawable?, modifier: Modifier = Modifier) {
+ val surfaceColor = notificationElementSurfaceColor()
+ Box(
+ modifier =
+ modifier
+ // Has to be a shared element because we may have semi-transparent background color
+ .element(NotificationRowPrimitives.Elements.NotificationIconBackground)
+ .size(40.dp)
+ .background(color = surfaceColor, shape = CircleShape)
+ ) {
+ if (drawable == null) return@Box
+ val painter = rememberDrawablePainter(drawable)
+ Image(
+ painter = painter,
+ contentDescription = null,
+ modifier = Modifier.padding(10.dp).fillMaxSize(),
+ contentScale = ContentScale.Fit,
+ colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.primary),
+ )
+ }
+}
+
+/** The Icon used to display a preview of contained child notifications in a Bundle. */
+@Composable
+fun PreviewIcon(drawable: Drawable, modifier: Modifier = Modifier) {
+ val surfaceColor = notificationElementSurfaceColor()
+ Box(
+ modifier =
+ modifier
+ .background(color = surfaceColor, shape = CircleShape)
+ .border(0.5.dp, surfaceColor, CircleShape)
+ ) {
+ val painter = rememberDrawablePainter(drawable)
+ Image(
+ painter = painter,
+ contentDescription = null,
+ modifier = Modifier.fillMaxSize().clip(CircleShape),
+ contentScale = ContentScale.Fit,
+ )
+ }
+}
+
+/** The ExpansionControl of any expandable notification row, containing a Chevron. */
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+fun ContentScope.ExpansionControl(
+ collapsed: Boolean,
+ hasUnread: Boolean,
+ numberToShow: Int?,
+ modifier: Modifier = Modifier,
+) {
+ val textColor =
+ if (hasUnread) MaterialTheme.colorScheme.onTertiary else MaterialTheme.colorScheme.onSurface
+ Box(modifier = modifier) {
+ // The background is a shared Element and therefore can't be the parent of a different
+ // shared Element (the chevron), otherwise the child can't be animated.
+ PillBackground(hasUnread, modifier = Modifier.matchParentSize())
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ modifier = Modifier.padding(vertical = 2.dp, horizontal = 6.dp),
+ ) {
+ val iconSizeDp = with(LocalDensity.current) { 16.sp.toDp() }
+
+ if (numberToShow != null) {
+ Text(
+ text = numberToShow.toString(),
+ style = MaterialTheme.typography.labelSmallEmphasized,
+ color = textColor,
+ modifier = Modifier.padding(end = 2.dp),
+ )
+ }
+ Chevron(collapsed = collapsed, modifier = Modifier.size(iconSizeDp), color = textColor)
+ }
+ }
+}
+
+@Composable
+private fun ContentScope.PillBackground(hasUnread: Boolean, modifier: Modifier = Modifier) {
+ ElementWithValues(NotificationRowPrimitives.Elements.PillBackground, modifier) {
+ val bgColorNoUnread = notificationElementSurfaceColor()
+ val surfaceColor by
+ animateElementColorAsState(
+ if (hasUnread) MaterialTheme.colorScheme.tertiary else bgColorNoUnread,
+ NotificationRowPrimitives.Values.PillBackgroundColor,
+ )
+ content {
+ Box(
+ modifier =
+ Modifier.drawBehind {
+ drawRoundRect(
+ color = surfaceColor,
+ cornerRadius = CornerRadius(100.dp.toPx(), 100.dp.toPx()),
+ )
+ }
+ )
+ }
+ }
+}
+
+@Composable
+@ReadOnlyComposable
+private fun notificationElementSurfaceColor(): Color {
+ return if (isSystemInDarkTheme()) {
+ Color.White.copy(alpha = 0.15f)
+ } else {
+ MaterialTheme.colorScheme.surfaceContainerHighest
+ }
+}
+
+@Composable
+private fun ContentScope.Chevron(collapsed: Boolean, color: Color, modifier: Modifier = Modifier) {
+ val key = NotificationRowPrimitives.Elements.Chevron
+ ElementWithValues(key, modifier) {
+ val rotation by
+ animateElementFloatAsState(
+ if (collapsed) 0f else 180f,
+ NotificationRowPrimitives.Values.ChevronRotation,
+ )
+ content {
+ Icon(
+ imageVector = Icons.Default.ExpandMore,
+ contentDescription = null,
+ modifier = Modifier.graphicsLayer { rotationZ = rotation },
+ tint = color,
+ )
+ }
+ }
+}
diff --git a/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt
new file mode 100644
index 0000000..d02ae43
--- /dev/null
+++ b/packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt
@@ -0,0 +1,87 @@
+/*
+ * Copyright (C) 2025 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 com.android.systemui.statusbar.notification.row.ui.viewmodel
+
+import android.graphics.drawable.Drawable
+import android.view.View
+import androidx.compose.animation.core.tween
+import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.MotionScheme
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.setValue
+import androidx.compose.ui.unit.dp
+import com.android.compose.animation.scene.MutableSceneTransitionLayoutState
+import com.android.compose.animation.scene.SceneTransitionLayoutState
+import com.android.compose.animation.scene.transitions
+import com.android.systemui.notifications.ui.composable.row.BundleHeader
+import kotlinx.coroutines.CoroutineScope
+
+interface BundleHeaderViewModel {
+ val titleText: String
+ val numberOfChildren: Int?
+ val bundleIcon: Drawable?
+ val previewIcons: List<Drawable>
+
+ val state: SceneTransitionLayoutState
+
+ val hasUnreadMessages: Boolean
+ val backgroundDrawable: Drawable?
+
+ fun onHeaderClicked(scope: CoroutineScope)
+}
+
+class BundleHeaderViewModelImpl : BundleHeaderViewModel {
+ override var titleText by mutableStateOf("")
+ override var numberOfChildren by mutableStateOf<Int?>(1)
+ override var hasUnreadMessages by mutableStateOf(true)
+ override var bundleIcon by mutableStateOf<Drawable?>(null)
+ override var previewIcons by mutableStateOf(listOf<Drawable>())
+ override var backgroundDrawable by mutableStateOf<Drawable?>(null)
+
+ var onExpandClickListener: View.OnClickListener? = null
+
+ @OptIn(ExperimentalMaterial3ExpressiveApi::class)
+ override var state: MutableSceneTransitionLayoutState =
+ MutableSceneTransitionLayoutState(
+ BundleHeader.Scenes.Collapsed,
+ MotionScheme.standard(),
+ transitions {
+ from(BundleHeader.Scenes.Collapsed, to = BundleHeader.Scenes.Expanded) {
+ spec = tween(500)
+ translate(BundleHeader.Elements.PreviewIcon3, x = 32.dp)
+ translate(BundleHeader.Elements.PreviewIcon2, x = 16.dp)
+ fade(BundleHeader.Elements.PreviewIcon1)
+ fade(BundleHeader.Elements.PreviewIcon2)
+ fade(BundleHeader.Elements.PreviewIcon3)
+ }
+ },
+ )
+
+ override fun onHeaderClicked(scope: CoroutineScope) {
+ val targetScene =
+ when (state.currentScene) {
+ BundleHeader.Scenes.Collapsed -> BundleHeader.Scenes.Expanded
+ BundleHeader.Scenes.Expanded -> BundleHeader.Scenes.Collapsed
+ else -> error("Unknown Scene")
+ }
+ state.setTargetScene(targetScene, scope)
+
+ onExpandClickListener?.onClick(null)
+ hasUnreadMessages = false
+ }
+}