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,
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)) {
Text(text = title, style = MaterialTheme.typography.bodyLarge)
Spacer(Modifier.height(8.dp))
@@ -13,6 +13,8 @@ import androidx.glance.action.Action
import androidx.glance.action.clickable
import androidx.glance.appwidget.cornerRadius
import androidx.glance.background
import androidx.glance.semantics.contentDescription
import androidx.glance.semantics.semantics
import androidx.glance.layout.Alignment
import androidx.glance.layout.Box
import androidx.glance.layout.Row
@@ -50,20 +52,21 @@ fun WidgetHeader(
modifier = modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically,
) {
val titleModifier = GlanceModifier.defaultWeight().let {
if (titleAction != null) it.clickable(titleAction) else it
}
val clickable = if (titleAction != null) GlanceModifier.clickable(titleAction) else GlanceModifier
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(
modifier = titleModifier
modifier = clickable
.cornerRadius(12.dp)
.background(GlanceTheme.colors.widgetBackground)
.padding(horizontal = 10.dp, vertical = 4.dp),
) {
HeaderText(title)
}
Spacer(GlanceModifier.defaultWeight())
} else {
Box(modifier = titleModifier) {
Box(modifier = GlanceModifier.defaultWeight().then(clickable)) {
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
* the rows below it.
* the rows below it. Filled by default; a header with several actions makes
* the secondary ones [WidgetActionStyle.TONAL].
*/
@Composable
fun WidgetHeaderAction(
@@ -95,20 +99,86 @@ fun WidgetHeaderAction(
contentDescription: String,
action: Action,
modifier: GlanceModifier = GlanceModifier,
style: WidgetActionStyle = WidgetActionStyle.FILLED,
) {
WidgetActionButton(
icon = icon,
contentDescription = contentDescription,
action = action,
modifier = modifier,
style = WidgetActionStyle.FILLED,
style = style,
size = HeaderActionWidth,
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 HeaderActionHeight = 28.dp
private val HeaderActionGap = 4.dp
/**
* A widget's primary tappable action: a [size] × [height] target (a 48dp
@@ -131,25 +201,11 @@ fun WidgetActionButton(
size: Dp = 48.dp,
height: Dp = size,
) {
val container = when (style) {
WidgetActionStyle.TONAL -> GlanceTheme.colors.primaryContainer
WidgetActionStyle.FILLED -> GlanceTheme.colors.primary
}
val content = when (style) {
WidgetActionStyle.TONAL -> GlanceTheme.colors.onPrimaryContainer
WidgetActionStyle.FILLED -> GlanceTheme.colors.onPrimary
}
val container = style.container()
val content = style.content()
val iconSize = height * 0.65f
if (label == null) {
Box(
modifier = modifier
.width(size)
.height(height)
.cornerRadius(height / 2)
.background(container)
.clickable(action),
contentAlignment = Alignment.Center,
) {
ActionPill(style = style, width = size, height = height, action = action, modifier = modifier) {
ActionIcon(icon, contentDescription, content, iconSize = iconSize)
}
} else {
@@ -1,7 +1,9 @@
package de.jeanlucmakiola.floret.glance
import android.os.Build
import android.widget.FrameLayout
import android.widget.RemoteViews
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
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.requiredSize
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.wrapContentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -17,6 +20,7 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
@@ -24,9 +28,11 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.TransformOrigin
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.dimensionResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.DpSize
@@ -74,7 +80,9 @@ fun WallpaperWindow(
/**
* 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
* 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
* [failure] if it can't render; [keys] re-render it when they change.
*
@@ -105,9 +113,12 @@ fun GlanceWidgetPreview(
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(
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,
) {
val current = views
@@ -119,8 +130,9 @@ fun GlanceWidgetPreview(
frame.addView(current.apply(frame.context, frame))
},
modifier = Modifier
.wrapContentSize(Alignment.TopCenter, unbounded = true)
.requiredSize(size)
.graphicsLayer(scaleX = scale, scaleY = scale)
.graphicsLayer(scaleX = scale, scaleY = scale, transformOrigin = TransformOrigin(0.5f, 0f))
.clearAndSetSemantics {}
.pointerInput(Unit) {
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 }
/** 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