Lawrence
08/18/2022, 6:38 PM// React
<Stepper activeStep={1}>
{steps.map((label) => (
<Step key={label}>
<StepLabel>{label}</StepLabel>
</Step>
))}
</Stepper>
// Compose
Stepper(Modifier.fillMaxSize(), step) {
steps.forEach { Step(Modifier.padding(15.dp, it) }
}
Implementation in threadLawrence
08/18/2022, 6:38 PM@Composable
internal fun Stepper(
modifier: Modifier,
activeStep: Int,
content: @Composable StepperScope.() -> Unit
) {
val stepperStepLabel = remember(activeStep) { StepperScopeImpl(activeStep) }
Column(modifier, Arrangement.SpaceBetween) { content(stepperStepLabel) }
}
internal interface StepperScope {
fun String.handle(): ItemStatus
}
data class StepItem(val label: String, val key: String, val index: Int)
private class StepperScopeImpl(
private val activeIndex: Int,
) : StepperScope {
private val steps = mutableStateMapOf<String, StepItem>()
private var currentIndex = 0
override fun String.handle(): ItemStatus {
val label = steps.getOrPut(this) { StepItem(this, this, currentIndex++) }
return if (label.index < activeIndex) {
ItemStatus.Done
} else if (label.index > activeIndex) {
ItemStatus.Next
} else {
ItemStatus.Current()
}
}
}
sealed interface ItemStatus {
object Done : ItemStatus
data class Current(val result: Boolean? = null) : ItemStatus
object Next : ItemStatus
}
@Composable
internal fun StepperScope.StepNxt(modifier: Modifier, label: String) {
val status = label.handle()
Row(modifier, verticalAlignment = Alignment.CenterVertically) {
when (status) {
ItemStatus.Done -> {
Icon(
Icons.Default.CheckCircle,
"completed",
Modifier.size(30.dp),
MaterialTheme.colors.primary)
Spacer(Modifier.width(8.dp))
Text(
label,
color = MaterialTheme.colors.primary,
fontWeight = FontWeight.Light,
style = MaterialTheme.typography.h6)
}
is ItemStatus.Current -> {
CircularProgressIndicator(Modifier.size(32.dp))
Spacer(Modifier.width(8.dp))
Text(label, color = MaterialTheme.colors.primary, style = MaterialTheme.typography.h6)
}
ItemStatus.Next -> {
val color = MaterialTheme.colors.onSurface.copy(alpha = 0.12f)
Spacer(Modifier.width(8.dp))
Text(
label,
color = color,
fontWeight = FontWeight.Normal,
style = MaterialTheme.typography.h6)
}
}
}
}
Casey Brooks
08/18/2022, 7:14 PMhandle()
within StepperScope
, add that content as a tab in the stepper”. The problem there is that handle()
may be called many times by Compose internally, each time adding itself as a new tab again. You can technically achieve this kind of behavior safely using SubcomposeLayout
(which is how TabRow
does its magic, using this same principle), but you probably shouldn’t do it like that.
Instead, you would want to “lift” the state of the stepper into some data class that lives above the Stepper()
function, and based on that state you can control what’s actually rendered to the composition (the number of tabs, which one is focused, etc).Casey Brooks
08/18/2022, 7:15 PMLawrence
08/18/2022, 7:20 PMLawrence
08/18/2022, 7:29 PMStep
to the Stepper
is the context/receiver pattern the way to go? Or would it be better to not open such things to the user and just have a Stepper?
Casey Brooks
08/18/2022, 7:59 PMStepper()
and Step()
, with all possible parameters the programmer might need to configure the UI. You leave
it entirely up to the programmer to write code that, for example, handles when the user clicks a button that would
advance to the next step. The user clicks a button, you handle that click and increment the variable that tells what
step you’re on, and then you update each step to show whether it is active or not (most of this recalculation is done
directly within Compose, but it allows for that logic to be extracted elsewhere, such as in an Androidx Model, or
processed with the MVI pattern such as with my Ballast library). This is the
“baseline” for how Compose was designed, and is the best way to start designing the API for you compose code. Here’s an
example of what that code might look like:
@Composable
fun stepperScreen() {
val steps = remember {
listOf(
StepItem(text = "Step 1"),
StepItem(text = "Step 2"),
StepItem(text = "Step 3"),
)
}
var currentStep by remember { mutableStateOf(0) }
Stepper {
steps.forEachIndexed { i, stepItem ->
Step(
text = stepItem.text,
status = when {
i > currentStep -> ItemStatus.Next
i == currentStep -> ItemStatus.Current
i < currentStep -> ItemStatus.Done
},
onNextStepButtonClicked = { currentStep++ },
)
}
}
}
The next way to do it really just builds on top of the example above, but extracting some of that boilerplate logic and
handling that interaction internally. For example, adding the following function:
class StepperState(
var currentStep: MutableState<Int>,
)
@Composable
fun rememberStepperState() {
return remember { StepperState() }
}
@Composable
fun Stepper(steps: List<StepItem>, state: StepperState = rememberStepperState()) {
Stepper {
steps.forEachIndexed { i, stepItem ->
Step(
text = stepItem.text,
status = when {
i > state.currentStep -> ItemStatus.Next
i == state.currentStep -> ItemStatus.Current
i < state.currentStep -> ItemStatus.Done
},
onNextStepButtonClicked = { currentStep.currentStep++ },
)
}
}
}
@Composable
fun stepperScreen() {
val steps = remember {
listOf(
StepItem(text = "Step 1"),
StepItem(text = "Step 2"),
StepItem(text = "Step 3"),
)
}
Stepper(steps)
}
And of course, you could design the API such that Step()
is only available within the Stepper { }
block with
something like this:
object StepperScope {
@Composable
fun Step(
text: String,
status: ItemStatus,
onNextStepButtonClicked: ()->Unit,
) {
// ...
}
}
@Composable
fun Stepper(content: StepperScope.()->Unit) {
//...
}
Casey Brooks
08/18/2022, 8:00 PMLawrence
08/18/2022, 8:09 PM