From a08bca15cf00cd953eeb302269b44c3a477008be Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 9 Sep 2022 16:23:42 -0700 Subject: [PATCH 1/2] Adds Compose buttons. Includes logic to add them to the Compose Gallery app. There's probably a lot of work that still needs to go into Buttons.kt but this is a start. Bug: 246002087 Test: Compose gallery app screen for buttons Change-Id: If284f43c205b04009b63d5eab2cc53f3b334e515 --- .../systemui/compose/SystemUiButtons.kt | 110 ++++++++++++++++++ .../systemui/compose/gallery/ButtonsScreen.kt | 77 ++++++++++++ .../systemui/compose/gallery/GalleryApp.kt | 2 + 3 files changed, 189 insertions(+) create mode 100644 packages/SystemUI/compose/core/src/com/android/systemui/compose/SystemUiButtons.kt create mode 100644 packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/ButtonsScreen.kt diff --git a/packages/SystemUI/compose/core/src/com/android/systemui/compose/SystemUiButtons.kt b/packages/SystemUI/compose/core/src/com/android/systemui/compose/SystemUiButtons.kt new file mode 100644 index 0000000000000..496f4b3460f71 --- /dev/null +++ b/packages/SystemUI/compose/core/src/com/android/systemui/compose/SystemUiButtons.kt @@ -0,0 +1,110 @@ +/* + * Copyright (C) 2022 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.compose + +import androidx.compose.foundation.BorderStroke +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.ButtonColors +import androidx.compose.material3.ButtonDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import com.android.systemui.compose.theme.LocalAndroidColorScheme + +@Composable +fun SysUiButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + androidx.compose.material3.Button( + modifier = modifier.padding(vertical = 6.dp).height(36.dp), + colors = filledButtonColors(), + contentPadding = ButtonPaddings, + onClick = onClick, + enabled = enabled, + ) { + content() + } +} + +@Composable +fun SysUiOutlinedButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + androidx.compose.material3.OutlinedButton( + modifier = modifier.padding(vertical = 6.dp).height(36.dp), + enabled = enabled, + colors = outlineButtonColors(), + border = outlineButtonBorder(), + contentPadding = ButtonPaddings, + onClick = onClick, + ) { + content() + } +} + +@Composable +fun SysUiTextButton( + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + content: @Composable RowScope.() -> Unit, +) { + androidx.compose.material3.TextButton( + onClick = onClick, + modifier = modifier, + enabled = enabled, + content = content, + ) +} + +private val ButtonPaddings = PaddingValues(horizontal = 16.dp, vertical = 8.dp) + +@Composable +private fun filledButtonColors(): ButtonColors { + val colors = LocalAndroidColorScheme.current + return ButtonDefaults.buttonColors( + containerColor = colors.colorAccentPrimary, + contentColor = colors.textColorOnAccent, + ) +} + +@Composable +private fun outlineButtonColors(): ButtonColors { + val colors = LocalAndroidColorScheme.current + return ButtonDefaults.outlinedButtonColors( + contentColor = colors.textColorPrimary, + ) +} + +@Composable +private fun outlineButtonBorder(): BorderStroke { + val colors = LocalAndroidColorScheme.current + return BorderStroke( + width = 1.dp, + color = colors.colorAccentPrimaryVariant, + ) +} diff --git a/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/ButtonsScreen.kt b/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/ButtonsScreen.kt new file mode 100644 index 0000000000000..881a1def113a0 --- /dev/null +++ b/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/ButtonsScreen.kt @@ -0,0 +1,77 @@ +/* + * Copyright (C) 2022 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. + * + */ + +@file:OptIn(ExperimentalMaterial3Api::class) + +package com.android.systemui.compose.gallery + +import androidx.compose.foundation.layout.Column +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.android.systemui.compose.SysUiButton +import com.android.systemui.compose.SysUiOutlinedButton +import com.android.systemui.compose.SysUiTextButton + +@Composable +fun ButtonsScreen( + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier, + ) { + SysUiButton( + onClick = {}, + ) { + Text("SysUiButton") + } + + SysUiButton( + onClick = {}, + enabled = false, + ) { + Text("SysUiButton - disabled") + } + + SysUiOutlinedButton( + onClick = {}, + ) { + Text("SysUiOutlinedButton") + } + + SysUiOutlinedButton( + onClick = {}, + enabled = false, + ) { + Text("SysUiOutlinedButton - disabled") + } + + SysUiTextButton( + onClick = {}, + ) { + Text("SysUiTextButton") + } + + SysUiTextButton( + onClick = {}, + enabled = false, + ) { + Text("SysUiTextButton - disabled") + } + } +} diff --git a/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/GalleryApp.kt b/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/GalleryApp.kt index bb98fb350a2e1..2e6456bcc4e11 100644 --- a/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/GalleryApp.kt +++ b/packages/SystemUI/compose/gallery/src/com/android/systemui/compose/gallery/GalleryApp.kt @@ -31,6 +31,7 @@ object GalleryAppScreens { val Typography = ChildScreen("typography") { TypographyScreen() } val MaterialColors = ChildScreen("material_colors") { MaterialColorsScreen() } val AndroidColors = ChildScreen("android_colors") { AndroidColorsScreen() } + val Buttons = ChildScreen("buttons") { ButtonsScreen() } val ExampleFeature = ChildScreen("example_feature") { ExampleFeatureScreen() } val PeopleEmpty = @@ -63,6 +64,7 @@ object GalleryAppScreens { "Material colors" to MaterialColors, "Android colors" to AndroidColors, "Example feature" to ExampleFeature, + "Buttons" to Buttons, "People" to People, ) ) From 4066809d02d589f526f2cc06d8e9af8bfa1b30ca Mon Sep 17 00:00:00 2001 From: Alejandro Nijamkin Date: Fri, 9 Sep 2022 16:29:15 -0700 Subject: [PATCH 2/2] UserSwitcher in Compose. Adds a screen to the gallery app to test it. Did not end up using the UI screenshot diff test because the buttons are not the same in Compose as they are in this bespoke UI. The UI is simple enough that I was able to get close enough. Fix: 246002091 Test: In the next CL, a screen is added to the gallery app to test it. Change-Id: I90d871bb819ecd7bd75a3f55746a94f7a37f68c2 --- .../systemui/common/ui/compose/TextExt.kt | 31 ++ .../user/ui/compose/UserSwitcherScreen.kt | 423 ++++++++++++++++++ 2 files changed, 454 insertions(+) create mode 100644 packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/TextExt.kt create mode 100644 packages/SystemUI/compose/features/src/com/android/systemui/user/ui/compose/UserSwitcherScreen.kt diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/TextExt.kt b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/TextExt.kt new file mode 100644 index 0000000000000..e1f73e304b9e3 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/common/ui/compose/TextExt.kt @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2022 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.common.ui.compose + +import androidx.compose.runtime.Composable +import androidx.compose.ui.res.stringResource +import com.android.systemui.common.shared.model.Text + +/** Returns the loaded [String] or `null` if there isn't one. */ +@Composable +fun Text.load(): String? { + return when (this) { + is Text.Loaded -> text + is Text.Resource -> stringResource(res) + } +} diff --git a/packages/SystemUI/compose/features/src/com/android/systemui/user/ui/compose/UserSwitcherScreen.kt b/packages/SystemUI/compose/features/src/com/android/systemui/user/ui/compose/UserSwitcherScreen.kt new file mode 100644 index 0000000000000..3175dcfa092b6 --- /dev/null +++ b/packages/SystemUI/compose/features/src/com/android/systemui/user/ui/compose/UserSwitcherScreen.kt @@ -0,0 +1,423 @@ +/* + * Copyright (C) 2022 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.user.ui.compose + +import android.graphics.Bitmap +import android.graphics.Canvas +import android.graphics.drawable.Drawable +import androidx.appcompat.content.res.AppCompatResources +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.heightIn +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.sizeIn +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.DropdownMenu +import androidx.compose.material3.DropdownMenuItem +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.painter.ColorPainter +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.colorResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.android.systemui.common.ui.compose.load +import com.android.systemui.compose.SysUiOutlinedButton +import com.android.systemui.compose.SysUiTextButton +import com.android.systemui.compose.features.R +import com.android.systemui.compose.theme.LocalAndroidColorScheme +import com.android.systemui.user.ui.viewmodel.UserActionViewModel +import com.android.systemui.user.ui.viewmodel.UserSwitcherViewModel +import com.android.systemui.user.ui.viewmodel.UserViewModel +import java.lang.Integer.min +import kotlin.math.ceil + +@Composable +fun UserSwitcherScreen( + viewModel: UserSwitcherViewModel, + onFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + val isFinishRequested: Boolean by viewModel.isFinishRequested.collectAsState(false) + val users: List by viewModel.users.collectAsState(emptyList()) + val maxUserColumns: Int by viewModel.maximumUserColumns.collectAsState(1) + val menuActions: List by viewModel.menu.collectAsState(emptyList()) + val isOpenMenuButtonVisible: Boolean by viewModel.isOpenMenuButtonVisible.collectAsState(false) + val isMenuVisible: Boolean by viewModel.isMenuVisible.collectAsState(false) + + UserSwitcherScreenStateless( + isFinishRequested = isFinishRequested, + users = users, + maxUserColumns = maxUserColumns, + menuActions = menuActions, + isOpenMenuButtonVisible = isOpenMenuButtonVisible, + isMenuVisible = isMenuVisible, + onMenuClosed = viewModel::onMenuClosed, + onOpenMenuButtonClicked = viewModel::onOpenMenuButtonClicked, + onCancelButtonClicked = viewModel::onCancelButtonClicked, + onFinished = { + onFinished() + viewModel.onFinished() + }, + modifier = modifier, + ) +} + +@Composable +private fun UserSwitcherScreenStateless( + isFinishRequested: Boolean, + users: List, + maxUserColumns: Int, + menuActions: List, + isOpenMenuButtonVisible: Boolean, + isMenuVisible: Boolean, + onMenuClosed: () -> Unit, + onOpenMenuButtonClicked: () -> Unit, + onCancelButtonClicked: () -> Unit, + onFinished: () -> Unit, + modifier: Modifier = Modifier, +) { + LaunchedEffect(isFinishRequested) { + if (isFinishRequested) { + onFinished() + } + } + + Box( + modifier = + modifier + .fillMaxSize() + .padding( + horizontal = 60.dp, + vertical = 40.dp, + ), + ) { + UserGrid( + users = users, + maxUserColumns = maxUserColumns, + modifier = Modifier.align(Alignment.Center), + ) + + Buttons( + menuActions = menuActions, + isOpenMenuButtonVisible = isOpenMenuButtonVisible, + isMenuVisible = isMenuVisible, + onMenuClosed = onMenuClosed, + onOpenMenuButtonClicked = onOpenMenuButtonClicked, + onCancelButtonClicked = onCancelButtonClicked, + modifier = Modifier.align(Alignment.BottomEnd), + ) + } +} + +@Composable +private fun UserGrid( + users: List, + maxUserColumns: Int, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(44.dp), + modifier = modifier, + ) { + val rowCount = ceil(users.size / maxUserColumns.toFloat()).toInt() + (0 until rowCount).forEach { rowIndex -> + Row( + horizontalArrangement = Arrangement.spacedBy(64.dp), + modifier = modifier, + ) { + val fromIndex = rowIndex * maxUserColumns + val toIndex = min(users.size, (rowIndex + 1) * maxUserColumns) + users.subList(fromIndex, toIndex).forEach { user -> + UserItem( + viewModel = user, + ) + } + } + } + } +} + +@Composable +private fun UserItem( + viewModel: UserViewModel, +) { + val onClicked = viewModel.onClicked + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = + if (onClicked != null) { + Modifier.clickable { onClicked() } + } else { + Modifier + } + .alpha(viewModel.alpha), + ) { + Box { + UserItemBackground(modifier = Modifier.align(Alignment.Center).size(222.dp)) + + UserItemIcon( + image = viewModel.image, + isSelectionMarkerVisible = viewModel.isSelectionMarkerVisible, + modifier = Modifier.align(Alignment.Center).size(222.dp) + ) + } + + // User name + val text = viewModel.name.load() + if (text != null) { + // We use the box to center-align the text vertically as that is not possible with Text + // alone. + Box( + modifier = Modifier.size(width = 222.dp, height = 48.dp), + ) { + Text( + text = text, + style = MaterialTheme.typography.titleLarge, + color = colorResource(com.android.internal.R.color.system_neutral1_50), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.align(Alignment.Center), + ) + } + } + } +} + +@Composable +private fun UserItemBackground( + modifier: Modifier = Modifier, +) { + Image( + painter = ColorPainter(LocalAndroidColorScheme.current.colorBackground), + contentDescription = null, + modifier = modifier.clip(CircleShape), + ) +} + +@Composable +private fun UserItemIcon( + image: Drawable, + isSelectionMarkerVisible: Boolean, + modifier: Modifier = Modifier, +) { + Image( + bitmap = image.toBitmap().asImageBitmap(), + contentDescription = null, + modifier = + if (isSelectionMarkerVisible) { + // Draws a ring + modifier.border( + width = 8.dp, + color = LocalAndroidColorScheme.current.colorAccentPrimary, + shape = CircleShape, + ) + } else { + modifier + } + .padding(16.dp) + .clip(CircleShape) + ) +} + +@Composable +private fun Buttons( + menuActions: List, + isOpenMenuButtonVisible: Boolean, + isMenuVisible: Boolean, + onMenuClosed: () -> Unit, + onOpenMenuButtonClicked: () -> Unit, + onCancelButtonClicked: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier, + ) { + // Cancel button. + SysUiTextButton( + onClick = onCancelButtonClicked, + ) { + Text(stringResource(R.string.cancel)) + } + + // "Open menu" button. + if (isOpenMenuButtonVisible) { + Spacer(modifier = Modifier.width(8.dp)) + // To properly use a DropdownMenu in Compose, we need to wrap the button that opens it + // and the menu itself in a Box. + Box { + SysUiOutlinedButton( + onClick = onOpenMenuButtonClicked, + ) { + Text(stringResource(R.string.add)) + } + Menu( + viewModel = menuActions, + isMenuVisible = isMenuVisible, + onMenuClosed = onMenuClosed, + ) + } + } + } +} + +@Composable +private fun Menu( + viewModel: List, + isMenuVisible: Boolean, + onMenuClosed: () -> Unit, + modifier: Modifier = Modifier, +) { + val maxItemWidth = LocalConfiguration.current.screenWidthDp.dp / 4 + DropdownMenu( + expanded = isMenuVisible, + onDismissRequest = onMenuClosed, + modifier = + modifier.background( + color = MaterialTheme.colorScheme.inverseOnSurface, + ), + ) { + viewModel.forEachIndexed { index, action -> + MenuItem( + viewModel = action, + onClicked = { action.onClicked() }, + topPadding = + if (index == 0) { + 16.dp + } else { + 0.dp + }, + bottomPadding = + if (index == viewModel.size - 1) { + 16.dp + } else { + 0.dp + }, + modifier = Modifier.sizeIn(maxWidth = maxItemWidth), + ) + } + } +} + +@Composable +private fun MenuItem( + viewModel: UserActionViewModel, + onClicked: () -> Unit, + topPadding: Dp, + bottomPadding: Dp, + modifier: Modifier = Modifier, +) { + val context = LocalContext.current + val density = LocalDensity.current + + val icon = + remember(viewModel.iconResourceId) { + val drawable = + checkNotNull(AppCompatResources.getDrawable(context, viewModel.iconResourceId)) + drawable + .toBitmap( + size = with(density) { 20.dp.toPx() }.toInt(), + tintColor = Color.White, + ) + .asImageBitmap() + } + + DropdownMenuItem( + text = { + Text( + text = stringResource(viewModel.textResourceId), + style = MaterialTheme.typography.bodyMedium, + ) + }, + onClick = onClicked, + leadingIcon = { + Spacer(modifier = Modifier.width(10.dp)) + Image( + bitmap = icon, + contentDescription = null, + ) + }, + modifier = + modifier + .heightIn( + min = 56.dp, + ) + .padding( + start = 18.dp, + end = 65.dp, + top = topPadding, + bottom = bottomPadding, + ), + ) +} + +/** + * Converts the [Drawable] to a [Bitmap]. + * + * Note that this is a relatively memory-heavy operation as it allocates a whole bitmap and draws + * the `Drawable` onto it. Use sparingly and with care. + */ +private fun Drawable.toBitmap( + size: Int? = null, + tintColor: Color? = null, +): Bitmap { + val bitmap = + if (intrinsicWidth <= 0 || intrinsicHeight <= 0) { + Bitmap.createBitmap(1, 1, Bitmap.Config.ARGB_8888) + } else { + Bitmap.createBitmap( + size ?: intrinsicWidth, + size ?: intrinsicHeight, + Bitmap.Config.ARGB_8888 + ) + } + val canvas = Canvas(bitmap) + setBounds(0, 0, canvas.width, canvas.height) + if (tintColor != null) { + setTint(tintColor.toArgb()) + } + draw(canvas) + return bitmap +}