glance: tonal and text header pills, header action gap, transparent title pill hugs its text, preview at true size cropped with widget corners; components: GroupedChoiceRow side inset

This commit is contained in:
2026-10-23 14:51:22 +02:00
parent 6dc7bc50c5
commit d6b55476d1
4 changed files with 108 additions and 31 deletions
@@ -36,7 +36,7 @@ fun <T> GroupedChoiceRow(
position: Position, position: Position,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
GroupedSurface(position = position, modifier = modifier) { GroupedSurface(position = position, modifier = modifier.padding(horizontal = GroupedListInset)) {
Column(modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 12.dp)) { Column(modifier = Modifier.padding(horizontal = GroupedListInset, vertical = 12.dp)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge) Text(text = title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
@@ -13,6 +13,8 @@ import androidx.glance.action.Action
import androidx.glance.action.clickable import androidx.glance.action.clickable
import androidx.glance.appwidget.cornerRadius import androidx.glance.appwidget.cornerRadius
import androidx.glance.background import androidx.glance.background
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.layout.Alignment import androidx.glance.layout.Alignment
import androidx.glance.layout.Box import androidx.glance.layout.Box
import androidx.glance.layout.Row import androidx.glance.layout.Row
@@ -50,20 +52,21 @@ fun WidgetHeader(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
) { ) {
val titleModifier = GlanceModifier.defaultWeight().let { val clickable = if (titleAction != null) GlanceModifier.clickable(titleAction) else GlanceModifier
if (titleAction != null) it.clickable(titleAction) else it
}
if (transparentTitle) { if (transparentTitle) {
// The pill hugs the title; a spacer takes the rest of the row so it
// doesn't stretch into a full-width bar.
Box( Box(
modifier = titleModifier modifier = clickable
.cornerRadius(12.dp) .cornerRadius(12.dp)
.background(GlanceTheme.colors.widgetBackground) .background(GlanceTheme.colors.widgetBackground)
.padding(horizontal = 10.dp, vertical = 4.dp), .padding(horizontal = 10.dp, vertical = 4.dp),
) { ) {
HeaderText(title) HeaderText(title)
} }
Spacer(GlanceModifier.defaultWeight())
} else { } else {
Box(modifier = titleModifier) { Box(modifier = GlanceModifier.defaultWeight().then(clickable)) {
HeaderText(title) HeaderText(title)
} }
} }
@@ -85,9 +88,10 @@ private fun HeaderText(title: String) {
} }
/** /**
* The standard trailing action of a [WidgetHeader]: a filled, flat pill * The standard trailing action of a [WidgetHeader]: a flat pill
* ([HeaderActionWidth] × [HeaderActionHeight]) that leads without crowding * ([HeaderActionWidth] × [HeaderActionHeight]) that leads without crowding
* the rows below it. * the rows below it. Filled by default; a header with several actions makes
* the secondary ones [WidgetActionStyle.TONAL].
*/ */
@Composable @Composable
fun WidgetHeaderAction( fun WidgetHeaderAction(
@@ -95,20 +99,86 @@ fun WidgetHeaderAction(
contentDescription: String, contentDescription: String,
action: Action, action: Action,
modifier: GlanceModifier = GlanceModifier, modifier: GlanceModifier = GlanceModifier,
style: WidgetActionStyle = WidgetActionStyle.FILLED,
) { ) {
WidgetActionButton( WidgetActionButton(
icon = icon, icon = icon,
contentDescription = contentDescription, contentDescription = contentDescription,
action = action, action = action,
modifier = modifier, modifier = modifier,
style = WidgetActionStyle.FILLED, style = style,
size = HeaderActionWidth, size = HeaderActionWidth,
height = HeaderActionHeight, height = HeaderActionHeight,
) )
} }
/** A [WidgetHeaderAction] that carries a short [text] (a day number, a count) instead of an icon. */
@Composable
fun WidgetHeaderTextAction(
text: String,
contentDescription: String,
action: Action,
modifier: GlanceModifier = GlanceModifier,
style: WidgetActionStyle = WidgetActionStyle.FILLED,
) {
ActionPill(
style = style,
width = HeaderActionWidth,
height = HeaderActionHeight,
action = action,
modifier = modifier.semantics { this.contentDescription = contentDescription },
) {
Text(
text = text,
maxLines = 1,
style = TextStyle(color = style.content(), fontSize = 14.sp, fontWeight = FontWeight.Medium),
)
}
}
@Composable
private fun WidgetActionStyle.container(): ColorProvider = when (this) {
WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer
WidgetActionStyle.FILLED -> GlanceTheme.colors.primary
}
@Composable
private fun WidgetActionStyle.content(): ColorProvider = when (this) {
WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer
WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary
}
/** The standard space between two actions in a [WidgetHeader]'s trailing slot. */
@Composable
fun RowScope.WidgetHeaderActionGap() {
Spacer(GlanceModifier.width(HeaderActionGap))
}
/** The fixed-size, full-pill container both icon and text actions are drawn in. */
@Composable
private fun ActionPill(
style: WidgetActionStyle,
width: Dp,
height: Dp,
action: Action,
modifier: GlanceModifier = GlanceModifier,
content: @Composable () -> Unit,
) {
Box(
modifier = modifier
.width(width)
.height(height)
.cornerRadius(height / 2)
.background(style.container())
.clickable(action),
contentAlignment = Alignment.Center,
content = content,
)
}
private val HeaderActionWidth = 36.dp private val HeaderActionWidth = 36.dp
private val HeaderActionHeight = 28.dp private val HeaderActionHeight = 28.dp
private val HeaderActionGap = 4.dp
/** /**
* A widget's primary tappable action: a [size] × [height] target (a 48dp * A widget's primary tappable action: a [size] × [height] target (a 48dp
@@ -131,25 +201,11 @@ fun WidgetActionButton(
size: Dp = 48.dp, size: Dp = 48.dp,
height: Dp = size, height: Dp = size,
) { ) {
val container = when (style) { val container = style.container()
WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer val content = style.content()
WidgetActionStyle.FILLED -> GlanceTheme.colors.primary
}
val content = when (style) {
WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer
WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary
}
val iconSize = height * 0.65f val iconSize = height * 0.65f
if (label == null) { if (label == null) {
Box( ActionPill(style = style, width = size, height = height, action = action, modifier = modifier) {
modifier = modifier
.width(size)
.height(height)
.cornerRadius(height / 2)
.background(container)
.clickable(action),
contentAlignment = Alignment.Center,
) {
ActionIcon(icon, contentDescription, content, iconSize = iconSize) ActionIcon(icon, contentDescription, content, iconSize = iconSize)
} }
} else { } else {
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.floret.glance package de.jeanlucmakiola.floret.glance
import android.os.Build
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.RemoteViews import android.widget.RemoteViews
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.BoxWithConstraintsScope import androidx.compose.foundation.layout.BoxWithConstraintsScope
@@ -9,6 +11,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.requiredSize import androidx.compose.foundation.layout.requiredSize
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -17,6 +20,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@@ -24,9 +28,11 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.semantics.clearAndSetSemantics import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize import androidx.compose.ui.unit.DpSize
@@ -74,7 +80,9 @@ fun WallpaperWindow(
/** /**
* A Glance [content] composition rendered to RemoteViews and inflated here, so * A Glance [content] composition rendered to RemoteViews and inflated here, so
* a config screen previews the real widget rather than a copy of it. Laid out * a config screen previews the real widget rather than a copy of it. Laid out
* at the widget's own [size] and scaled to fit [maxSize]. A picture, not a * at the widget's own [size], shrunk only to fit [maxSize]'s width so its text
* reads as it does on the home screen, and cropped below [maxSize]'s height,
* keeping the top and closing the cut with the widget's own corner radius. A picture, not a
* control: touches stop here. [placeholder] shows until the first render and * control: touches stop here. [placeholder] shows until the first render and
* [failure] if it can't render; [keys] re-render it when they change. * [failure] if it can't render; [keys] re-render it when they change.
* *
@@ -105,9 +113,12 @@ fun GlanceWidgetPreview(
failed = views == null failed = views == null
} }
} }
val scale = minOf(1f, maxSize.width / size.width, maxSize.height / size.height) val scale = minOf(1f, maxSize.width / size.width)
val corner = widgetCornerRadius() * scale
Box( Box(
modifier = modifier.size(size.width * scale, size.height * scale), modifier = modifier
.size(size.width * scale, minOf(size.height * scale, maxSize.height))
.clip(RoundedCornerShape(corner)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center,
) { ) {
val current = views val current = views
@@ -119,8 +130,9 @@ fun GlanceWidgetPreview(
frame.addView(current.apply(frame.context, frame)) frame.addView(current.apply(frame.context, frame))
}, },
modifier = Modifier modifier = Modifier
.wrapContentSize(Alignment.TopCenter, unbounded = true)
.requiredSize(size) .requiredSize(size)
.graphicsLayer(scaleX = scale, scaleY = scale) .graphicsLayer(scaleX = scale, scaleY = scale, transformOrigin = TransformOrigin(0.5f, 0f))
.clearAndSetSemantics {} .clearAndSetSemantics {}
.pointerInput(Unit) { .pointerInput(Unit) {
awaitPointerEventScope { awaitPointerEventScope {
@@ -133,3 +145,12 @@ fun GlanceWidgetPreview(
} }
} }
} }
/** The corner [WidgetSurface] draws: the system widget radius on API 31+, its fallback below. */
@Composable
private fun widgetCornerRadius(): Dp =
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
dimensionResource(android.R.dimen.system_app_widget_background_radius)
} else {
LegacyWidgetCornerRadius
}
@@ -28,7 +28,7 @@ enum class WidgetBackground { SOLID, TINTED, TRANSPARENT }
val LocalWidgetBackground = compositionLocalOf { WidgetBackground.SOLID } val LocalWidgetBackground = compositionLocalOf { WidgetBackground.SOLID }
/** Falls back to this radius below API 31, where there's no system widget-corner dimension. */ /** Falls back to this radius below API 31, where there's no system widget-corner dimension. */
private val LegacyWidgetCornerRadius = 20.dp internal val LegacyWidgetCornerRadius = 20.dp
/** /**
* A widget's root container: fills the host, takes the system's rounded corner * A widget's root container: fills the host, takes the system's rounded corner