diff --git a/samples/user-interface/appwidgets/src/main/AndroidManifest.xml b/samples/user-interface/appwidgets/src/main/AndroidManifest.xml index f0bf0b19..c5501a42 100644 --- a/samples/user-interface/appwidgets/src/main/AndroidManifest.xml +++ b/samples/user-interface/appwidgets/src/main/AndroidManifest.xml @@ -38,6 +38,38 @@ + + + + + + + + + + + + + + + + + + + + Unit>, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxHeight(), +) { + val padding = spacing / 2 // split spacing between siblings + + Row(modifier = modifier) { + Column( + modifier = GlanceModifier + .fillMaxHeight() + .defaultWeight() + ) { + items.forEachIndexed { index, item -> + val paddingModifier = when (index) { + // Only bottom padding + 0 -> GlanceModifier.padding(bottom = padding) + + // Top and bottom padding + items.lastIndex -> GlanceModifier.padding(top = padding) + + // Only top padding + else -> GlanceModifier.padding( + top = padding, + bottom = padding + ) + } + + Box( + modifier = paddingModifier + .fillMaxWidth() + .defaultWeight() + ) { + item() + } + } + } + } +} + +/** + * Arranges the provided [items] horizontally spaced by given [spacing]. + */ +@Composable +fun SpacedRow( + items: List<@Composable () -> Unit>, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxWidth(), +) { + val padding = spacing / 2 // split spacing between siblings + + Column(modifier = modifier) { + Row( + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + ) { + items.forEachIndexed { index, item -> + val paddingModifier = when (index) { + // Right padding only + 0 -> GlanceModifier.padding(end = padding) + + // Left padding only + items.lastIndex -> GlanceModifier.padding(start = padding) + + // Both left and right padding + else -> GlanceModifier.padding(start = padding, end = padding) + } + + Box( + modifier = paddingModifier + .fillMaxHeight() + .defaultWeight() + ) { + item() + } + } + } + } +} + +/** + * Arranges given [items] in a grid of up to 2 rows spaced by [spacing] in the available space. + * + * Suitable for cases where there are multiple [items] that can be arranged in two rows. + */ +@Composable +fun TwoRowGrid( + items: List<@Composable () -> Unit>, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxSize(), +) { + val middle = items.size / 2 + val rowOneItems = items.subList(0, middle) + val rowTwoItems = items.subList(middle, items.size) + + Column(modifier = modifier) { + if (rowOneItems.isNotEmpty()) { + SpacedRow( + items = rowOneItems, + spacing = spacing, + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + .padding(bottom = spacing / 2), + ) + } + if (rowTwoItems.isNotEmpty()) { + SpacedRow( + items = rowTwoItems, + spacing = spacing, + modifier = GlanceModifier + .fillMaxWidth() + .padding(top = spacing / 2) + .defaultWeight() + ) + } + } +} + +/** + * Arranges the provided [items] in a grid of two rows spaced by [spacing] with a [sideBarItem] on + * the left. + */ +@Composable +fun SideBarTwoRowGrid( + sideBarItem: @Composable () -> Unit, + items: List<@Composable () -> Unit>, + sideBarWidth: Dp, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxSize(), +) { + Row(modifier = modifier) { + Box( + modifier = GlanceModifier + .fillMaxHeight() + .width(sideBarWidth) + ) { + sideBarItem() + } + Spacer( + modifier = GlanceModifier + .fillMaxHeight() + .width(spacing) + ) + TwoRowGrid( + items = items, + spacing = spacing, + modifier = GlanceModifier + .fillMaxHeight() + .defaultWeight() + ) + } +} + +/** + * Arranges the provided [items] in a grid of two rows spaced by [spacing] with a [headerItem] on the top. + */ +@Composable +fun HeaderTwoRowGrid( + headerItem: @Composable () -> Unit, + items: List<@Composable () -> Unit>, + headerHeight: Dp, + spacing: Dp, + modifier: GlanceModifier = GlanceModifier.fillMaxSize(), +) { + Column(modifier = modifier) { + Box( + modifier = GlanceModifier + .height(headerHeight) + .fillMaxWidth() + ) { + headerItem() + } + Spacer( + modifier = GlanceModifier + .fillMaxWidth() + .height(spacing) + ) + TwoRowGrid( + items = items, + spacing = spacing, + modifier = GlanceModifier + .fillMaxWidth() + .defaultWeight() + ) + } +} \ No newline at end of file diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/glance/layout/toolbars/layout/SearchToolBarLayout.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/glance/layout/toolbars/layout/SearchToolBarLayout.kt new file mode 100644 index 00000000..6e52dc4d --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/glance/layout/toolbars/layout/SearchToolBarLayout.kt @@ -0,0 +1,417 @@ +/* + * Copyright 2024 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.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.action.clickable +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.cornerRadius +import androidx.glance.background +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Row +import androidx.glance.layout.Spacer +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.layout.width +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import androidx.glance.semantics.contentDescription +import androidx.glance.semantics.semantics +import androidx.glance.text.FontWeight +import androidx.glance.text.Text +import androidx.glance.text.TextStyle +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.headerItemHeight +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.iconSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.itemsSpacing +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.minButtonSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.sideBarLeadingItemWidth +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutDimens.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.Companion.canShowSearchText +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.Companion.canUseFilledButtons +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.Companion.numberOfItemsThatFit +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.HeaderTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.HorizontalRow +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.SideBarTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.TwoByTwoGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.SearchToolBarLayoutSize.VerticalColumn +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity + +/** + * Layout focused on presenting a search entrypoint along with additional handy shortcuts that + * provide quick access to frequently used functions. + * + * This serves as an implementation suggestion, but should be customized to fit your product's + * needs. + * + * @param searchButton a button representing the search action in the widget displayed more + * prominently than the trailing buttons.. + * @param trailingButtons list of 4 buttons representing handy shortcuts to frequently used + * functions in your app. + * @see SearchToolBarLayoutSize for supported breakpoints + */ +@Composable +fun SearchToolBarLayout( + searchButton: SearchToolBarButton, + // 4 items, as a list here for convenience, that you might inline in your implementation. + trailingButtons: List, +) { + val searchButtonItem: @Composable () -> Unit = { + if (canShowSearchText()) { + SearchBar(searchButton = searchButton) + } else { + SearchIconButton( + searchButton = searchButton, + filled = canUseFilledButtons() + ) + } + } + + val trailingButtonItems: List<@Composable () -> Unit> = + trailingButtons.map { + { + TrailingButton( + button = it, + filled = canUseFilledButtons() + ) + } + } + + Scaffold( + modifier = GlanceModifier.padding(vertical = widgetPadding), + horizontalPadding = widgetPadding + ) { + when (val layoutSize = SearchToolBarLayoutSize.fromLocalSize()) { + HorizontalRow, VerticalColumn -> { + val horizontal = (layoutSize == HorizontalRow) + val numberOfItems = numberOfItemsThatFit( + horizontal = horizontal, + minItemSize = minButtonSize, + spacing = itemsSpacing + ) + val allItems = listOf(searchButtonItem) + trailingButtonItems + val finalItems = allItems.take(numberOfItems) + + if (horizontal) { + SpacedRow( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } else { + SpacedColumn( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + + TwoByTwoGrid -> TwoRowGrid( + // 4 items including search button + items = listOf(searchButtonItem) + trailingButtonItems.take(3), + spacing = itemsSpacing + ) + + SideBarTwoRowGrid -> SideBarTwoRowGrid( + sideBarItem = searchButtonItem, + items = trailingButtonItems.take(4), + sideBarWidth = sideBarLeadingItemWidth, + spacing = itemsSpacing + ) + + HeaderTwoRowGrid -> HeaderTwoRowGrid( + headerItem = searchButtonItem, + items = trailingButtonItems.take(4), + headerHeight = headerItemHeight, + spacing = itemsSpacing + ) + } + } +} + +/** + * Data class representing different buttons displayed in the search toolbar widget. + * @param iconRes Resource id of the icon button. + * @param contentDescription description about the button that can be used by the accessibility + * services. + * @param onClick action to perform on click of the button. + * @param text optional text that can be displayed if space is available. + */ +data class SearchToolBarButton( + @DrawableRes val iconRes: Int, + val contentDescription: String, + val onClick: Action, + val text: String? = null, +) + +/** + * Search entrypoint in style of a rounded icon button. + */ +@Composable +private fun SearchIconButton( + searchButton: SearchToolBarButton, + filled: Boolean, +) { + RectangularIconButton( + imageProvider = ImageProvider(searchButton.iconRes), + contentDescription = searchButton.contentDescription, + backgroundColor = if (filled) { + GlanceTheme.colors.tertiary + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = if (filled) { + GlanceTheme.colors.onTertiary + } else { + GlanceTheme.colors.onSecondaryContainer + }, + iconSize = iconSize, + roundedCornerShape = RoundedCornerShape.FULL, + onClick = searchButton.onClick, + modifier = GlanceModifier.fillMaxSize() + ) +} + +/** + * Search entrypoint in style of a search bar. + */ +@Composable +private fun SearchBar(searchButton: SearchToolBarButton) { + Row( + horizontalAlignment = Alignment.Start, + verticalAlignment = Alignment.CenterVertically, + modifier = GlanceModifier + .fillMaxSize() + .padding(horizontal = 16.dp, vertical = 12.dp) + .background(GlanceTheme.colors.secondaryContainer) + .cornerRadius(RoundedCornerShape.FULL.cornerRadius) + .semantics { this.contentDescription = searchButton.contentDescription } + .clickable(searchButton.onClick), + ) { + // Search or brand icon + Image( + provider = ImageProvider(searchButton.iconRes), + contentDescription = null, + colorFilter = ColorFilter.tint(GlanceTheme.colors.primary), + modifier = GlanceModifier.size(iconSize) + ) + // Followed by text + searchButton.text?.let { + Spacer(GlanceModifier.width(8.dp)) + Text( + text = it, + maxLines = 1, + style = TextStyle( + color = GlanceTheme.colors.onSecondaryContainer, + fontSize = 16.sp, + fontWeight = FontWeight.Medium + ), + ) + } + } +} + +/** + * Rounded action buttons that support [filled] background when specified. + */ +@Composable +private fun TrailingButton( + button: SearchToolBarButton, + filled: Boolean = true, +) { + RectangularIconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + backgroundColor = if (filled) { + GlanceTheme.colors.secondaryContainer + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = GlanceTheme.colors.onSecondaryContainer, + onClick = button.onClick, + iconSize = iconSize, + roundedCornerShape = RoundedCornerShape.MEDIUM, + modifier = GlanceModifier.fillMaxSize() + ) +} + +// Breakpoints based on the UX design. +private enum class SearchToolBarLayoutSize { + // Row of search button followed by action buttons that fit horizontally. + HorizontalRow, + + // Column of search button and action buttons that fit vertically. + VerticalColumn, + + // A two row, two column grid containing search button and 3 trailing buttons. + TwoByTwoGrid, + + // A side bar for search followed by a 2x2 grid of trailing buttons. + SideBarTwoRowGrid, + + // A header containing header bar followed by a 2x2 grid of trailing buttons. + HeaderTwoRowGrid; + + companion object { + @Composable + fun fromLocalSize(): SearchToolBarLayoutSize { + val size = LocalSize.current + val height = size.height + val width = size.width + + return if (height < 128.dp) { + HorizontalRow + } else if (width < 128.dp) { + VerticalColumn + } else if (height < 188.dp && width < 188.dp) { + TwoByTwoGrid + } else if (height < 188.dp) { + SideBarTwoRowGrid + } else { + HeaderTwoRowGrid + } + } + + /** + * Helper to decide whether to show search text in current widget size. + */ + @Composable + fun canShowSearchText(): Boolean { + val localSize = LocalSize.current + + // Per breakpoints in the UX design + return localSize.width >= 184.dp && localSize.height >= 188.dp + } + + /** + * Helper to decide whether to show filled icons vs without containers in current widget size. + */ + @Composable + fun canUseFilledButtons(): Boolean { + val localSize = LocalSize.current + + // Per breakpoints in the UX design + return localSize.height >= 72.dp && localSize.width >= 72.dp + } + + /** + * Helper to decide how many items to show in the available space in given orientation. + * + * @param horizontal if its a horizontal orientation + * @param minItemSize min size to maintain for each item when identify how many to fit + * @param spacing spacing to between items + */ + @Composable + fun numberOfItemsThatFit(horizontal: Boolean, minItemSize: Dp, spacing: Dp): Int { + val size = if (horizontal) { + LocalSize.current.width + } else { + LocalSize.current.height + } + + // n buttons have n-1 content spacers, so, we add one to total width to make the width division + // simpler. + val normalizedWidth: Dp = size + spacing + val normalizedButtonWidth: Dp = minItemSize + spacing + // Number of equally wide buttons that fit in a row + return ((normalizedWidth / normalizedButtonWidth)).toInt() + } + } +} + +// Various dimensions coming from the UX design +private object SearchToolBarLayoutDimens { + /** Minimum size needed for buttons / clickable areas for accessibility. */ + val minButtonSize = 48.dp + + /** Padding around the content within the widget. */ + val widgetPadding = 12.dp + + /** Spacing between buttons in all layouts. */ + val itemsSpacing = 8.dp + + /** Size of icons in all buttons */ + val iconSize = 24.dp + + /** Height of side bar in the [SideBarTwoRowGrid] layout. */ + val sideBarLeadingItemWidth = 52.dp + + /** Height of header in the [HeaderTwoRowGrid] layout. */ + val headerItemHeight = 52.dp +} + +/** + * Previews for various breakpoints for this search toolbar layout. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 56, heightDp = 56) // only search icon +@Preview(widthDp = 128, heightDp = 48) // reveals one additional button - no background +@Preview(widthDp = 128, heightDp = 72) // w/ background colors +@Preview(widthDp = 296, heightDp = 48) // more buttons - no background +@Preview(widthDp = 296, heightDp = 72) // more buttons - w/ background colors +@Preview(widthDp = 72, heightDp = 228) // vertical +@Preview(widthDp = 128, heightDp = 128) // 2x2 grid +@Preview(widthDp = 296, heightDp = 128) // search sidebar + 2x2 grid +@Preview(widthDp = 128, heightDp = 228) // search on top (no text) + 2x2 grid +@Preview(widthDp = 240, heightDp = 228) // search on top w/ text + 2x2 grid +@Composable +private fun SearchToolbarPreview() { + SearchToolBarLayout( + searchButton = SearchToolBarButton( + iconRes = R.drawable.sample_search_icon, + contentDescription = "Search notes", + text = "Search", + onClick = actionStartDemoActivity("search notes button") + ), + trailingButtons = listOf( + SearchToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "audio", + onClick = actionStartDemoActivity("audio button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_videocam_icon, + contentDescription = "video note", + onClick = actionStartDemoActivity("video note button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ), + SearchToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ) + ) +} \ No newline at end of file diff --git a/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/glance/layout/toolbars/layout/ToolBarLayout.kt b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/glance/layout/toolbars/layout/ToolBarLayout.kt new file mode 100644 index 00000000..b2db4a15 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/java/com/example/platform/ui/appwidgets/glance/layout/toolbars/layout/ToolBarLayout.kt @@ -0,0 +1,455 @@ +/* + * Copyright 2024 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.example.platform.ui.appwidgets.glance.layout.toolbars.layout + +import androidx.annotation.DrawableRes +import androidx.compose.runtime.Composable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import androidx.glance.ColorFilter +import androidx.glance.GlanceModifier +import androidx.glance.GlanceTheme +import androidx.glance.Image +import androidx.glance.ImageProvider +import androidx.glance.LocalSize +import androidx.glance.action.Action +import androidx.glance.appwidget.components.Scaffold +import androidx.glance.appwidget.components.TitleBar +import androidx.glance.color.ColorProvider +import androidx.glance.layout.Alignment +import androidx.glance.layout.Box +import androidx.glance.layout.fillMaxSize +import androidx.glance.layout.padding +import androidx.glance.layout.size +import androidx.glance.preview.ExperimentalGlancePreviewApi +import androidx.glance.preview.Preview +import com.example.platform.ui.appwidgets.R +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.iconSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.itemsSpacing +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.minButtonSize +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutDimens.widgetPadding +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.canShowHeaderTitle +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.canUseFilledButtons +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.numberOfItemsThatFit +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.Companion.numberOfContentButtonsInTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.HeaderTwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.HorizontalRow +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.TwoRowGrid +import com.example.platform.ui.appwidgets.glance.layout.toolbars.layout.ToolBarLayoutSize.VerticalColumn +import com.example.platform.ui.appwidgets.glance.layout.utils.ActionUtils.actionStartDemoActivity +import com.example.platform.ui.appwidgets.glance.layout.utils.MediumWidgetPreview +import com.example.platform.ui.appwidgets.glance.layout.utils.SmallWidgetPreview + +/** + * A layout focused on presenting a brand icon, a most frequently used entrypoint along with 4 + * additional entry points that provide quick access to various functions in your app. + * + * This serves as an implementation suggestion, but should be customized to fit your product's + * needs. + * + * @see appName a title for the toolbar e.g. app name that can be displayed at large sizes. + * @see appIconRes a brand icon to be displayed in the toolbar at all sizes. + * @param headerButton a button representing the most frequently used action of your app that is + * displayed more prominently than than the other [buttons]. + * @param buttons list of additional 4 buttons for other frequently used functions in your app. + */ +@Composable +fun ToolBarLayout( + appName: String, + @DrawableRes appIconRes: Int, + headerButton: ToolBarButton, + // 4 items, as a list here for convenience, that you might inline in your implementation. + buttons: List, +) { + // Deconstructed header items shown along side other buttons in smaller widget sizes where + // a header isn't shown. + val appIconItem: @Composable () -> Unit = { + FluidHeaderAppIcon(iconRes = appIconRes) + } + val headerButtonItem: @Composable () -> Unit = { + // Unlike in the combined header case, this header button is fluid & fills the available space + // allowing us to display it along with other buttons. + FluidHeaderIconButton( + button = headerButton + ) + } + + // Combined header item (which shows app icon and header button in the title bar); shown at larger + // widget sizes. + val header: @Composable () -> Unit = { + Header( + appIconRes = appIconRes, + actionButton = headerButton, + title = if (canShowHeaderTitle()) { + appName + } else { + "" + }, + ) + } + + // Other buttons + val buttonItems: List<@Composable () -> Unit> = + buttons.map { { FluidContentIconButton(it, filled = canUseFilledButtons()) } } + + when (val layoutSize = ToolBarLayoutSize.fromLocalSize()) { + HorizontalRow, VerticalColumn -> { + + Scaffold( + modifier = GlanceModifier.padding(vertical = widgetPadding), + horizontalPadding = widgetPadding, + ) { + val horizontal = layoutSize == HorizontalRow + val numberOfItems = numberOfItemsThatFit( + horizontal = horizontal, + minItemSize = minButtonSize, + spacing = itemsSpacing + ) + val allItems = listOf(appIconItem, headerButtonItem) + buttonItems + val finalItems = allItems.take(numberOfItems) + + if (horizontal) { + SpacedRow( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } else { + SpacedColumn( + items = finalItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + } + + TwoRowGrid -> { + val contentButtonsToShow = buttonItems.take( + numberOfContentButtonsInTwoRowGrid() + ) + + Scaffold( + modifier = GlanceModifier.padding(vertical = widgetPadding), + horizontalPadding = widgetPadding + ) { + TwoRowGrid( + items = listOf(appIconItem, headerButtonItem) + contentButtonsToShow, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize(), + ) + } + } + + HeaderTwoRowGrid -> { + Scaffold( + modifier = GlanceModifier.padding(bottom = widgetPadding), + horizontalPadding = widgetPadding, + titleBar = header, + ) { + TwoRowGrid( + items = buttonItems, + spacing = itemsSpacing, + modifier = GlanceModifier.fillMaxSize() + ) + } + } + } +} + +/** + * Title bar / header displayed at top at larger sizes. + * + * Displays brand icon, title, and a pill shaped filled action button. + * @see [HeaderTwoRowGrid] + */ +@Composable +private fun Header( + appIconRes: Int, + title: String, + actionButton: ToolBarButton, +) { + TitleBar( + startIcon = ImageProvider(appIconRes), + title = title, + iconColor = GlanceTheme.colors.primary, + actions = { + PillShapedButton( + iconImageProvider = ImageProvider(actionButton.iconRes), + contentDescription = actionButton.contentDescription, + iconSize = iconSize, + backgroundColor = if (canUseFilledButtons()) { + GlanceTheme.colors.tertiary + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = GlanceTheme.colors.onTertiary, + onClick = actionButton.onClick, + modifier = GlanceModifier.padding(end = widgetPadding) + ) + } + ) +} + +/** + * Brand icon displayed in the compact sizes where a title bar (header) cannot be shown. + * + * The background of this icon fills the available space. + * + * Using a separate icon enables us to equally space and size it with other buttons. + * @see [HorizontalRow], [VerticalColumn] & [TwoRowGrid] + */ +@Composable +private fun FluidHeaderAppIcon(@DrawableRes iconRes: Int) { + Box( + modifier = GlanceModifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + Image( + provider = ImageProvider(iconRes), + contentDescription = null, + modifier = GlanceModifier.size(iconSize), + colorFilter = ColorFilter.tint(GlanceTheme.colors.onSurface) + ) + } +} + +/** + * A rounded icon button for the prominent header action displayed alongside other buttons in + * compact sizes where a title bar (header) cannot be shown. + * + * Using a separate icon button enables us to equally space and size it with other buttons. + * @see [HorizontalRow], [VerticalColumn] & [TwoRowGrid] + */ +@Composable +private fun FluidHeaderIconButton(button: ToolBarButton) { + RectangularIconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + contentColor = if (canUseFilledButtons()) { + GlanceTheme.colors.onTertiary + } else { + GlanceTheme.colors.onSecondaryContainer + }, + backgroundColor = if (canUseFilledButtons()) { + GlanceTheme.colors.tertiary + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + roundedCornerShape = RoundedCornerShape.FULL, + iconSize = iconSize, + modifier = GlanceModifier.fillMaxSize(), + onClick = button.onClick + ) +} + +/** + * Button meant for regular action buttons that appear after the header / deconstructed header + * items. + * + * Fills the given space, uses smaller radius, and supports filled / transparent backgrounds. + */ +@Composable +private fun FluidContentIconButton(button: ToolBarButton, filled: Boolean = true) { + RectangularIconButton( + imageProvider = ImageProvider(button.iconRes), + contentDescription = button.contentDescription, + iconSize = iconSize, + roundedCornerShape = RoundedCornerShape.MEDIUM, + backgroundColor = if (filled) { + GlanceTheme.colors.secondaryContainer + } else { + ColorProvider(Color.Transparent, Color.Transparent) + }, + contentColor = GlanceTheme.colors.onSecondaryContainer, + onClick = button.onClick, + modifier = GlanceModifier.fillMaxSize() + ) +} + +/** + * Data class representing buttons displayed in the toolbar widget. + * @param iconRes Resource id of the icon button + * @param contentDescription description about the button that can be used by the accessibility + * services + * @param onClick action to perform on click of the button + * @param text optional text that will be displayed if space suffices. + */ +data class ToolBarButton( + @DrawableRes val iconRes: Int, + val contentDescription: String, + val onClick: Action, + val text: String? = null, +) + +// Breakpoints from the UX design +private enum class ToolBarLayoutSize { + // Row of app icon, featured button and regular action buttons that fit horizontally + HorizontalRow, + + // Column of app icon, featured button and regular action buttons that fit vertically. + VerticalColumn, + + // Two rows, 2-3 columns containing first column of app icon, featured action button and other + // columns displaying regular action buttons that fit. + TwoRowGrid, + + // Header row (containing app icon + featured button) followed by 2 row grid containing the 4 + // regular action buttons. + HeaderTwoRowGrid; + + companion object { + @Composable + fun fromLocalSize(): ToolBarLayoutSize { + val size = LocalSize.current + val height = size.height + val width = size.width + + return if (height < 128.dp) { + HorizontalRow + } else if (width < 128.dp) { + VerticalColumn + } else if (height < 172.dp) { + TwoRowGrid + } else { + HeaderTwoRowGrid + } + } + + /** + * Indicates if buttons with background color can be displayed for the current widget size. + * + * Background is hidden when we are limited by height / width. + */ + @Composable + fun canUseFilledButtons(): Boolean { + val localSize = LocalSize.current + + return localSize.height >= 72.dp && localSize.width >= 72.dp + } + + /** + * Returns how many items to show that would potentially fit in the given orientation + * (horizontal / vertical) if we were filling entire space. + * @see [HorizontalRow] & [VerticalColumn] + */ + @Composable + fun numberOfItemsThatFit( + horizontal: Boolean, + minItemSize: Dp, + spacing: Dp, + ): Int { + val size = if (horizontal) { + LocalSize.current.width + } else { + LocalSize.current.height + } + + // n buttons have n-1 content spacers, so, we add one to total width to make the width division + // simpler. + val normalizedWidth: Dp = size + spacing + val normalizedButtonWidth: Dp = minItemSize + spacing + // Number of equally wide buttons that fit in a row + return ((normalizedWidth / normalizedButtonWidth)).toInt() + } + + /** + * Returns number of regular buttons that can fit in a 2-row grid where brand icon and a + * featured action button would also be shown. + * + * @see [TwoRowGrid] + */ + @Composable + fun numberOfContentButtonsInTwoRowGrid() = + if (LocalSize.current.width >= 240.dp) { // from UX design + 4 // 1st column (app icon, featured button) and 2nd & 3rd column (4 regular buttons) + } else { + 2 // 1st column (app icon, featured button) and 2nd column (2 regular buttons) + } + + /** + * Identifies if we should show or hide the title in the header at the current widget size. + */ + @Composable + fun canShowHeaderTitle() = + LocalSize.current.width >= 240.dp && LocalSize.current.height >= 172.dp // from UX design + } +} + +// Dimensions from UX design. +private object ToolBarLayoutDimens { + /** Minimum size needed for buttons / clickable areas for accessibility. */ + val minButtonSize = 48.dp + + /** Padding around the content within the widget. */ + val widgetPadding = 12.dp + + /** Spacing between buttons in all layouts. */ + val itemsSpacing = 8.dp + + /** Size of icons in all buttons */ + val iconSize = 24.dp +} + +/** + * Previews for various breakpoints for this toolbar layout. + */ +@OptIn(ExperimentalGlancePreviewApi::class) +@Preview(widthDp = 56, heightDp = 56) // only app icon +@Preview(widthDp = 128, heightDp = 48) // reveals header button - no background +@Preview(widthDp = 128, heightDp = 72) // w/ background colors +@Preview(widthDp = 296, heightDp = 48) // more buttons - no background +@Preview(widthDp = 296, heightDp = 72) // more buttons - w/ background colors +@Preview(widthDp = 72, heightDp = 228) // vertical +@Preview(widthDp = 128, heightDp = 128) // 2x2 grid +@Preview(widthDp = 296, heightDp = 128) // 3x2 grid +@Preview(widthDp = 128, heightDp = 228) // w/ title bar - no title +@Preview(widthDp = 240, heightDp = 228) // w/ title bar and title +@Composable +private fun ToolbarPreview() { + ToolBarLayout( + appName = "App name", + appIconRes = R.drawable.sample_app_logo, + headerButton = ToolBarButton( + iconRes = R.drawable.sample_add_icon, + contentDescription = "add", + onClick = actionStartDemoActivity("add button") + ), + buttons = listOf( + ToolBarButton( + iconRes = R.drawable.sample_mic_icon, + contentDescription = "mic", + onClick = actionStartDemoActivity("mic button") + ), + ToolBarButton( + iconRes = R.drawable.sample_share_icon, + contentDescription = "share", + onClick = actionStartDemoActivity("share button") + ), + ToolBarButton( + iconRes = R.drawable.sample_videocam_icon, + contentDescription = "video", + onClick = actionStartDemoActivity("video button") + ), + ToolBarButton( + iconRes = R.drawable.sample_camera_icon, + contentDescription = "camera", + onClick = actionStartDemoActivity("camera button") + ) + ) + ) +} \ No newline at end of file diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-night-nodpi/sample_search_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-night-nodpi/sample_search_toolbar_preview.png new file mode 100644 index 00000000..5a0f17f8 Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-night-nodpi/sample_search_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-night-nodpi/sample_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-night-nodpi/sample_toolbar_preview.png new file mode 100644 index 00000000..b936bc46 Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-night-nodpi/sample_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/cl_activity_row_search_toolbar.png b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/cl_activity_row_search_toolbar.png new file mode 100644 index 00000000..b2989543 Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/cl_activity_row_search_toolbar.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/cl_activity_row_toolbar.png b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/cl_activity_row_toolbar.png new file mode 100644 index 00000000..ba29b178 Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/cl_activity_row_toolbar.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/sample_search_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/sample_search_toolbar_preview.png new file mode 100644 index 00000000..746bcf9b Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/sample_search_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/sample_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/sample_toolbar_preview.png new file mode 100644 index 00000000..b58959ee Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-nodpi/sample_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-night-nodpi/sample_search_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-night-nodpi/sample_search_toolbar_preview.png new file mode 100644 index 00000000..ee68337b Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-night-nodpi/sample_search_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-night-nodpi/sample_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-night-nodpi/sample_toolbar_preview.png new file mode 100644 index 00000000..09013f1d Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-night-nodpi/sample_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-nodpi/sample_search_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-nodpi/sample_search_toolbar_preview.png new file mode 100644 index 00000000..d9fd29d5 Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-nodpi/sample_search_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-nodpi/sample_toolbar_preview.png b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-nodpi/sample_toolbar_preview.png new file mode 100644 index 00000000..5bc33277 Binary files /dev/null and b/samples/user-interface/appwidgets/src/main/res/drawable-xlarge-nodpi/sample_toolbar_preview.png differ diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/sample_app_logo.xml b/samples/user-interface/appwidgets/src/main/res/drawable/sample_app_logo.xml new file mode 100644 index 00000000..370ccd7e --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/sample_app_logo.xml @@ -0,0 +1,26 @@ + + + + + + \ No newline at end of file diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/sample_camera_icon.xml b/samples/user-interface/appwidgets/src/main/res/drawable/sample_camera_icon.xml new file mode 100644 index 00000000..8634a435 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/sample_camera_icon.xml @@ -0,0 +1,27 @@ + + + + + + \ No newline at end of file diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/sample_mic_icon.xml b/samples/user-interface/appwidgets/src/main/res/drawable/sample_mic_icon.xml new file mode 100644 index 00000000..7bb2cf5b --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/sample_mic_icon.xml @@ -0,0 +1,25 @@ + + + + diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/sample_search_icon.xml b/samples/user-interface/appwidgets/src/main/res/drawable/sample_search_icon.xml new file mode 100644 index 00000000..a8317a1f --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/sample_search_icon.xml @@ -0,0 +1,25 @@ + + + + diff --git a/samples/user-interface/appwidgets/src/main/res/drawable/sample_videocam_icon.xml b/samples/user-interface/appwidgets/src/main/res/drawable/sample_videocam_icon.xml new file mode 100644 index 00000000..8ea3b0d3 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/drawable/sample_videocam_icon.xml @@ -0,0 +1,25 @@ + + + + diff --git a/samples/user-interface/appwidgets/src/main/res/values-xlarge/dimens.xml b/samples/user-interface/appwidgets/src/main/res/values-xlarge/dimens.xml index a10bc021..d34c4941 100644 --- a/samples/user-interface/appwidgets/src/main/res/values-xlarge/dimens.xml +++ b/samples/user-interface/appwidgets/src/main/res/values-xlarge/dimens.xml @@ -2,6 +2,32 @@ + + + + 3 + 1 + 298dp + 64dp + 48dp + 48dp + 488dp + 672dp + + + 2 + 2 + 180dp + 184dp + 48dp + 48dp + 488dp + 672dp + 2 diff --git a/samples/user-interface/appwidgets/src/main/res/values/dimens.xml b/samples/user-interface/appwidgets/src/main/res/values/dimens.xml index 17c812c6..325980b7 100644 --- a/samples/user-interface/appwidgets/src/main/res/values/dimens.xml +++ b/samples/user-interface/appwidgets/src/main/res/values/dimens.xml @@ -22,6 +22,32 @@ + + + + 4 + 1 + 256dp + 48dp + 48dp + 48dp + 624dp + 422dp + + + 2 + 2 + 120dp + 115dp + 48dp + 48dp + 624dp + 422dp + 2 diff --git a/samples/user-interface/appwidgets/src/main/res/values/strings.xml b/samples/user-interface/appwidgets/src/main/res/values/strings.xml index f1e559a6..eaa048ed 100644 --- a/samples/user-interface/appwidgets/src/main/res/values/strings.xml +++ b/samples/user-interface/appwidgets/src/main/res/values/strings.xml @@ -86,6 +86,8 @@ Checklist Action List Image Grid + Toolbar + Search Toolbar Add @@ -107,5 +109,9 @@ Ideal for titles, status updates, short descriptions, or any scenario where a single line of text effectively conveys the message. Checklist The checklist layout is perfect for displaying tasks, providing clear tap targets for users to easily mark items as done. + Toolbar + Increase user engagement and streamline key workflows by providing instant access to your app\'s most important features with a toolbar widget. + Search Toolbar + Ideal for apps where search is paramount, this layout provides a dedicated search entry point while allowing for additional shortcuts based on available widget space. \ No newline at end of file diff --git a/samples/user-interface/appwidgets/src/main/res/xml/sample_search_toolbar_widget_info.xml b/samples/user-interface/appwidgets/src/main/res/xml/sample_search_toolbar_widget_info.xml new file mode 100644 index 00000000..153d53c2 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/xml/sample_search_toolbar_widget_info.xml @@ -0,0 +1,28 @@ + + diff --git a/samples/user-interface/appwidgets/src/main/res/xml/sample_toolbar_widget_info.xml b/samples/user-interface/appwidgets/src/main/res/xml/sample_toolbar_widget_info.xml new file mode 100644 index 00000000..8b6af4a4 --- /dev/null +++ b/samples/user-interface/appwidgets/src/main/res/xml/sample_toolbar_widget_info.xml @@ -0,0 +1,28 @@ + +