Loading packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt 0 → 100644 +193 −0 Original line number Diff line number Diff line /* * 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) } } } } packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt 0 → 100644 +201 −0 Original line number Diff line number Diff line /* * 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, ) } } } packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt 0 → 100644 +87 −0 Original line number Diff line number Diff line /* * 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 } } Loading
packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/BundleHeader.kt 0 → 100644 +193 −0 Original line number Diff line number Diff line /* * 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) } } } }
packages/SystemUI/compose/features/src/com/android/systemui/notifications/ui/composable/row/NotificationRowPrimitives.kt 0 → 100644 +201 −0 Original line number Diff line number Diff line /* * 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, ) } } }
packages/SystemUI/src/com/android/systemui/statusbar/notification/row/ui/viewmodel/BundleHeaderViewModel.kt 0 → 100644 +87 −0 Original line number Diff line number Diff line /* * 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 } }