Donate to e Foundation | Murena handsets with /e/OS | Own a part of Murena! Learn more

Commit 83248d62 authored by Andreas Miko's avatar Andreas Miko Committed by Android (Google) Code Review
Browse files

Merge "Implement Bundle Header in Compose" into main

parents b4a1b07e 0d7895c7
Loading
Loading
Loading
Loading
+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)
            }
        }
    }
}
+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,
            )
        }
    }
}
+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
    }
}