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