feat(components): the preview picker's plate and scaled screen preview
ScaledViewPreview measures real screen composables at a phone viewport and scales the finished layout down; ViewPreviewFrame is the tonal plate it sits on, cross-fading between options at the grouped rows' width and radius so preview and options read as one stack. Converged from Calendula's view and month-style choosers, where the pattern was invented, and now Agendula's quick-add step. The viewport height is a parameter here rather than a calendar-shaped constant.
This commit is contained in:
@@ -10,6 +10,26 @@ are no binary releases; "version" tracks the shared `version` in the root build
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- **`components`** — `GroupedRow` takes a `contentColor`, the pair to the
|
||||
existing `container`: a row given a tint now names the `on*` role that goes
|
||||
with it instead of leaving its text on `onSurface`, which only happens to
|
||||
read on a container the theme did not pick. Wanted by a status row that
|
||||
colours itself by outcome (Agendula's export receipt: `secondaryContainer` on
|
||||
success, `errorContainer` on failure).
|
||||
|
||||
- **`components`** — `ScaledViewPreview` and `ViewPreviewFrame`, the preview
|
||||
picker's two halves: the first measures real screen composables at a phone
|
||||
viewport and scales the finished layout down (swallowing touches and
|
||||
collapsing semantics, so a preview reads as a picture rather than a control),
|
||||
the second is the tonal plate it sits on, cross-fading between options and
|
||||
taking `GroupedListInset` and the grouped rows' outer radius so preview and
|
||||
options read as one stack. Converged from Calendula's view/month-style
|
||||
choosers, where the pattern was invented, and now Agendula's onboarding step
|
||||
for the quick-add bar; the viewport height the app scales from is a parameter
|
||||
rather than a calendar-shaped constant.
|
||||
|
||||
## [0.3.0] - 2026-08-10
|
||||
|
||||
### Added
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.Crossfade
|
||||
import androidx.compose.animation.core.snap
|
||||
import androidx.compose.animation.core.tween
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.graphics.TransformOrigin
|
||||
import androidx.compose.ui.graphics.graphicsLayer
|
||||
import androidx.compose.ui.input.pointer.PointerEventPass
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.layout.layout
|
||||
import androidx.compose.ui.platform.LocalConfiguration
|
||||
import androidx.compose.ui.semantics.clearAndSetSemantics
|
||||
import androidx.compose.ui.unit.Constraints
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.rememberReduceMotion
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* The viewport a preview pretends to be, when the caller does not say: a phone's
|
||||
* width by the height a full-screen view gets under a top bar. Scaling every
|
||||
* preview from one fixed height keeps a chooser's options comparable — each
|
||||
* shows the same slice of screen.
|
||||
*/
|
||||
val DefaultPreviewViewport = 440.dp
|
||||
|
||||
/**
|
||||
* Renders [content] at a full phone viewport and shrinks the finished layout
|
||||
* into a box [height] tall, for a chooser that shows what it is offering rather
|
||||
* than describing it. Callers pass the *real* screen composables, so a preview
|
||||
* cannot drift from what it depicts.
|
||||
*
|
||||
* The [layout] below ignores the incoming constraints so the content measures at
|
||||
* a phone viewport, and a [graphicsLayer] scale shrinks the result.
|
||||
* `Modifier.requiredSize` is the obvious alternative but *centres* content that
|
||||
* overflows its constraints, leaving only the bottom-right corner in the clip.
|
||||
*
|
||||
* [viewportHeight] is what the content is measured at before scaling; raise it
|
||||
* for a screen that needs more room to read than [DefaultPreviewViewport].
|
||||
*/
|
||||
@Composable
|
||||
fun ScaledViewPreview(
|
||||
height: Dp,
|
||||
modifier: Modifier = Modifier,
|
||||
viewportHeight: Dp = DefaultPreviewViewport,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val screenWidth = LocalConfiguration.current.screenWidthDp.dp
|
||||
val scale = height.value / viewportHeight.value
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.clipToBounds()
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
// A preview is a picture, not a control: swallow touches before the
|
||||
// content's own clickables see them, and give screen readers one
|
||||
// label instead of every row inside it.
|
||||
.pointerInput(Unit) {
|
||||
awaitPointerEventScope {
|
||||
while (true) {
|
||||
awaitPointerEvent(PointerEventPass.Initial).changes
|
||||
.forEach { it.consume() }
|
||||
}
|
||||
}
|
||||
}
|
||||
.clearAndSetSemantics { }
|
||||
// Measure at a full phone viewport but report the scaled size, so the
|
||||
// node occupies exactly what it draws.
|
||||
.layout { measurable, _ ->
|
||||
val fullWidth = screenWidth.roundToPx()
|
||||
val fullHeight = viewportHeight.roundToPx()
|
||||
val placeable = measurable.measure(Constraints.fixed(fullWidth, fullHeight))
|
||||
layout((fullWidth * scale).roundToInt(), (fullHeight * scale).roundToInt()) {
|
||||
placeable.place(0, 0)
|
||||
}
|
||||
}
|
||||
.graphicsLayer {
|
||||
scaleX = scale
|
||||
scaleY = scale
|
||||
transformOrigin = TransformOrigin(0f, 0f)
|
||||
},
|
||||
) {
|
||||
Column(Modifier.fillMaxSize()) { content() }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The stage a preview picker sets its preview on, cross-fading as [selected]
|
||||
* changes. A preview draws the app's own surface, so a tonal plate gives it the
|
||||
* edge it would otherwise lack on a chooser screen; the plate takes the width
|
||||
* and outer radius of the [GroupedRow]s underneath so the two read as one stack.
|
||||
*
|
||||
* Only vertical spacing is the caller's to pass — the horizontal inset is
|
||||
* [GroupedListInset], the rows' own, and the alignment is the point.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> ViewPreviewFrame(
|
||||
selected: T,
|
||||
label: String,
|
||||
modifier: Modifier = Modifier,
|
||||
content: @Composable (T) -> Unit,
|
||||
) {
|
||||
val reduceMotion = rememberReduceMotion()
|
||||
Box(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = GroupedListInset)
|
||||
.clip(FrameShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHigh)
|
||||
.padding(FrameInset),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState = selected,
|
||||
animationSpec = if (reduceMotion) snap() else tween(durationMillis = 250),
|
||||
label = label,
|
||||
) { shown ->
|
||||
// Each preview renders the real screen at phone size and scales it
|
||||
// down, so its own corners are square — the plate rounds them.
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.clip(PreviewShape)
|
||||
.background(MaterialTheme.colorScheme.surface)
|
||||
.clipToBounds(),
|
||||
) {
|
||||
content(shown)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private val FrameInset = 8.dp
|
||||
|
||||
/** The grouped rows' own outer radius, so the stack shares one silhouette. */
|
||||
private val FrameShape = RoundedCornerShape(22.dp)
|
||||
private val PreviewShape = RoundedCornerShape(12.dp)
|
||||
Reference in New Issue
Block a user