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:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user