I am trying to implement this <Stepper> component ...
# compose
l
I am trying to implement this Stepper component from React with a similar API in Compose. I am trying to see if this is the Compose way of doing things.
Copy code
// 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 thread
Copy code
@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)
      }
    }
  }
}
c
From what I can tell, no this is not the way to do it. This implementation is basically saying “every time I call
handle()
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).
tl;dr: don’t use the Compose code itself to define the model for the stepper. Define your own model, and then render that into Compose.
l
Thanks for the feedback! I see what you mean. I guess I was too hung up on making it 1:1 with the React implementation
If I want the user to supply their own
Step
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?
c
There’s kinda 2 ways you could go about it (and you’ll see both ways used in libraries and in the core Material APIs): The majority of the stepper’s state and interatction is managed entirely by the user. So basically, all you offer is the core
Stepper()
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:
Copy code
@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:
Copy code
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:
Copy code
object StepperScope {
    @Composable
    fun Step(
        text: String,
        status: ItemStatus,
        onNextStepButtonClicked: ()->Unit,
    ) { 
        // ...
    }
}

@Composable
fun Stepper(content: StepperScope.()->Unit) {
    //...
}
So the first example shows that you can build the DSL so that the user can write code however they want to manage it, making it as complex as they need to, but for the simpler use-cases the second example can reduce some of the boilerplate and make it a bit more opinionated. But the second example really is just built on top of the first
l
Awesome! I can see how the parent and child composables can fit with your explanation/examples. Really appreciate you taking the time to answer it!