Merge changes from topic "picker-move" into main

* changes:
  Add a widget apps list and the header composable for widget picker.
  Add screenshot tests for the widgets grid in picker
  Implement a grid of widgets for displaying in widget picker
  Add widget preview composable for widget previews in picker
  Add details composable for details shown below widget previews in picker
  Add a temporary component to display app icon for widgets list
  Make title optional on the widget picker bottom sheet & update insets
  Add a search bar for widget picker in compose
  Add an option to show shadow on the floating toolbar.
  Add top level reusable layouts for widget picker
This commit is contained in:
Shamali Patwa
2025-05-14 12:53:28 -07:00
committed by Android (Google) Code Review
25 changed files with 2512 additions and 24 deletions
+31
View File
@@ -82,6 +82,20 @@ android_library {
],
}
android_library {
name: "widget_picker_ui_data_types",
sdk_version: "current",
min_sdk_version: min_launcher3_sdk_version,
srcs: [
"src/com/android/launcher3/widgetpicker/ui/model/DisplayableWidgetApp.kt",
"src/com/android/launcher3/widgetpicker/ui/model/WidgetSizeGroup.kt",
],
static_libs: [
"androidx.compose.runtime_runtime",
"widget_picker_shared_data_types",
],
}
android_library {
name: "widget_picker_ui_components",
sdk_version: "current",
@@ -90,8 +104,20 @@ android_library {
"src/com/android/launcher3/widgetpicker/ui/components/ScrollableFloatingToolbar.kt",
"src/com/android/launcher3/widgetpicker/ui/components/LeadingIconToolbarTab.kt",
"src/com/android/launcher3/widgetpicker/ui/components/TitledBottomSheet.kt",
"src/com/android/launcher3/widgetpicker/ui/components/TwoPaneLayout.kt",
"src/com/android/launcher3/widgetpicker/ui/components/SinglePaneLayout.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetsSearchBar.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetAppIcon.kt",
"src/com/android/launcher3/widgetpicker/ui/components/Strings.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetDetails.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetPreview.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetsGrid.kt",
"src/com/android/launcher3/widgetpicker/ui/components/ExpandCollapseIndicator.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsListHeader.kt",
"src/com/android/launcher3/widgetpicker/ui/components/WidgetAppsList.kt",
],
static_libs: [
"widget_picker_ui_data_types",
"androidx.compose.foundation_foundation",
"androidx.compose.foundation_foundation-layout",
"androidx.compose.runtime_runtime",
@@ -102,6 +128,11 @@ android_library {
"androidx.compose.material_material-icons-extended",
"androidx.activity_activity-compose",
"widget_picker_window_size_class",
"kotlinx-coroutines-android",
"kotlinx-coroutines-core",
],
resource_dirs: [
"res",
],
}
@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="UTF-8"?><!--
~ 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.
-->
<resources xmlns:android="http://schemas.android.com/apk/res/android" xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<!-- Placeholder text shown when app name is not loaded yet to display in the widget picker [CHAR_LIMIT=25] -->
<string name="widgets_list_header_app_name_fallback_label">App name</string>
<!-- Label for showing the number of widgets an app has in the full widgets picker.
[CHAR_LIMIT=25] -->
<string name="widgets_list_header_widgets_count_label">{count, plural,
=1 {# widget}
other {# widgets}
}</string>
<!-- The format string for the cell dimensions of a widget in the widget picker e.g. 2x2 -->
<!-- There is a special version of this format string for Farsi (%1$dx%2$d) -->
<string name="widget_span_dimensions_format">%1$d \u00d7 %2$d</string>
<!-- Accessibility spoken message format for the cell dimensions of a widget in the widget picker -->
<string name="widget_span_dimensions_accessible_format">%1$d wide by %2$d high</string>
<!-- Tab / list header label. A user can tap this to access recommended (or featured) widgets.
[CHAR_LIMIT=25] -->
<string name="featured_widgets_tab_label">Featured</string>
<!-- Search bar strings -->
<!-- Placeholder text displayed in the widget's search bar [CHAR_LIMIT=50] -->
<string name="widgets_search_bar_hint">Search</string>
<!-- Accessibility label for the button to clear text in widgets search bar -->
<string name="widget_search_bar_clear_button_label">Clear</string>
<!-- Accessibility label for the button to go back from the search screen -->
<string name="widget_search_bar_back_button_label">Back</string>
</resources>
@@ -36,6 +36,31 @@ data class PickableWidget(
val id: WidgetId,
val appId: WidgetAppId,
val label: String,
val description: String,
val description: CharSequence?,
val appWidgetProviderInfo: AppWidgetProviderInfo,
val sizeInfo: WidgetSizeInfo,
)
/**
* Sizing information for a specific widget shown in a grid.
*
* @param spanX the number of horizontal cells in the host's grid that this widget takes
* @param spanY the number of vertical cells in the host's grid that this widget takes
* @param widthPx the width in pixels that the widget should ideally be sized at based on host's
* grid
* @param heightPx the height in pixels that the widget should ideally be sized at based on host's
* grid
* @param containerWidthPx the width of container in which the widget may need to be fit to; For
* instance, for visual coherence, widgets of sizes like 3x2 are shown in 2x2 container based on a
* predefined mapping logic. This allows us to show them in a single row when space permits.
* [containerWidthPx] is the width in pixel for such a container. [containerHeightPx] is the
* height in pixels for the container spans.
*/
data class WidgetSizeInfo(
val spanX: Int,
val spanY: Int,
val widthPx: Int,
val heightPx: Int,
val containerWidthPx: Int,
val containerHeightPx: Int,
)
@@ -0,0 +1,77 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.KeyboardArrowDown
import androidx.compose.material.icons.rounded.KeyboardArrowUp
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.unit.dp
/** A visual cue displayed on list headers to indicate its current expand / collapse state. */
@Composable
fun ExpandCollapseIndicator(
expanded: Boolean,
modifier: Modifier = Modifier,
iconColor: Color = ExpandCollapseIndicatorDefaults.iconColor,
backgroundColor: Color = ExpandCollapseIndicatorDefaults.backgroundColor,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
modifier
.size(ExpandCollapseIndicatorDimensions.size)
.clip(ExpandCollapseIndicatorDimensions.cornerShape)
.background(backgroundColor),
) {
Icon(
modifier = Modifier.size(ExpandCollapseIndicatorDimensions.iconSize),
tint = iconColor,
contentDescription = null, // Decorative
imageVector =
if (expanded) {
Icons.Rounded.KeyboardArrowUp
} else {
Icons.Rounded.KeyboardArrowDown
},
)
}
}
private object ExpandCollapseIndicatorDimensions {
val cornerShape = RoundedCornerShape(50.dp)
val size = 24.dp
val iconSize = 16.dp
}
private object ExpandCollapseIndicatorDefaults {
val iconColor
@Composable get() = MaterialTheme.colorScheme.onSecondaryContainer
val backgroundColor
@Composable get() = MaterialTheme.colorScheme.secondaryContainer
}
@@ -72,10 +72,10 @@ fun LeadingIconToolbarTab(
modifier =
Modifier.fillMaxWidth()
.clip(CircleShape)
.clickable { onClick() }
.background(color = backgroundColor)
.minimumInteractiveComponentSize()
.padding(horizontal = LeadingIconToolbarTabDefaults.horizontalPadding)
.clickable { onClick() },
.padding(horizontal = LeadingIconToolbarTabDefaults.horizontalPadding),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.Center,
) {
@@ -31,7 +31,6 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.clipToBounds
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
@@ -59,20 +58,20 @@ import kotlinx.coroutines.launch
*
* This design is suitable for 2-3 tabs.
*
* @property tabs A list of tabs (typically [LeadingIconToolbarTab]) that can be arranged in a
* @param tabs A list of tabs (typically [LeadingIconToolbarTab]) that can be arranged in a
* scrollable row.
* @property selectedTabIndex the tab that is currently selected; this enables bringing the tab into
* @param selectedTabIndex the tab that is currently selected; this enables bringing the tab into
* the visible area.
* @property modifier additional modifications to be applied to the top level of the toolbar.
* @property shape shape to clip the contents of the toolbar; defaults to fully rounded corners.
* @property edgePadding padding applied horizontally on sides of the toolbar; in case of long
* length tabs, visually the padding will appear only on left (in LTR for instance) and when you
* scroll completely to right, the padding will appear on right; in case tab content is smaller in
* @param modifier additional modifications to be applied to the top level of the toolbar.
* @param shape shape to clip the contents of the toolbar; defaults to fully rounded corners.
* @param edgePadding padding applied horizontally on sides of the toolbar; in case of long length
* tabs, visually the padding will appear only on left (in LTR for instance) and when you scroll
* completely to right, the padding will appear on right; in case tab content is smaller in
* length, the padding appears on both sides.
* @property maxWidth if the toolbar needs to be constraint to a specific width.
* @property minTabWidth minimum width to be guaranteed for individual tabs
* @property containerColor color to be applied to the surface of toolbar
* @property scrollState the [ScrollState] of the toolbar's scrollable tab content
* @param maxWidth if the toolbar needs to be constraint to a specific width.
* @param minTabWidth minimum width to be guaranteed for individual tabs
* @param containerColor color to be applied to the surface of toolbar
* @param shadowElevation The size of the shadow below the surface.
*/
@Composable
fun ScrollableFloatingToolbar(
@@ -84,6 +83,7 @@ fun ScrollableFloatingToolbar(
maxWidth: Dp = ScrollableFloatingToolbarDefaults.maxWidth,
minTabWidth: Dp = ScrollableFloatingToolbarDefaults.minTabWidth,
containerColor: Color = ScrollableFloatingToolbarDefaults.containerColor,
shadowElevation: Dp = ScrollableFloatingToolbarDefaults.shadowElevation,
) {
check(tabs.size in 2..3) { "Unexpected number of tabs: ${tabs.size}. Suitable for 2-3 tabs." }
@@ -91,7 +91,9 @@ fun ScrollableFloatingToolbar(
Surface(
color = containerColor,
modifier = modifier.clip(shape).wrapContentSize(align = Alignment.Center),
shadowElevation = shadowElevation,
shape = shape,
modifier = modifier.wrapContentSize(align = Alignment.Center),
) {
ScrollableTabsLayout(
tabs = tabs,
@@ -237,6 +239,8 @@ object ScrollableFloatingToolbarDefaults {
val maxWidth: Dp = 348.dp
val minTabWidth: Dp = 90.dp
val shadowElevation: Dp = 3.dp
val containerColor: Color
@Composable get() = MaterialTheme.colorScheme.surfaceBright
}
@@ -0,0 +1,79 @@
/*
* 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.launcher3.widgetpicker.ui.components
/*
* 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.
*/
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.ui.components.SinglePaneLayoutDimensions.searchBarBottomMargin
/**
* A layout that shows all the widget picker content in a column like pane.
*
* @param searchBar A sticky search bar shown on top in the left pane.
* @param toolbar an option toolbar shown at bottom of the screen that allows users to select what
* to see in the [content]
* @param content the primary content e.g. widgets expand collapse list.
*/
@Composable
fun SinglePaneLayout(
searchBar: @Composable () -> Unit,
toolbar: (@Composable () -> Unit)? = null,
content: @Composable () -> Unit,
) {
val topContent: @Composable ColumnScope.() -> Unit = {
Column(modifier = Modifier.fillMaxWidth().weight(1f)) {
searchBar()
Spacer(modifier = Modifier.fillMaxWidth().height(searchBarBottomMargin))
content()
}
}
val bottomContent: @Composable ColumnScope.() -> Unit = { toolbar?.let { it() } }
Column(modifier = Modifier.fillMaxSize(), horizontalAlignment = Alignment.CenterHorizontally) {
topContent()
bottomContent()
}
}
private object SinglePaneLayoutDimensions {
val searchBarBottomMargin = 16.dp
}
@@ -0,0 +1,36 @@
/*
* 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.launcher3.widgetpicker.ui.components
import android.icu.text.MessageFormat
import androidx.compose.runtime.Composable
import androidx.compose.ui.res.stringResource
import com.android.launcher3.widgetpicker.R
import java.util.Locale
/** Helper to build a string representing widgets count */
@Composable
fun widgetsCountString(count: Int): String {
val icuCountFormat =
MessageFormat(
stringResource(R.string.widgets_list_header_widgets_count_label),
Locale.getDefault(),
)
return icuCountFormat.format(mapOf(COUNT_KEY to count))
}
private const val COUNT_KEY = "count"
@@ -26,7 +26,6 @@ import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.union
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.ExperimentalMaterial3Api
@@ -59,7 +58,7 @@ import com.android.launcher3.widgetpicker.ui.windowsizeclass.isExtraTall
* structure for different types of widget pickers.
*
* @param modifier modifier to be applies to the bottom sheet container.
* @param title A top level title for the bottom sheet.
* @param title A top level title for the bottom sheet. If title is absent, top header isn't shown.
* @param description an optional short (1-2 line) description that can be shown below the title.
* @param heightStyle indicates how much vertical space should the bottom sheet take; see
* [ModalBottomSheetHeightStyle].
@@ -72,7 +71,7 @@ import com.android.launcher3.widgetpicker.ui.windowsizeclass.isExtraTall
@OptIn(ExperimentalMaterial3Api::class)
fun TitledBottomSheet(
modifier: Modifier = Modifier,
title: String,
title: String?,
description: String?,
heightStyle: ModalBottomSheetHeightStyle,
showDragHandle: Boolean = true,
@@ -103,12 +102,12 @@ fun TitledBottomSheet(
ModalBottomSheet(
sheetState = modalBottomSheetState,
sheetGesturesEnabled = false,
sheetMaxWidth = Dp.Unspecified,
containerColor = MaterialTheme.colorScheme.surfaceContainer,
onDismissRequest = onDismissRequest,
dragHandle = dragHandle,
modifier =
modifier.windowInsetsPadding(WindowInsets.statusBars.union(WindowInsets.displayCutout)),
modifier = modifier.windowInsetsPadding(WindowInsets.statusBars),
) {
Column(
modifier =
@@ -116,8 +115,8 @@ fun TitledBottomSheet(
.padding(horizontal = sheetInnerHorizontalPadding)
.padding(top = sheetInnerTopPadding.takeIf { !showDragHandle } ?: 0.dp)
) {
Header(title = title, description = description)
content()
title?.let { Header(title = title, description = description) }
Box(modifier = Modifier.windowInsetsPadding(WindowInsets.displayCutout)) { content() }
}
}
}
@@ -161,7 +160,7 @@ private fun Header(title: String, description: String?) {
private object TitledBottomSheetDimens {
val sheetInnerTopPadding = 16.dp
val sheetInnerHorizontalPadding = 10.dp
val headerBottomMargin = 24.dp
val headerBottomMargin = 16.dp
}
private object DragHandleDimens {
@@ -0,0 +1,125 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.paneTitle
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.LEFT_PANE_WEIGHT
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.RIGHT_PANE_WEIGHT
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.horizontalPadding
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.paneSpacing
import com.android.launcher3.widgetpicker.ui.components.TwoPaneLayoutDimensions.searchBarBottomMargin
/**
* A layout that splits the widget picker content into two panes where the left pane takes about
* [LEFT_PANE_WEIGHT] space while the right pane takes the remaining.
*
* The left pane always has a [searchBar] on the top and the left and right panes contain dynamic
* content based on the user's actions.
*
* @param searchBar A sticky search bar shown on top in the left pane.
* @param leftContent list options available for user to select that will be shown on left
* @param rightContent content for the currently selected list option
* @param rightPaneTitle when a user selects an option on left pane, changing this title for right
* pane guides the user that content for selected option is now visible on right. When using
* accessibility services like talkback, after selecting an option on left, the users can use four
* finger swipe down to move focus to the right pane.
* @param rightPaneBackgroundColor color to use for that background of content on right.
*/
@Composable
fun TwoPaneLayout(
searchBar: @Composable () -> Unit,
leftContent: @Composable () -> Unit,
rightContent: @Composable () -> Unit,
rightPaneTitle: String?,
rightPaneBackgroundColor: Color = TwoPaneLayoutDefaults.rightPaneBackgroundColor,
) {
val rightPaneModifier =
if (rightPaneTitle != null) {
Modifier.semantics { paneTitle = rightPaneTitle }
} else Modifier
val leftPane: @Composable RowScope.() -> Unit = {
Column(
modifier =
Modifier.semantics { isTraversalGroup = true }
.fillMaxHeight()
.padding(end = paneSpacing)
.weight(LEFT_PANE_WEIGHT)
) {
searchBar()
Spacer(modifier = Modifier.height(searchBarBottomMargin).fillMaxWidth())
leftContent()
}
}
val rightPane: @Composable RowScope.() -> Unit = {
Box(
contentAlignment = Alignment.Center,
modifier =
rightPaneModifier
.fillMaxHeight()
.weight(RIGHT_PANE_WEIGHT)
.clip(TwoPaneLayoutDimensions.rightPaneShape)
.background(rightPaneBackgroundColor),
) {
rightContent()
}
}
Row(modifier = Modifier.padding(horizontal = horizontalPadding).fillMaxSize()) {
leftPane()
rightPane()
}
}
private object TwoPaneLayoutDimensions {
const val LEFT_PANE_WEIGHT = 0.37f
const val RIGHT_PANE_WEIGHT = 1 - LEFT_PANE_WEIGHT
val horizontalPadding = 14.dp
val paneSpacing = 16.dp
val searchBarBottomMargin = 16.dp
val rightPaneShape = RoundedCornerShape(28.dp)
}
private object TwoPaneLayoutDefaults {
val rightPaneBackgroundColor
@Composable get() = MaterialTheme.colorScheme.surfaceBright
}
@@ -0,0 +1,122 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.res.colorResource
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.shared.model.AppIcon
import com.android.launcher3.widgetpicker.shared.model.AppIconBadge
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
/** An app icon rendered from the provided [WidgetAppIcon] with an option badge. */
@Composable
fun WidgetAppIcon(widgetAppIcon: WidgetAppIcon, size: AppIconSize) {
val appIcon = widgetAppIcon.icon
val badge = widgetAppIcon.badge
AnimatedContent(targetState = appIcon) { icon ->
when (icon) {
AppIcon.PlaceHolderAppIcon -> PlaceholderAppIcon(size)
is AppIcon.LowResColorIcon -> LowResAppIcon(size, icon)
is AppIcon.HighResBitmapIcon -> {
HighResAppIcon(size, icon, badge)
}
}
}
}
@Composable
private fun HighResAppIcon(
size: AppIconSize,
icon: AppIcon.HighResBitmapIcon,
badge: AppIconBadge,
) {
Box(modifier = Modifier.size(size.iconSize)) {
Icon(
bitmap = icon.bitmap.asImageBitmap(),
modifier = Modifier.fillMaxSize().clip(CircleShape),
contentDescription = null,
tint = Color.Unspecified,
)
if (badge is AppIconBadge.DrawableBadge) {
DrawableAppIconBadge(badge = badge, size = size)
}
}
}
@Composable
private fun LowResAppIcon(size: AppIconSize, icon: AppIcon.LowResColorIcon) {
Box(
modifier =
Modifier.size(size.iconSize).background(color = Color(icon.color), shape = CircleShape)
)
}
@Composable
private fun PlaceholderAppIcon(size: AppIconSize) {
Box(
modifier =
Modifier.size(size.iconSize)
.background(
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.2f),
shape = CircleShape,
)
)
}
@Composable
private fun BoxScope.DrawableAppIconBadge(badge: AppIconBadge.DrawableBadge, size: AppIconSize) {
Icon(
painter = painterResource(badge.drawableResId),
modifier =
Modifier.align(alignment = Alignment.BottomEnd)
.size(size.badgeSize)
.background(color = Color.White, shape = CircleShape)
.shadow(elevation = 0.5.dp, shape = CircleShape, spotColor = Color(0x11000000)),
contentDescription = null,
tint = colorResource(badge.tintColor),
)
}
/** Size in which to display the app icon. */
enum class AppIconSize(val iconSize: Dp, val badgeSize: Dp) {
/** A large size app icon meant to be displayed in the list header. */
MEDIUM(iconSize = 48.dp, badgeSize = 24.dp),
/** A small size app icon meant to be displayed along side the widget title / label. */
SMALL(iconSize = 24.dp, badgeSize = 12.dp),
}
@@ -0,0 +1,229 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.R
import com.android.launcher3.widgetpicker.shared.model.AppIcon
import com.android.launcher3.widgetpicker.shared.model.AppIconBadge
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.ui.model.DisplayableWidgetApp
/**
* Displays a various apps on device that host widgets.
*
* Apps are displays as a header that user can select OR expand/collapse depending on the
* [widgetAppHeaderStyle].
*/
@Composable
fun WidgetAppsList(
widgetApps: List<DisplayableWidgetApp>,
selectedWidgetAppId: WidgetAppId?,
widgetAppHeaderStyle: WidgetAppHeaderStyle,
modifier: Modifier,
onWidgetAppClick: (DisplayableWidgetApp) -> Unit,
appIcons: Map<WidgetAppId, WidgetAppIcon>,
widgetPreviews: Map<WidgetId, WidgetPreview>,
headerDescriptionStyle: AppHeaderDescriptionStyle = AppHeaderDescriptionStyle.WIDGETS_COUNT,
) {
val listState = rememberLazyListState()
LazyColumn(
state = listState,
verticalArrangement = Arrangement.spacedBy(WidgetAppsListDimensions.itemSpacing),
modifier = modifier.clip(WidgetAppsListDimensions.largeShape),
) {
items(
count = widgetApps.size,
key = { index -> widgetApps[index].id.toString() },
contentType = { widgetAppHeaderStyle },
) { index ->
val widgetApp = widgetApps[index]
val selected = widgetApp.id == selectedWidgetAppId
val title = widgetApp.widgetHeaderTitle()
val description = widgetApp.widgetHeaderDescription(headerDescriptionStyle)
val appIconForItem =
remember(appIcons) {
appIcons[widgetApp.id]
?: WidgetAppIcon(AppIcon.PlaceHolderAppIcon, AppIconBadge.NoBadge)
}
val appIcon: @Composable () -> Unit =
remember(appIconForItem) {
{ WidgetAppIcon(widgetAppIcon = appIconForItem, size = AppIconSize.MEDIUM) }
}
when (widgetAppHeaderStyle) {
WidgetAppHeaderStyle.EXPANDABLE -> {
ExpandableWidgetAppHeader(
isFirst = index == 0,
isLast = index == widgetApps.lastIndex,
expanded = selected,
widgetApp = widgetApp,
appIcon = appIcon,
title = title,
description = description,
widgetPreviews =
if (selected) {
widgetPreviews
} else {
emptyMap()
},
onWidgetAppClick = onWidgetAppClick,
)
}
WidgetAppHeaderStyle.CLICKABLE ->
SelectableListHeader(
modifier = Modifier.fillMaxWidth(),
leadingAppIcon = appIcon,
selected = selected,
title = title,
subTitle = description,
shape = WidgetAppsListDimensions.largeShape,
onSelect = { onWidgetAppClick(widgetApp) },
)
}
}
}
}
@Composable
private fun ExpandableWidgetAppHeader(
isLast: Boolean,
isFirst: Boolean,
expanded: Boolean,
widgetApp: DisplayableWidgetApp,
appIcon: @Composable () -> Unit,
title: String,
description: String,
widgetPreviews: Map<WidgetId, WidgetPreview>,
onWidgetAppClick: (DisplayableWidgetApp) -> Unit,
) {
val expandedContent: @Composable () -> Unit =
remember(widgetApp, widgetPreviews) {
{
WidgetsGrid(
widgetSizeGroups = widgetApp.widgetSizeGroups,
showAllWidgetDetails = true,
previews = widgetPreviews,
modifier =
Modifier.fillMaxWidth()
.background(
color = MaterialTheme.colorScheme.surfaceBright,
shape =
when {
isLast -> WidgetAppsListDimensions.bottomLargeShape
else -> WidgetAppsListDimensions.smallShape
},
),
)
}
}
ExpandableListHeader(
modifier = Modifier.fillMaxWidth(),
expanded = expanded,
leadingAppIcon = appIcon,
title = title,
subTitle = description,
expandedContent = expandedContent,
onClick = { onWidgetAppClick(widgetApp) },
shape =
when {
isFirst -> WidgetAppsListDimensions.topLargeShape
isLast && !expanded -> WidgetAppsListDimensions.bottomLargeShape
else -> WidgetAppsListDimensions.smallShape
},
)
}
@Composable
private fun DisplayableWidgetApp.widgetHeaderTitle(): String {
return title?.toString() ?: stringResource(R.string.widgets_list_header_app_name_fallback_label)
}
@Composable
private fun DisplayableWidgetApp.widgetHeaderDescription(style: AppHeaderDescriptionStyle): String {
return when (style) {
AppHeaderDescriptionStyle.WIDGETS_COUNT -> widgetsCountString(widgetsCount)
AppHeaderDescriptionStyle.COMBINED_WIDGETS_TITLE ->
widgetSizeGroups.flatMap { it.widgets }.map { it.label }.joinToString { it }
}
}
/** Type of app Header. */
enum class WidgetAppHeaderStyle {
// Clicking selects the item. Uses header background color to highlight that header is selected.
CLICKABLE,
// Clicking expands the item. Uses a arrow icon at end to indicate that header is selected.
EXPANDABLE,
}
enum class AppHeaderDescriptionStyle {
WIDGETS_COUNT,
COMBINED_WIDGETS_TITLE,
}
private object WidgetAppsListDimensions {
val itemSpacing = 4.dp
val largeRadius = 24.dp
val smallRadius = 4.dp
/** For entire list and clickable headers */
val largeShape = RoundedCornerShape(largeRadius)
/** For first expandable item */
val topLargeShape =
RoundedCornerShape(
topStart = largeRadius,
topEnd = largeRadius,
bottomStart = smallRadius,
bottomEnd = smallRadius,
)
/** For last expandable item -- when in collapsed state */
val bottomLargeShape =
RoundedCornerShape(
topStart = smallRadius,
topEnd = smallRadius,
bottomStart = largeRadius,
bottomEnd = largeRadius,
)
/** For middle expandable items and last expandable item when in expanded state. */
val smallShape = RoundedCornerShape(smallRadius)
}
@@ -0,0 +1,310 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.core.tween
import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Star
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.selected
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.R
/**
* A list header in widget picker that when [expanded] displays the [expandedContent].
*
* Useful for single pane layouts where content is shown inline with the header on selection.
*
* @param modifier modifier for the top level composable of header
* @param expanded whether to show the [expandedContent] below the header
* @param leadingAppIcon an app icon shown in the beginning of the header row
* @param title a short 1 line title for the header
* @param subTitle a short 1 line description (e.g. number of items in the [expandedContent]).
* @param expandedContent the content for the header when its selected
* @param onClick action to perform on click; e.g. manage the expand / collapse state
* @param shape shape for the header e.g. a different shape based on position in the list
*/
@Composable
fun ExpandableListHeader(
modifier: Modifier,
expanded: Boolean,
leadingAppIcon: @Composable () -> Unit,
title: String,
subTitle: String,
expandedContent: @Composable () -> Unit,
onClick: () -> Unit,
shape: RoundedCornerShape,
) {
val finalModifier =
modifier
.clip(shape = shape)
.background(color = ExpandedListHeaderDefaults.backgroundColor)
.clickable { onClick() }
Column(modifier = finalModifier) {
WidgetAppHeader(
modifier = Modifier,
leadingIcon = { leadingAppIcon() },
title = title,
subTitle = subTitle,
selected = expanded,
trailingButton = { ExpandCollapseIndicator(expanded) },
)
AnimatedVisibility(
visible = expanded,
enter = ExpandedListHeaderDefaults.contentExpandAnimationSpec,
exit = ExpandedListHeaderDefaults.contentCollapseAnimationSpec,
modifier = Modifier.fillMaxWidth(),
) {
expandedContent()
}
}
}
/**
* A list header in widget picker that is selectable by clicking it.
*
* Useful for two pane layouts where content is shown in right pane while header is shown in left.
*
* @param modifier modifier for the top level composable of header
* @param selected whether to show highlight the header's background to indicate its currently
* selected.
* @param leadingAppIcon an app icon shown in the beginning of the header row
* @param title a short 1 line title for the header
* @param subTitle a short 1 line description (e.g. number of widgets in the selected app).
* @param onSelect action to perform when user clicks to select the header
* @param shape shape for the header e.g. depending on position in the list, a different corner
* @param selectedBackgroundColor background color when header is [selected]
* @param unSelectedBackgroundColor background color when header is not [selected]
*/
@Composable
fun SelectableListHeader(
modifier: Modifier,
selected: Boolean,
leadingAppIcon: @Composable () -> Unit,
title: String,
subTitle: String,
onSelect: () -> Unit,
shape: RoundedCornerShape,
selectedBackgroundColor: Color = ClickableListHeaderDefaults.selectedBackgroundColor,
unSelectedBackgroundColor: Color = ClickableListHeaderDefaults.unSelectedBackgroundColor,
) {
val clickModifier =
if (!selected) {
Modifier.clickable { onSelect() }
} else {
Modifier
}
WidgetAppHeader(
modifier =
modifier
.semantics(mergeDescendants = true) { this.selected = selected }
.clip(shape = shape)
.background(
color =
if (selected) {
selectedBackgroundColor
} else {
unSelectedBackgroundColor
}
)
.then(clickModifier),
leadingIcon = { leadingAppIcon() },
title = title,
subTitle = subTitle,
selected = selected,
)
}
/**
* A selectable header that can be shown for suggested (featured) widgets option.
*
* @param modifier modifier for top level composable of the suggestions header.
* @param selected if the header is currently selected.
* @param count number of suggested widgets.
* @param onSelect action to perform when user selects the header.
* @param shape shape for the header e.g. depending on position in the list, a different corner.
* @param selectedBackgroundColor background color when header is [selected].
* @param unSelectedBackgroundColor background color when header is not [selected].
*/
@Composable
fun SelectableSuggestionsHeader(
modifier: Modifier,
selected: Boolean,
count: Int,
onSelect: () -> Unit,
shape: RoundedCornerShape,
selectedBackgroundColor: Color = ClickableListHeaderDefaults.selectedBackgroundColor,
unSelectedBackgroundColor: Color = ClickableListHeaderDefaults.unSelectedBackgroundColor,
) {
SelectableListHeader(
modifier = modifier,
selected = selected,
shape = shape,
selectedBackgroundColor = selectedBackgroundColor,
unSelectedBackgroundColor = unSelectedBackgroundColor,
title = stringResource(R.string.featured_widgets_tab_label),
subTitle = widgetsCountString(count),
leadingAppIcon = {
Icon(
imageVector = Icons.Filled.Star,
contentDescription = null,
modifier =
Modifier.clip(shape)
.background(MaterialTheme.colorScheme.surfaceBright)
.minimumInteractiveComponentSize(),
)
},
onSelect = {
if (!selected) {
onSelect()
}
},
)
}
@Composable
private fun WidgetAppHeader(
modifier: Modifier,
leadingIcon: @Composable () -> Unit,
title: String,
subTitle: String,
selected: Boolean,
trailingButton: (@Composable () -> Unit)? = null,
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier =
modifier
.height(height = ListHeaderDimensions.headerHeight)
.padding(horizontal = ListHeaderDimensions.headerHorizontalPadding),
) {
leadingIcon()
CenterText(
modifier =
Modifier.weight(1f)
.padding(horizontal = ListHeaderDimensions.centerTextHorizontalPadding),
title = title,
subTitle = subTitle,
selected = selected,
)
trailingButton?.let { it() }
}
}
@Composable
private fun CenterText(title: String, subTitle: String, selected: Boolean, modifier: Modifier) {
Column(modifier = modifier) {
Text(
text = title,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = WidgetAppListHeaderDefaults.titleTextColor,
style =
if (selected) {
WidgetAppListHeaderDefaults.selectedTitleTextStyle
} else {
WidgetAppListHeaderDefaults.unSelectedTitleTextStyle
},
)
Text(
text = subTitle,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = WidgetAppListHeaderDefaults.subTitleTextColor,
style =
if (selected) {
WidgetAppListHeaderDefaults.selectedSubTitleTextStyle
} else {
WidgetAppListHeaderDefaults.unSelectedSubTitleTextStyle
},
)
}
}
private object ListHeaderDimensions {
val headerHeight = 80.dp
val headerHorizontalPadding = 16.dp
val centerTextHorizontalPadding = 16.dp
}
private object ExpandedListHeaderDefaults {
val backgroundColor: Color
@Composable get() = MaterialTheme.colorScheme.surfaceBright
val contentExpandAnimationSpec = fadeIn(tween(durationMillis = 500)) + expandVertically()
val contentCollapseAnimationSpec = fadeOut(tween(durationMillis = 500)) + shrinkVertically()
}
private object ClickableListHeaderDefaults {
val selectedBackgroundColor
@Composable get() = MaterialTheme.colorScheme.secondaryContainer
val unSelectedBackgroundColor
@Composable get() = Color.Transparent
}
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
private object WidgetAppListHeaderDefaults {
val selectedTitleTextStyle: TextStyle
@Composable
get() = MaterialTheme.typography.titleMediumEmphasized.copy(fontWeight = FontWeight.Medium)
val unSelectedTitleTextStyle: TextStyle
@Composable
get() = MaterialTheme.typography.titleMedium.copy(fontWeight = FontWeight.Normal)
val selectedSubTitleTextStyle: TextStyle
@Composable get() = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Medium)
val unSelectedSubTitleTextStyle: TextStyle
@Composable get() = MaterialTheme.typography.titleSmall.copy(fontWeight = FontWeight.Normal)
val titleTextColor: Color
@Composable get() = MaterialTheme.colorScheme.onSurface
val subTitleTextColor: Color
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
}
@@ -0,0 +1,139 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxHeight
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.R
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
/**
* Displays the details of the widget that can be shown below their previews.
*
* @param widget the information about the widget that can be used to display the details
* @param appIcon an optional app icon that can be displayed when widget is shown outside of the
* app's context e.g. in recommendations.
* @param showAllDetails when set, besides the widget label, also shows widget spans and 1-3 line
* long description
* @param modifier modifier for the top level composable.
*/
@Composable
fun WidgetDetails(
widget: PickableWidget,
appIcon: (@Composable () -> Unit)?,
showAllDetails: Boolean,
modifier: Modifier,
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.Top,
modifier = modifier.fillMaxSize().padding(WidgetDetailsDimension.padding),
) {
WidgetLabel(label = widget.label, appIcon = appIcon, modifier = Modifier)
if (showAllDetails) {
WidgetSpanSizeLabel(spanX = widget.sizeInfo.spanX, spanY = widget.sizeInfo.spanY)
widget.description?.let { WidgetDescription(it) }
}
}
}
/** The label / short title of the widget provided by the developer in the manifest. */
@Composable
private fun WidgetLabel(label: String, appIcon: (@Composable () -> Unit)?, modifier: Modifier) {
Row(modifier = modifier, horizontalArrangement = Arrangement.Center) {
if (appIcon != null) {
appIcon()
Spacer(
modifier =
Modifier.width(WidgetDetailsDimension.appIconLabelSpacing).fillMaxHeight()
)
}
Text(
text = label,
overflow = TextOverflow.Ellipsis,
maxLines = 1,
textAlign = TextAlign.Center,
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface,
fontWeight = FontWeight.Medium,
),
)
}
}
/**
* Display a long description provided by the developers for the widget in their appwidget provider
* info.
*/
@Composable
private fun WidgetDescription(description: CharSequence) {
Text(
text = description.toString(),
overflow = TextOverflow.Ellipsis,
textAlign = TextAlign.Center,
maxLines = 3,
style =
MaterialTheme.typography.bodySmall.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Normal,
),
)
}
/** Span (X and Y) sizing info for the widget. */
@Composable
private fun WidgetSpanSizeLabel(spanX: Int, spanY: Int) {
val contentDescription =
stringResource(R.string.widget_span_dimensions_accessible_format, spanX, spanY)
Text(
text = stringResource(R.string.widget_span_dimensions_format, spanX, spanY),
textAlign = TextAlign.Center,
maxLines = 1,
style =
MaterialTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurfaceVariant,
fontWeight = FontWeight.Normal,
),
modifier = Modifier.semantics { this.contentDescription = contentDescription },
)
}
private object WidgetDetailsDimension {
val padding: Dp = 4.dp
val appIconLabelSpacing = 8.dp
}
@@ -0,0 +1,143 @@
/*
* 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.launcher3.widgetpicker.ui.components
import android.graphics.Bitmap
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf
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.clip
import androidx.compose.ui.graphics.asImageBitmap
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
import androidx.compose.ui.unit.coerceAtMost
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo
/** Renders a different types of preview for an appwidget. */
@Composable
fun WidgetPreview(sizeInfo: WidgetSizeInfo, preview: WidgetPreview, modifier: Modifier = Modifier) {
val widgetRadius = dimensionResource(android.R.dimen.system_app_widget_background_radius)
val density = LocalDensity.current
val containerSize =
with(density) {
DpSize(sizeInfo.containerWidthPx.toDp(), sizeInfo.containerHeightPx.toDp())
}
Box(modifier = modifier.wrapContentSize()) {
when (preview) {
is WidgetPreview.PlaceholderWidgetPreview ->
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
is WidgetPreview.BitmapWidgetPreview ->
BitmapWidgetPreview(
bitmap = preview.bitmap,
size = containerSize,
widgetRadius = widgetRadius,
)
// TODO(b/408283627): Add Remote Views preview.
is WidgetPreview.RemoteViewsWidgetPreview ->
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
// TODO(b/408283627): Add Generated previews.
is WidgetPreview.ProviderInfoWidgetPreview ->
PlaceholderWidgetPreview(size = containerSize, widgetRadius = widgetRadius)
}
}
}
@Composable
private fun PlaceholderWidgetPreview(size: DpSize, widgetRadius: Dp) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.width(size.width)
.height(size.height)
.background(
color = MaterialTheme.colorScheme.secondaryContainer,
shape = RoundedCornerShape(widgetRadius),
),
) {
CircularProgressIndicator()
}
}
@Composable
private fun BitmapWidgetPreview(bitmap: Bitmap, size: DpSize, widgetRadius: Dp) {
val density = LocalDensity.current
val imageScale by
remember(bitmap) {
derivedStateOf {
with(density) {
val bitmapHeight = bitmap.height.toDp()
val bitmapWidth = bitmap.width.toDp()
val bitmapAspectRatio = bitmapWidth / bitmapHeight
val containerAspectRatio: Float = size.width / size.height
// Scale by width if image has larger aspect ratio than the container else by
// height; and avoid cropping the previews.
if (bitmapAspectRatio > containerAspectRatio) {
size.width / bitmapWidth
} else {
size.height / bitmapHeight
}
}
}
}
val imageSize by
remember(imageScale) {
derivedStateOf {
with(density) {
val bitmapHeight = bitmap.height.toDp()
val bitmapWidth = bitmap.width.toDp()
DpSize(bitmapWidth * imageScale, bitmapHeight * imageScale)
}
}
}
val scaledCornerRadius by
remember(imageScale) {
derivedStateOf { (widgetRadius * imageScale).coerceAtMost(widgetRadius) }
}
Image(
bitmap = bitmap.asImageBitmap(),
contentDescription = null, // only visual (widget details provides the readable info)
contentScale = ContentScale.FillBounds,
modifier =
Modifier.width(imageSize.width)
.height(imageSize.height)
.clip(shape = RoundedCornerShape(scaledCornerRadius)),
)
}
@@ -0,0 +1,375 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.layout.Layout
import androidx.compose.ui.layout.Measurable
import androidx.compose.ui.layout.Placeable
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.traversalIndex
import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastFold
import androidx.compose.ui.util.fastMaxOfOrDefault
import androidx.compose.ui.util.fastSumBy
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
import com.android.launcher3.widgetpicker.shared.model.WidgetAppIcon
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.ui.components.WidgetGridDimensions.MAX_ITEMS_PER_ROW
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
import kotlin.math.max
/**
* Displays widgets with their previews and details organized as a grid.
*
* @param widgetSizeGroups group of widgets that use same preview size bucket (container) and hence
* can be displayed side by side in a row for optimal previewing.
* @param showAllWidgetDetails whether to show all details of each widget in the grid OR just show a
* label.
* @param appIcons optional map containing app icons to show in the widget details besides the label
* (when showing the widgets outside of app context e.g. recommendations)
* @param modifier modifier with parent constraints and additional modifications
*/
@Composable
fun WidgetsGrid(
widgetSizeGroups: List<WidgetSizeGroup>,
showAllWidgetDetails: Boolean,
previews: Map<WidgetId, WidgetPreview>,
modifier: Modifier,
appIcons: Map<WidgetAppId, WidgetAppIcon> = emptyMap(),
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier.padding(vertical = WidgetGridDimensions.gridVerticalPadding),
) {
widgetSizeGroups.forEach { group ->
WidgetsFlowRow(
widgetSizeGroup = group,
showAllWidgetDetails = showAllWidgetDetails,
appIcons = appIcons,
previews = previews,
)
}
}
}
/**
* Custom layout displaying similarly sized widgets in multiple rows.
* - A key feature is the alignment of the top baseline of each widget's details section within a
* row, regardless of preview size.
* - Supports a maximum of [MAX_ITEMS_PER_ROW] per row.
* - Row height adapts to the tallest element within it.
*
* Example visualization:
* ```
* xxxxxx xxxxxx
* xxxxxx xxxxxxxx xxxxxx
* xxxxxx xxxxxxxx xxxxxx <- Different preview heights
* <title> <title> <title> <- Top baseline aligned
* <span> <span> <span>
* <description> <description>
* <continued..>
* ```
*/
@Composable
private fun WidgetsFlowRow(
widgetSizeGroup: WidgetSizeGroup,
showAllWidgetDetails: Boolean,
appIcons: Map<WidgetAppId, WidgetAppIcon>,
previews: Map<WidgetId, WidgetPreview>,
cellHorizontalPadding: Dp = WidgetGridDimensions.cellHorizontalPadding,
rowVerticalSpacing: Dp = WidgetGridDimensions.rowVerticalSpacing,
minItemWidth: Dp = WidgetGridDimensions.minItemWidth,
) {
val items = widgetSizeGroup.widgets
WidgetsFlowRowLayout(
widgetPreviews = { Previews(items, previews) },
widgetDetails = { Details(showAllWidgetDetails, items, appIcons) },
previewContainerWidthPx = widgetSizeGroup.previewContainerWidthPx,
cellHorizontalPadding = cellHorizontalPadding,
rowVerticalSpacing = rowVerticalSpacing,
minItemWidth = minItemWidth,
)
}
@Composable
private fun Previews(widgets: List<PickableWidget>, previews: Map<WidgetId, WidgetPreview>) {
widgets.forEachIndexed { index, widgetItem ->
val id = widgetItem.id
val widgetPreview: WidgetPreview =
remember(id, previews) {
previews.getOrDefault(id, WidgetPreview.PlaceholderWidgetPreview)
}
Box(
contentAlignment = Alignment.BottomCenter,
modifier =
Modifier.fillMaxSize().clearAndSetSemantics { traversalIndex = index.toFloat() },
) {
WidgetPreview(sizeInfo = widgetItem.sizeInfo, preview = widgetPreview)
}
}
}
@Composable
private fun Details(
showAllWidgetDetails: Boolean,
widgets: List<PickableWidget>,
appIcons: Map<WidgetAppId, WidgetAppIcon>,
) {
widgets.forEachIndexed { index, widgetItem ->
val appId = widgetItem.appId
val icon = remember(appId, appIcons) { appIcons.getOrDefault(appId, null) }
val appIcon: (@Composable () -> Unit)? =
icon?.let { { WidgetAppIcon(widgetAppIcon = it, size = AppIconSize.SMALL) } }
WidgetDetails(
widget = widgetItem,
showAllDetails = showAllWidgetDetails,
appIcon = appIcon,
modifier =
Modifier.semantics(mergeDescendants = true) { traversalIndex = index.toFloat() },
)
}
}
@Composable
private fun WidgetsFlowRowLayout(
widgetPreviews: @Composable () -> Unit,
widgetDetails: @Composable () -> Unit,
previewContainerWidthPx: Int,
cellHorizontalPadding: Dp,
rowVerticalSpacing: Dp,
minItemWidth: Dp,
) {
Layout(
modifier = Modifier.semantics { isTraversalGroup = true },
contents = listOf(widgetPreviews, widgetDetails),
) { (widgetPreviewMeasurables, widgetDetailsMeasurables), constraints ->
check(widgetPreviewMeasurables.size == widgetDetailsMeasurables.size)
val parentWidth = constraints.maxWidth
val rowVerticalSpacingPx = rowVerticalSpacing.roundToPx()
val cellHorizontalPaddingPx = cellHorizontalPadding.roundToPx()
val minItemWidthPx = minItemWidth.roundToPx()
val (possibleItemsPerRow, availableWidthPerItem) =
calculateItemsPerRowAndMaxWidthPerItem(
cellHorizontalPaddingPx = cellHorizontalPaddingPx,
previewContainerWidthPx = previewContainerWidthPx,
minItemWidthPx = minItemWidthPx,
parentWidth = parentWidth,
)
// Measure and group into rows
val previewPlaceablesRows =
widgetPreviewMeasurables.measureAndSplitIntoRows(
itemsPerRow = possibleItemsPerRow,
constraints =
constraints.copy(
maxWidth = availableWidthPerItem,
maxHeight = Constraints.Infinity,
),
)
val detailsPlaceableRows =
widgetDetailsMeasurables.measureAndSplitIntoRows(
itemsPerRow = possibleItemsPerRow,
constraints =
constraints.copy(
maxWidth = availableWidthPerItem,
maxHeight = Constraints.Infinity,
),
)
check(previewPlaceablesRows.size == detailsPlaceableRows.size)
// Now we need:
// 1) totalGridHeight to pass to layout constraints and
// 2) height of the tallest preview in each row, and
// 3) height of tallest details section in a row.
val (totalGridHeight, measuredRowDimensions) =
collectMeasuredDimensions(
numberOfRows = previewPlaceablesRows.size,
previewPlaceableRows = previewPlaceablesRows,
detailsPlaceableRows = detailsPlaceableRows,
rowVerticalSpacingPx = rowVerticalSpacingPx,
)
// Place
layout(constraints.maxWidth, totalGridHeight) {
placeRows(
previewPlaceableRows = previewPlaceablesRows,
detailsPlaceableRows = detailsPlaceableRows,
measuredRowDimensions = measuredRowDimensions,
parentWidth = parentWidth,
rowVerticalSpacingPx = rowVerticalSpacingPx,
)
}
}
}
private fun collectMeasuredDimensions(
numberOfRows: Int,
previewPlaceableRows: List<List<Placeable>>,
detailsPlaceableRows: List<List<Placeable>>,
rowVerticalSpacingPx: Int,
): Pair<Int, MutableList<MeasuredRowDimensions>> {
var totalGridHeight = 0
val measuredRowDimensions = mutableListOf<MeasuredRowDimensions>()
repeat(numberOfRows) { index ->
val previewsRow = previewPlaceableRows[index]
val detailsRow = detailsPlaceableRows[index]
val maxPreviewHeight = previewsRow.fastMaxOfOrDefault(0) { it.height }
val maxDetailsHeight = detailsRow.fastMaxOfOrDefault(0) { it.height }
val totalRowWidth = detailsRow.fastSumBy { it.width }
measuredRowDimensions.add(
MeasuredRowDimensions(
tallestPreviewHeight = maxPreviewHeight,
tallestDetailsHeight = maxDetailsHeight,
totalWidth = totalRowWidth,
)
)
totalGridHeight += maxPreviewHeight + maxDetailsHeight + rowVerticalSpacingPx
}
return Pair(totalGridHeight, measuredRowDimensions)
}
private fun Placeable.PlacementScope.placeRows(
previewPlaceableRows: List<List<Placeable>>,
detailsPlaceableRows: List<List<Placeable>>,
measuredRowDimensions: List<MeasuredRowDimensions>,
parentWidth: Int,
rowVerticalSpacingPx: Int,
) {
check(previewPlaceableRows.size == detailsPlaceableRows.size)
val rowSize = previewPlaceableRows.size
var yPosition = 0
repeat(rowSize) { index ->
val previewsRow = previewPlaceableRows[index]
val detailsRow = detailsPlaceableRows[index]
val measuredRow = measuredRowDimensions[index]
// Divide padding between items to center everything.
val padding = ((parentWidth - measuredRow.totalWidth) / previewsRow.size) / 2
var xPosition = 0
repeat(previewsRow.size) { rowItemIndex ->
val detailItem = detailsRow[rowItemIndex]
val previewItem = previewsRow[rowItemIndex]
val itemWidth = max(detailItem.width, previewItem.width)
xPosition += padding
// Offset the preview by the difference in its height when compared to its
// tallest sibling, so that it will appear bottom aligned.
val previewTopOffset =
measuredRow.tallestPreviewHeight - previewsRow[rowItemIndex].height
previewsRow[rowItemIndex].placeRelative(xPosition, yPosition + previewTopOffset)
// place details after size of the tallest preview
detailsRow[rowItemIndex].placeRelative(
xPosition,
(yPosition + (measuredRow.tallestPreviewHeight)),
)
xPosition += itemWidth + padding // right padding
}
// Move to next row
yPosition +=
measuredRow.tallestPreviewHeight +
measuredRow.tallestDetailsHeight +
rowVerticalSpacingPx
}
}
private fun calculateItemsPerRowAndMaxWidthPerItem(
cellHorizontalPaddingPx: Int,
previewContainerWidthPx: Int,
minItemWidthPx: Int,
parentWidth: Int,
): Pair<Int, Int> {
val totalItemHorizontalPadding = 2 * cellHorizontalPaddingPx
// Let's assume at minimum an item takes up preview container width
val minWidthItemMightNeed = max(previewContainerWidthPx, minItemWidthPx)
// And with its horizontal padding added, we can then calculate how many items fit in a row
// and then cap it to a maximum limit.
val possibleItemsPerRow =
(parentWidth / (minWidthItemMightNeed + totalItemHorizontalPadding)).coerceIn(
minimumValue = 1,
maximumValue = MAX_ITEMS_PER_ROW,
)
// Using the capped number, we find out how much space will then be available for an item.
val availableWidthPerItem =
(parentWidth - (totalItemHorizontalPadding * possibleItemsPerRow)) / possibleItemsPerRow
return Pair(possibleItemsPerRow, availableWidthPerItem)
}
private data class MeasuredRowDimensions(
val tallestPreviewHeight: Int,
val tallestDetailsHeight: Int,
val totalWidth: Int,
)
/**
* Measures the items and in same pass attempts to group the placeables into multiple rows based on
* the available width.
*/
private fun List<Measurable>.measureAndSplitIntoRows(
constraints: Constraints,
itemsPerRow: Int,
): List<List<Placeable>> {
return fastFold(mutableListOf<MutableList<Placeable>>()) { rows, measurable ->
val placeable = measurable.measure(constraints)
if (rows.isEmpty() || rows.last().size == itemsPerRow) {
rows.add(mutableListOf(placeable))
} else {
rows.last().add(placeable)
}
rows
}
}
private object WidgetGridDimensions {
val cellHorizontalPadding: Dp = 4.dp
val rowVerticalSpacing: Dp = 12.dp
val gridVerticalPadding: Dp = 16.dp
// We display at max 3 items side by side - which usually is case in case of shortcuts.
const val MAX_ITEMS_PER_ROW = 3
val minItemWidth = 100.dp
}
@@ -0,0 +1,280 @@
/*
* 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.launcher3.widgetpicker.ui.components
import androidx.activity.BackEventCompat
import androidx.activity.compose.PredictiveBackHandler
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.outlined.ArrowBack
import androidx.compose.material.icons.filled.Close
import androidx.compose.material.icons.filled.Search
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.material3.TextFieldColors
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.focus.onFocusChanged
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import com.android.launcher3.widgetpicker.R
import kotlin.coroutines.cancellation.CancellationException
import kotlinx.coroutines.flow.Flow
/**
* A search bar shown in widget picker for searching widgets.
*
* By default shows a leading search icon, followed by the placeholder text. When user focuses on
* the search bar, keyboard (if supported) is automatically opened, the search icon turns in to a
* back button and user can either use this back button or the predictive back to go back.
*
* @param text currently entered text
* @param isSearching whether user is actively searching and search results are shown
* @param onSearch callback invoked when users types in the search bar; as an effect, [isSearching]
* should be set to true
* @param onToggleSearchMode callback invoked with `true` when user focuses on search bar to start a
* search; false when user either presses back button within search bar or uses predictive back to
* exit search.
* @param modifier modifier for the top level search bar
*/
@Composable
fun WidgetsSearchBar(
text: String,
isSearching: Boolean,
onSearch: (String) -> Unit,
onToggleSearchMode: (Boolean) -> Unit,
modifier: Modifier,
) {
val interactionSource = remember { MutableInteractionSource() }
val focusRequester = remember { FocusRequester() }
val focusManager = LocalFocusManager.current
val exitSearchMode = {
onSearch("")
focusManager.clearFocus()
onToggleSearchMode(false)
}
BasicTextField(
modifier =
modifier
.heightIn(min = WidgetsSearchBarDimens.minHeight)
.focusRequester(focusRequester)
.onFocusChanged { focusState ->
if (focusState.isFocused) {
onToggleSearchMode(true)
}
},
value = text,
onValueChange = { onSearch(it) },
singleLine = true,
cursorBrush = SolidColor(MaterialTheme.colorScheme.primary),
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(onSearch = { onSearch(text) }),
interactionSource = interactionSource,
textStyle = WidgetsSearchBarDefaults.textStyle,
decorationBox =
@Composable { innerTextField ->
WidgetsSearchBarContent(
text = text,
innerTextField = innerTextField,
interactionSource = interactionSource,
isSearching = isSearching,
exitSearchMode = exitSearchMode,
onClear = { onSearch("") },
)
},
)
LaunchedEffect(isSearching) {
if (isSearching) {
focusRequester.requestFocus()
}
}
PredictiveBackHandler(isSearching) { progress: Flow<BackEventCompat> ->
try {
progress.collect {}
exitSearchMode()
} catch (_: CancellationException) {}
}
}
@Composable
private fun WidgetsSearchBarContent(
text: String,
innerTextField: @Composable () -> Unit,
interactionSource: MutableInteractionSource,
isSearching: Boolean,
exitSearchMode: () -> Unit,
onClear: () -> Unit,
) {
TextFieldDefaults.DecorationBox(
value = text,
enabled = true,
singleLine = true,
innerTextField = innerTextField,
interactionSource = interactionSource,
visualTransformation = VisualTransformation.None,
contentPadding = WidgetsSearchBarDimens.paddingValues,
container = {
TextFieldDefaults.Container(
enabled = true,
isError = false,
interactionSource = interactionSource,
shape = CircleShape,
colors = WidgetsSearchBarDefaults.containerColors,
focusedIndicatorLineThickness = 0.dp,
unfocusedIndicatorLineThickness = 0.dp,
)
},
placeholder = { PlaceholderText() },
leadingIcon = { LeadingButton(isSearching = isSearching, onBack = exitSearchMode) },
trailingIcon = {
if (text.isNotEmpty()) {
ClearButton(onClick = onClear)
}
},
)
}
@Composable
private fun LeadingButton(isSearching: Boolean, onBack: () -> Unit) {
AnimatedContent(
contentAlignment = Alignment.Center,
modifier = Modifier.minimumInteractiveComponentSize(),
targetState = isSearching,
transitionSpec = {
fadeIn(animationSpec = tween(durationMillis = 300)) togetherWith
fadeOut(animationSpec = tween(durationMillis = 300))
},
) { showBackButton ->
if (showBackButton) {
BackButton(onClick = onBack)
} else {
SearchIcon()
}
}
}
@Composable
private fun SearchIcon() {
Icon(
imageVector = Icons.Filled.Search,
contentDescription = null, // decorative
)
}
@Composable
private fun BackButton(onClick: () -> Unit) {
IconButton(colors = WidgetsSearchBarDefaults.iconButtonColors, onClick = onClick) {
Icon(
imageVector = Icons.AutoMirrored.Outlined.ArrowBack,
contentDescription = stringResource(R.string.widget_search_bar_clear_button_label),
)
}
}
@Composable
private fun PlaceholderText() {
Text(
text = stringResource(R.string.widgets_search_bar_hint),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = WidgetsSearchBarDefaults.placeholderTextColor,
style = WidgetsSearchBarDefaults.placeholderTextStyle,
)
}
@Composable
private fun ClearButton(onClick: () -> Unit) {
IconButton(colors = WidgetsSearchBarDefaults.iconButtonColors, onClick = onClick) {
Icon(
imageVector = Icons.Filled.Close,
contentDescription = stringResource(R.string.widget_search_bar_clear_button_label),
)
}
}
private object WidgetsSearchBarDimens {
val paddingValues = PaddingValues(horizontal = 16.dp, vertical = 8.dp)
val minHeight = 52.dp
}
private object WidgetsSearchBarDefaults {
val containerColors: TextFieldColors
@Composable
get() =
TextFieldDefaults.colors(
focusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
unfocusedContainerColor = MaterialTheme.colorScheme.surfaceBright,
)
val placeholderTextColor: Color
@Composable get() = MaterialTheme.colorScheme.onSurfaceVariant
val iconButtonColors
@Composable
get() = IconButtonDefaults.iconButtonColors().copy(containerColor = Color.Transparent)
val textStyle: TextStyle
@Composable
get() =
MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp,
fontWeight = FontWeight.Normal,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
val placeholderTextStyle: TextStyle
@Composable
get() =
MaterialTheme.typography.bodyLarge.copy(
fontSize = 20.sp,
fontWeight = FontWeight.Normal,
)
}
@@ -0,0 +1,38 @@
/*
* 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.launcher3.widgetpicker.ui.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
/**
* Information about the widget app transformed for displaying as a expandable section in UI.
*
* @param id unique id for the app section
* @param title title for the widget
* @param widgetSizeGroups groups of similar sized widgets that can be displayed together
* @param widgetsCount total number of widgets in the app
*/
@Stable
@Immutable
data class DisplayableWidgetApp(
val id: WidgetAppId,
val title: CharSequence?,
val widgetSizeGroups: List<WidgetSizeGroup>,
val widgetsCount: Int,
)
@@ -0,0 +1,38 @@
/*
* 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.launcher3.widgetpicker.ui.model
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
/**
* A group of widgets that are bucketed into a same sized container that can to be arranged in same
* or consecutive rows within the appwidget grid.
*
* For example, if 2x2 and 3x2 widgets are all bucketed to 2x2 cell size's container, all those
* widgets will be in this group with the [previewContainerHeightPx] and [previewContainerWidthPx]
* being the pixel sizes of 2x2 span size. Grouping these same height widgets, enables us to
* visually show the widgets side by side and enable visual coherence in the grid.
*/
@Stable
@Immutable
data class WidgetSizeGroup(
val previewContainerHeightPx: Int,
val previewContainerWidthPx: Int,
val widgets: List<PickableWidget>,
)
+4
View File
@@ -36,17 +36,21 @@ android_test {
srcs: [
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/goldenpathmanager/WidgetPickerGoldenPathManager.kt",
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/FloatingToolbarScreenshotTest.kt",
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridScreenshotTest.kt",
"multivalentScreenshotTests/src/com/android/launcher3/widgetpicker/ui/components/WidgetsGridTestSamples.kt",
],
sdk_version: "current",
static_libs: [
"widget_picker_ui_components",
"collector-device-lib-platform",
"widget_picker_window_size_class",
"testables",
"androidx.compose.runtime_runtime",
"junit",
"androidx.test.ext.junit",
"androidx.test.rules",
"androidx.test.runner",
"platform-test-rules",
"platform-screenshot-diff-core",
"platform-parametric-runner-lib",
"ScreenshotComposeUtilsLib",
@@ -16,6 +16,7 @@
package com.android.launcher3.widgetpicker.ui.components
import android.platform.test.rule.DisableAnimationsRule
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@@ -51,6 +52,8 @@ import platform.test.screenshot.utils.compose.ComposeScreenshotTestRule
@RunWith(ParameterizedAndroidJunit4::class)
class FloatingToolbarScreenshotTest(emulationSpec: DeviceEmulationSpec) {
@get:Rule(order = 0) val disableAnimationsRule = DisableAnimationsRule()
@get:Rule(order = 1)
val screenshotRule =
ComposeScreenshotTestRule(
@@ -179,6 +182,7 @@ private fun TestComposable(
ScrollableFloatingToolbar(
modifier = Modifier.wrapContentSize(align = Alignment.Center),
selectedTabIndex = selectedIndex,
shadowElevation = 0.dp,
tabs =
tabs.map {
{
@@ -0,0 +1,123 @@
/*
* 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.launcher3.widgetpicker.ui.components
import android.platform.test.rule.DisableAnimationsRule
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import com.android.launcher3.widgetpicker.goldenpathmanager.WidgetPickerGoldenPathManager
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import platform.test.runner.parameterized.ParameterizedAndroidJunit4
import platform.test.runner.parameterized.Parameters
import platform.test.screenshot.DeviceEmulationSpec
import platform.test.screenshot.Displays
import platform.test.screenshot.getEmulatedDevicePathConfig
import platform.test.screenshot.utils.compose.ComposeScreenshotTestRule
@RunWith(ParameterizedAndroidJunit4::class)
class WidgetsGridScreenshotTest(emulationSpec: DeviceEmulationSpec) {
@get:Rule(order = 0) val disableAnimationsRule = DisableAnimationsRule()
@get:Rule(order = 1)
val screenshotRule =
ComposeScreenshotTestRule(
emulationSpec,
WidgetPickerGoldenPathManager(getEmulatedDevicePathConfig(emulationSpec)),
)
@Test
fun widgetsGrid_multipleSizeGroups() {
screenshotRule.screenshotTest("widgetsGrid_multipleSizeGroups") {
MultipleSizeGroupsPreview()
}
}
@Test
fun widgetsGrid_oneByOneWidgets() {
screenshotRule.screenshotTest("widgetsGrid_oneByOneWidgets") { OneByOneWidgetsPreview() }
}
companion object {
@Parameters(name = "{0}")
@JvmStatic
fun getTestSpecs(): List<DeviceEmulationSpec> {
return DeviceEmulationSpec.forDisplays(
Displays.Phone,
isDarkTheme = false,
isLandscape = false,
)
}
}
}
@Preview(widthDp = 420)
@Composable
private fun MultipleSizeGroupsPreview() {
val testWidth = 420.dp
val testWidthPx = with(LocalDensity.current) { testWidth.roundToPx() }
val sample = WidgetsGridTestSamples.varyingSizedWidgets(testWidthPx)
GridPreview(groups = sample.widgetSizeGroups, testWidth = testWidth, previews = sample.previews)
}
@Preview(widthDp = 420)
@Composable
private fun OneByOneWidgetsPreview() {
val testWidth = 420.dp
val testWidthPx = with(LocalDensity.current) { testWidth.roundToPx() }
val sample = WidgetsGridTestSamples.oneByOneWidgets(testWidthPx)
GridPreview(groups = sample.widgetSizeGroups, testWidth = testWidth, previews = sample.previews)
}
@Composable
private fun GridPreview(
groups: List<WidgetSizeGroup>,
previews: Map<WidgetId, WidgetPreview>,
testWidth: Dp,
) {
Box(
contentAlignment = Alignment.Center,
modifier =
Modifier.width(testWidth)
.wrapContentHeight()
.background(MaterialTheme.colorScheme.surfaceBright),
) {
WidgetsGrid(
widgetSizeGroups = groups,
showAllWidgetDetails = true,
previews = previews,
modifier = Modifier.fillMaxWidth().wrapContentHeight(),
)
}
}
@@ -0,0 +1,263 @@
/*
* 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.launcher3.widgetpicker.ui.components
import android.appwidget.AppWidgetProviderInfo
import android.content.ComponentName
import android.graphics.Bitmap
import android.graphics.Canvas
import android.graphics.Color
import android.os.Process.myUserHandle
import androidx.compose.ui.unit.IntSize
import com.android.launcher3.widgetpicker.shared.model.PickableWidget
import com.android.launcher3.widgetpicker.shared.model.WidgetAppId
import com.android.launcher3.widgetpicker.shared.model.WidgetId
import com.android.launcher3.widgetpicker.shared.model.WidgetPreview
import com.android.launcher3.widgetpicker.shared.model.WidgetSizeInfo
import com.android.launcher3.widgetpicker.ui.model.WidgetSizeGroup
/**
* Different combinations of test widget groupings to verify behavior of arranging them in a grid.
*/
object WidgetsGridTestSamples {
/** A test sample with 3 size groups: 2x2s, 4x2s and 1x1s */
fun varyingSizedWidgets(screenWidth: Int): WidgetsSample {
val (cellWidth, cellHeight) = testCellSize(screenWidth)
val fillsBoundsWidgetId = newWidgetId("FillsBounds")
val threeByTwoWidgetId = newWidgetId("ThreeByTwo")
val noFillBoundsWidgetId = newWidgetId("NoFillBounds")
val tinyImageWidgetId = newWidgetId("TinyImage")
val fourByTwoWidgetId = newWidgetId("FourByTwo")
return WidgetsSample(
previews =
mapOf(
fillsBoundsWidgetId to
createBitmapPreview(cellWidth * 2, cellHeight * 2, Color.RED),
threeByTwoWidgetId to
createBitmapPreview(cellWidth * 3, cellHeight * 2, Color.GREEN),
noFillBoundsWidgetId to
createBitmapPreview(cellWidth * 3, cellHeight * 3, Color.BLUE),
tinyImageWidgetId to
createBitmapPreview(cellWidth, cellHeight / 2, Color.YELLOW),
fourByTwoWidgetId to
createBitmapPreview(cellWidth * 3, cellHeight * 2, Color.LTGRAY),
),
widgetSizeGroups =
listOf(
// widgets with 2x2 container size
WidgetSizeGroup(
previewContainerWidthPx = cellWidth * 2,
previewContainerHeightPx = cellHeight * 2,
widgets =
listOf(
twoByTwo(cellWidth, cellHeight)
.copy(id = fillsBoundsWidgetId, label = "Fills bounds"),
threeByTwo(cellWidth, cellHeight)
.copy(
id = threeByTwoWidgetId,
label = "Three by two",
description = "Scaled to 2x2 container",
),
twoByTwo(cellWidth, cellHeight)
.copy(
id = noFillBoundsWidgetId,
label = "Doesn't Fill bounds",
description =
"Actual image size is more like 3x3, it will be scaled.",
),
twoByTwo(cellWidth, cellHeight)
.copy(
id = tinyImageWidgetId,
label = "Tiny image",
description = "Should scale up",
),
),
),
// widgets with 4x2 size
WidgetSizeGroup(
previewContainerWidthPx = cellWidth * 4,
previewContainerHeightPx = cellHeight * 2,
widgets =
listOf(
fourByTwo(cellWidth, cellHeight)
.copy(id = fourByTwoWidgetId, label = "FourByTwo 3x2 image")
),
),
),
)
}
/** A group of 1x1 sized widgets. */
fun oneByOneWidgets(screenWidth: Int): WidgetsSample {
val (cellWidth, cellHeight) = testCellSize(screenWidth)
val largeWidthWidgetId = newWidgetId("LargerWidth")
val largeHeightWidgetId = newWidgetId("LargerHeight")
val tinySizeWidgetId = newWidgetId("TinySize")
val correctSizeWidthId = newWidgetId("CorrectSize")
return WidgetsSample(
widgetSizeGroups =
listOf(
WidgetSizeGroup(
previewContainerWidthPx = cellWidth,
previewContainerHeightPx = cellHeight,
widgets =
listOf(
oneByOne(cellWidth, cellHeight)
.copy(id = largeWidthWidgetId, label = "Larger width"),
oneByOne(cellWidth, cellHeight)
.copy(
id = largeHeightWidgetId,
label = "Larger height",
description = "Has a description",
),
oneByOne(cellWidth, cellHeight)
.copy(
id = newWidgetId("TinySize"),
label = "Tiny size",
description = "Slightly longer description",
),
oneByOne(cellWidth, cellHeight)
.copy(id = newWidgetId("CorrectSize"), label = "Correct size"),
),
)
),
previews =
mapOf(
largeWidthWidgetId to
createBitmapPreview(cellWidth * 2, cellHeight, Color.YELLOW),
largeHeightWidgetId to
createBitmapPreview(cellWidth, cellHeight * 3, Color.BLUE),
tinySizeWidgetId to
createBitmapPreview(cellWidth / 2, cellHeight / 3, Color.RED),
correctSizeWidthId to createBitmapPreview(cellWidth, cellHeight, Color.GREEN),
),
)
}
private fun oneByOne(cellWidth: Int, cellHeight: Int) =
PickableWidget(
id = newWidgetId("OneByOne"),
appId = TEST_WIDGET_APP_ID,
label = "One by One",
description = null,
sizeInfo =
WidgetSizeInfo(
spanX = 1,
spanY = 1,
widthPx = cellWidth,
heightPx = cellHeight,
containerWidthPx = cellWidth,
containerHeightPx = cellHeight,
),
appWidgetProviderInfo = AppWidgetProviderInfo(),
)
private fun twoByTwo(cellWidth: Int, cellHeight: Int) =
PickableWidget(
id = newWidgetId("TwoByTwo"),
appId = TEST_WIDGET_APP_ID,
label = "One by One",
description = null,
appWidgetProviderInfo = AppWidgetProviderInfo(),
sizeInfo =
WidgetSizeInfo(
spanX = 2,
spanY = 2,
widthPx = cellWidth * 2,
heightPx = cellHeight * 2,
// container same as size
containerWidthPx = cellWidth * 2,
containerHeightPx = cellHeight * 2,
),
)
private fun threeByTwo(cellWidth: Int, cellHeight: Int) =
PickableWidget(
id = newWidgetId("ThreeByTwo"),
appId = TEST_WIDGET_APP_ID,
label = "One by One",
description = null,
appWidgetProviderInfo = AppWidgetProviderInfo(),
sizeInfo =
WidgetSizeInfo(
spanX = 3,
spanY = 2,
widthPx = cellWidth * 3,
heightPx = cellHeight * 2,
// 3x2s are bucketed to 2x2 container
containerWidthPx = cellWidth * 2,
containerHeightPx = cellHeight * 2,
),
)
private fun fourByTwo(cellWidth: Int, cellHeight: Int) =
PickableWidget(
id = newWidgetId("FourByTwo"),
appId = TEST_WIDGET_APP_ID,
label = "Four by two",
description = null,
appWidgetProviderInfo = AppWidgetProviderInfo(),
sizeInfo =
WidgetSizeInfo(
spanX = 4,
spanY = 2,
widthPx = cellWidth * 4,
heightPx = cellHeight * 2,
// container same as size
containerWidthPx = cellWidth * 4,
containerHeightPx = cellHeight * 2,
),
)
private fun newWidgetId(suffix: String) =
WidgetId(
ComponentName.createRelative(PACKAGE_NAME, "WidgetReceiver$suffix"),
myUserHandle(),
)
private fun createBitmapPreview(
width: Int,
height: Int,
color: Int,
): WidgetPreview.BitmapWidgetPreview {
val bitmap: Bitmap = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888)
Canvas(bitmap).drawColor(color)
return WidgetPreview.BitmapWidgetPreview(bitmap = bitmap)
}
private fun testCellSize(screenWidthPx: Int): IntSize {
val gridCols = 4
val totalHorizontalGridCellPadding = 40 * 2
val cellWidth = ((screenWidthPx / gridCols) - totalHorizontalGridCellPadding)
val cellHeight = cellWidth + 20 // assume cell height slightly larger than width
return IntSize(cellWidth, cellHeight)
}
private const val PACKAGE_NAME = "com.android.widgetpicker.tests"
private val TEST_WIDGET_APP_ID = WidgetAppId(PACKAGE_NAME, myUserHandle(), category = null)
}
data class WidgetsSample(
val widgetSizeGroups: List<WidgetSizeGroup>,
val previews: Map<WidgetId, WidgetPreview>,
)