components: numbered instruction steps
A connected list for a procedure the user has to carry out somewhere else. Collapsible, so it can be shown again as a reference once it has already had a screen of its own.
This commit is contained in:
@@ -0,0 +1,162 @@
|
||||
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
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
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
|
||||
|
||||
/**
|
||||
* A short procedure the user has to carry out somewhere else, as a connected
|
||||
* list of numbered steps.
|
||||
*
|
||||
* A component rather than a paragraph, because the instructions that matter most
|
||||
* in a sign-in flow are for a *different* app — go to this site, turn that
|
||||
* setting on, bring the result back here — and four actions written as prose is
|
||||
* the form in which people miss the third one.
|
||||
*
|
||||
* Built from [GroupedRow] rather than as a tinted card, so a procedure looks
|
||||
* like every other run of rows in the family: one rounded container, full
|
||||
* corners at the ends, small ones between. That buys two things a card did not.
|
||||
* Each step is a row of its own, so a long one wraps inside its own container
|
||||
* instead of pushing the rest of a paragraph around; and the numerals sit in the
|
||||
* leading slot every other row puts an avatar or icon in, which is what makes
|
||||
* the column of them scan as a sequence.
|
||||
*
|
||||
* [title] renders as the group's [GroupedSectionHeader] — omit it where the
|
||||
* screen already carries the headline, or the same sentence is said twice.
|
||||
* [footnote] is the caveat that is not a step: what this will cost, or what it
|
||||
* will not do.
|
||||
*
|
||||
* [collapsible] folds the whole thing behind its [title] as a row that opens on
|
||||
* a tap, closed to begin with. For a procedure the user has *already been shown*
|
||||
* — carried along afterwards so it can be re-read rather than remembered — that
|
||||
* is the difference between a reference and a wall repeating itself. It needs a
|
||||
* [title], since collapsed there is nothing else to tap.
|
||||
*/
|
||||
@Composable
|
||||
fun InstructionSteps(
|
||||
steps: List<String>,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String? = null,
|
||||
footnote: String? = null,
|
||||
collapsible: Boolean = false,
|
||||
) {
|
||||
var open by rememberSaveable { mutableStateOf(false) }
|
||||
// Collapsed only where there is a header to reopen from.
|
||||
val folds = collapsible && title != null
|
||||
Column(modifier) {
|
||||
when {
|
||||
folds -> {
|
||||
GroupedRow(
|
||||
title = title,
|
||||
// Alone while shut, the head of the run once open — the
|
||||
// corners are what say whether anything follows.
|
||||
position = if (open) Position.Top else Position.Alone,
|
||||
minHeight = 56.dp,
|
||||
// No disc: the empty leading slot is what tells the header
|
||||
// apart from the numbered rows it opens.
|
||||
trailing = { Chevron(open) },
|
||||
onClick = { open = !open },
|
||||
)
|
||||
}
|
||||
|
||||
title != null -> GroupedSectionHeader(title)
|
||||
}
|
||||
if (folds && !open) return@Column
|
||||
steps.forEachIndexed { index, step ->
|
||||
GroupedRow(
|
||||
title = step,
|
||||
// The header is the group's first row when there is one, so the
|
||||
// steps run from the middle rather than starting the shape over.
|
||||
position = if (folds) {
|
||||
positionOf(index + 1, steps.size + 1)
|
||||
} else {
|
||||
positionOf(index, steps.size)
|
||||
},
|
||||
// Roomy enough for one line, free to grow for three. A step that
|
||||
// wraps is the normal case here, not the exception.
|
||||
minHeight = 56.dp,
|
||||
leading = { StepNumber(index + 1) },
|
||||
)
|
||||
}
|
||||
footnote?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
// Outside the cards but belonging to them, so it starts on the
|
||||
// same line they do.
|
||||
modifier = Modifier.padding(
|
||||
start = GroupedListInset,
|
||||
end = GroupedListInset,
|
||||
top = 8.dp,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The step's number, in the leading slot.
|
||||
*
|
||||
* A filled `primary` disc rather than a bare numeral: on a neutral row the
|
||||
* accent is what marks the column as a sequence rather than a list of unrelated
|
||||
* settings, and it holds that reading at a glance without a rule down the side.
|
||||
*
|
||||
*/
|
||||
@Composable
|
||||
private fun StepNumber(number: Int) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(StepNumberSize)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.primary),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Text(
|
||||
text = number.toString(),
|
||||
style = MaterialTheme.typography.labelLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onPrimary,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
Reference in New Issue
Block a user