feat(components): checked rows, switch rows, custom-expand row and option rows
GroupedRow gains checked, OptionPicker gains dismissOnSelect and exposes its rows as OptionRows. New GroupedSwitchRow, CustomExpandRow, GroupedHint and ExpandIcon. Every existing call keeps its behaviour.
This commit is contained in:
@@ -159,6 +159,10 @@ fun GroupedSurface(
|
||||
* the inter-row gap for a caller that owns its own uniform spacing, e.g.
|
||||
* [ReorderableColumn]. [onLongClick] (see [GroupedSurface]) adds a long-press to
|
||||
* the row.
|
||||
*
|
||||
* [checked] marks a picker row as the chosen one: it implies [selected] and puts
|
||||
* [SelectedCheck] in the trailing slot when [trailing] is null — the single-select
|
||||
* picker row without the `if (selected) { { SelectedCheck() } } else null` dance.
|
||||
*/
|
||||
@Composable
|
||||
fun GroupedRow(
|
||||
@@ -176,6 +180,7 @@ fun GroupedRow(
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
checked: Boolean = false,
|
||||
) = GroupedRow(
|
||||
title = AnnotatedString(title),
|
||||
position = position,
|
||||
@@ -191,6 +196,7 @@ fun GroupedRow(
|
||||
trailing = trailing,
|
||||
onClick = onClick,
|
||||
onLongClick = onLongClick,
|
||||
checked = checked,
|
||||
)
|
||||
|
||||
/**
|
||||
@@ -215,8 +221,15 @@ fun GroupedRow(
|
||||
trailing: @Composable (() -> Unit)? = null,
|
||||
onClick: (() -> Unit)? = null,
|
||||
onLongClick: (() -> Unit)? = null,
|
||||
checked: Boolean = false,
|
||||
) {
|
||||
val itemColors = if (selected) {
|
||||
val highlighted = selected || checked
|
||||
val trailingContent = trailing ?: if (checked) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
val itemColors = if (highlighted) {
|
||||
ListItemDefaults.colors(
|
||||
containerColor = Color.Transparent,
|
||||
headlineColor = MaterialTheme.colorScheme.onSecondaryContainer,
|
||||
@@ -246,7 +259,7 @@ fun GroupedRow(
|
||||
ListItemDefaults.colors(containerColor = Color.Transparent)
|
||||
}
|
||||
val containerColor = when {
|
||||
selected -> MaterialTheme.colorScheme.secondaryContainer
|
||||
highlighted -> MaterialTheme.colorScheme.secondaryContainer
|
||||
container != null -> container
|
||||
else -> MaterialTheme.colorScheme.surfaceContainerHigh
|
||||
}
|
||||
@@ -262,7 +275,7 @@ fun GroupedRow(
|
||||
headlineContent = { Text(title) },
|
||||
supportingContent = summary?.let { text -> { Text(text) } },
|
||||
leadingContent = leading,
|
||||
trailingContent = trailing,
|
||||
trailingContent = trailingContent,
|
||||
colors = itemColors,
|
||||
modifier = Modifier.heightIn(min = minHeight),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Switch
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.rotate
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.TextStyle
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.dp
|
||||
import de.jeanlucmakiola.floret.identity.collapseExit
|
||||
import de.jeanlucmakiola.floret.identity.expandEnter
|
||||
|
||||
/**
|
||||
* A [GroupedRow] with a trailing [Switch], where a tap anywhere on the row flips
|
||||
* it — the family's settings toggle. [enabled] false dims the row and disables
|
||||
* both the row and the switch.
|
||||
*/
|
||||
@Composable
|
||||
fun GroupedSwitchRow(
|
||||
title: String,
|
||||
checked: Boolean,
|
||||
onCheckedChange: (Boolean) -> Unit,
|
||||
position: Position,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
enabled: Boolean = true,
|
||||
leading: @Composable (() -> Unit)? = null,
|
||||
) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
position = position,
|
||||
modifier = modifier,
|
||||
summary = summary,
|
||||
dimmed = !enabled,
|
||||
leading = leading,
|
||||
trailing = {
|
||||
Switch(checked = checked, onCheckedChange = onCheckedChange, enabled = enabled)
|
||||
},
|
||||
onClick = if (enabled) ({ onCheckedChange(!checked) }) else null,
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The "Custom…" row that closes a presets-plus-custom picker: a [GroupedRow] that
|
||||
* toggles [expanded] on tap and reveals [editor] beneath it (typically a
|
||||
* [CustomAmountEditor]). Expanded, the row takes [Position.Top] so it and the
|
||||
* editor read as one container; collapsed it sits at [position].
|
||||
*
|
||||
* [checked] marks the custom value as the current choice, as on the preset rows;
|
||||
* the caller names it through [title]/[summary].
|
||||
*/
|
||||
@Composable
|
||||
fun CustomExpandRow(
|
||||
title: String,
|
||||
position: Position,
|
||||
expanded: Boolean,
|
||||
onExpandedChange: (Boolean) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
summary: String? = null,
|
||||
checked: Boolean = false,
|
||||
editor: @Composable () -> Unit,
|
||||
) {
|
||||
Column(modifier) {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
summary = summary,
|
||||
position = if (expanded) Position.Top else position,
|
||||
onClick = { onExpandedChange(!expanded) },
|
||||
checked = checked,
|
||||
)
|
||||
AnimatedVisibility(
|
||||
visible = expanded,
|
||||
enter = expandEnter(),
|
||||
exit = collapseExit(),
|
||||
) {
|
||||
editor()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Muted supporting text outside the cards — under a [GroupedSectionHeader], or
|
||||
* above a picker's rows — inset to [GroupedListInset] so it shares their edge.
|
||||
* [style] and [verticalPadding] cover the denser variants (`bodySmall`, 8dp under
|
||||
* a picker title).
|
||||
*/
|
||||
@Composable
|
||||
fun GroupedHint(
|
||||
text: String,
|
||||
modifier: Modifier = Modifier,
|
||||
style: TextStyle = MaterialTheme.typography.bodyMedium,
|
||||
verticalPadding: Dp = 4.dp,
|
||||
) {
|
||||
Text(
|
||||
text = text,
|
||||
style = style,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = modifier.padding(horizontal = GroupedListInset, vertical = verticalPadding),
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* The open/shut chevron for a row that folds content beneath it; turns over as
|
||||
* [expanded] changes. Decorative — the row carries the semantics.
|
||||
*/
|
||||
@Composable
|
||||
fun ExpandIcon(
|
||||
expanded: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
tint: Color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
) {
|
||||
val turn by animateFloatAsState(if (expanded) 180f else 0f, label = "expand-icon")
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = tint,
|
||||
modifier = modifier.rotate(turn),
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,11 @@
|
||||
package de.jeanlucmakiola.floret.components
|
||||
|
||||
import androidx.compose.animation.core.animateFloatAsState
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.rounded.KeyboardArrowDown
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
@@ -20,7 +16,6 @@ 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.rotate
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
|
||||
@@ -74,7 +69,7 @@ fun InstructionSteps(
|
||||
minHeight = 56.dp,
|
||||
// No disc: the empty leading slot is what tells the header
|
||||
// apart from the numbered rows it opens.
|
||||
trailing = { Chevron(open) },
|
||||
trailing = { ExpandIcon(open) },
|
||||
onClick = { open = !open },
|
||||
)
|
||||
}
|
||||
@@ -141,22 +136,5 @@ private fun StepNumber(number: Int) {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The open/shut marker.
|
||||
*
|
||||
* Decorative: the row it sits on carries the name and the click, so a screen
|
||||
* reader announcing a chevron as well would be describing the furniture.
|
||||
*/
|
||||
@Composable
|
||||
private fun Chevron(open: Boolean) {
|
||||
val turn by animateFloatAsState(if (open) 180f else 0f, label = "instruction-chevron")
|
||||
Icon(
|
||||
imageVector = Icons.Rounded.KeyboardArrowDown,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.rotate(turn),
|
||||
)
|
||||
}
|
||||
|
||||
/** The leading slot's own size, matched to the icons that usually fill it. */
|
||||
private val StepNumberSize = 28.dp
|
||||
|
||||
@@ -105,6 +105,9 @@ fun FullScreenPicker(
|
||||
* the cheap middle rung of the richness ladder described on [FullScreenPicker]:
|
||||
* every option shows its effect at once and stays comparable, without the cost
|
||||
* of a live preview.
|
||||
*
|
||||
* [dismissOnSelect] closes the picker on a pick (the default). Pass false for a
|
||||
* preview picker, which applies at once and stays open (see [FullScreenPicker]).
|
||||
*/
|
||||
@Composable
|
||||
fun <T> OptionPicker(
|
||||
@@ -118,28 +121,49 @@ fun <T> OptionPicker(
|
||||
leading: (@Composable (T) -> Unit)? = null,
|
||||
header: (@Composable ColumnScope.() -> Unit)? = null,
|
||||
predictiveBack: Boolean = false,
|
||||
dismissOnSelect: Boolean = true,
|
||||
) {
|
||||
FullScreenPicker(title = title, onDismiss = onDismiss, predictiveBack = predictiveBack) {
|
||||
header?.invoke(this)
|
||||
options.forEachIndexed { index, option ->
|
||||
val isSelected = option == selected
|
||||
GroupedRow(
|
||||
title = label(option),
|
||||
summary = summary?.invoke(option),
|
||||
position = positionOf(index, options.size),
|
||||
selected = isSelected,
|
||||
leading = leading?.let { { it(option) } },
|
||||
trailing = if (isSelected) {
|
||||
{ SelectedCheck() }
|
||||
} else {
|
||||
null
|
||||
},
|
||||
onClick = {
|
||||
onSelect(option)
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
}
|
||||
OptionRows(
|
||||
options = options,
|
||||
selected = selected,
|
||||
label = label,
|
||||
onSelect = {
|
||||
onSelect(it)
|
||||
if (dismissOnSelect) onDismiss()
|
||||
},
|
||||
summary = summary,
|
||||
leading = leading,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* [OptionPicker]'s option list on its own: one connected run of [GroupedRow]s,
|
||||
* the current option highlighted and checked. For a picker that composes its own
|
||||
* [FullScreenPicker] — a preview above the rows, a description between — but
|
||||
* wants the standard rows underneath. [onSelect] only reports the pick; closing
|
||||
* the picker is the caller's call.
|
||||
*/
|
||||
@Composable
|
||||
fun <T> OptionRows(
|
||||
options: List<T>,
|
||||
selected: T,
|
||||
label: @Composable (T) -> String,
|
||||
onSelect: (T) -> Unit,
|
||||
summary: (@Composable (T) -> String?)? = null,
|
||||
leading: (@Composable (T) -> Unit)? = null,
|
||||
) {
|
||||
options.forEachIndexed { index, option ->
|
||||
GroupedRow(
|
||||
title = label(option),
|
||||
summary = summary?.invoke(option),
|
||||
position = positionOf(index, options.size),
|
||||
leading = leading?.let { { it(option) } },
|
||||
onClick = { onSelect(option) },
|
||||
checked = option == selected,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user