square / workflow-kotlin

A Swift and Kotlin library for making composable state machines, and UIs driven by those state machines.
https://square.github.io/workflow
Apache License 2.0
1.03k stars 101 forks source link

Extension library proposal: Stepped workflows #775

Open zach-klippenstein opened 5 years ago

zach-klippenstein commented 5 years ago

Abstract

This is a proposal for a mini-library built on top of Workflows for creating "stepped" flows – a DAG of screens that work together to accomplish some task, driven by a single parent workflow. E.g. a realistic login flow that has multiple authentication methods, password reset, 2-factor, etc.

This proposal is expressed in Kotlin, but a similar design could be done for Swift.

Design

Steps

Each "step" is a workflow. Each step does its own bookkeeping to know when it's "complete" and the next screen should be shown. The step workflows rendering type includes a number of things:

Step rendering values are of the following type:

sealed class StepRendering<out UiT, out ResultT> {
  abstract val uiModel: UiT

  data class InProgress<UiT>(override val uiModel: UiT): StepRendering<UiT, Nothing>()
  data class Complete<UiT, ResultT>(
    override val uiModel: UiT,
    val result: ResultT,
    val onBack: () -> Unit
  ): StepRendering<UiT, ResultT>()
}

A step workflow is of the following type:

interface WorkflowStep<I, O : Any, UiT, ResultT> : Workflow<I, O, StepRendering<UiT, ResultT>>

Step workflows must conform their rendering (or output, see below) type to the special type, but other than that are free to do anything a workflow can do, including receive input, emit output, handle events, run workers, and compose other workflows (even other stepped sub-flows).

Output-based Steps

WorkflowStep requires steps to track their own completion state. Many steps may not need much state tracking however, and are simply "complete" once a "Next" button has been clicked. For those steps, we can provide a helper to effectively move the indication of "complete" from the rendering to the output:

sealed class StepOutput<out O : Any, out ResultT> {
  data class Output<O : Any>(val output: O): StepOutput<O, Nothing>()
  data class Complete<O : Any, ResultT>(
    val result: ResultT,
    val output: O? = null
  ): StepOutput<O, ResultT>()
}

interface SimpleWorkflowStep<I, O : Any, UiT, ResultT> : WorkflowStep<I, StepOutput<O, ResultT>, UiT>

A workflow of type SimpleWorkflowStep can be converted to a WorkflowStep by wrapping it in a workflow that simply keeps track of whether a Complete output has been emitted or the onBack event has been invoked, and returns the appropriate rendering case.

Uniqueness

Note that all wrappers would have the same type, so would appear to be the same "session" to the RenderContext. They would need to be distinguished with keys, which could be derived from the wrapped workflows' class names. However this is kind of hacky, and we could add explicit support for "wrapped" workflows to get around this (e.g. a WrapperWorkflow interface that exposes a val wrapped: Workflow<*, *, *> which is included in the comparison).

Parent Workflow

Given a set of StepWorkflows, it should be apparent how to manually write a workflow that can drive them:

  1. Determine the first step, and render it (forwarding its output).
  2. If the rendering is InProgress, return its UiT – done.
  3. If the rendering is Complete, use the ResultT to determine the next step, and return to 1. The previous step's UiT may either be ignored or composed with the subsequent step's UI, (e.g. building up a BackStackScreen).

Since the parent workflow is just a Workflow, it can be composed with other workflows, or even implement StepWorkflow itself.

Declarative DAG

However, writing all this wiring code manually every time would get verbose, and bury the actual business logic in boilerplate. We can do better. The code we'd like to write would just render a step, get its result, and then continue rendering the subsequent steps, without worrying about Taking inspiration from Haskell, if we think of StepRendering as IO, we can build our own version of do.

Given the RenderContext from the parent, we can define our own wrapper around it that defines its own renderStep method that implicitly builds up the BackStackScreen from the UiTs from each step, and only returns ResultTs to the parent. renderStep could also take an optional function to determine how to fold the new UiT into the previous one (e.g. copying in a body-layer screen under a dialog). The business logic for the parent is written as if all steps were complete, and is about as close to a pure graph definition as we can get. Under the hood, renderStep can stop the render after the first InProgress step by throwing and catching a special exception instead of returning (which it doesn't have enough information to do anyway, since InProgress doesn't contain a ResultT).

Since it's provided as a simple helper function, this glue helper can be invoked as expression body for the parent's render method in simple cases, or the parent can do other rendering work in addition to rendering its steps.

zach-klippenstein commented 5 years ago

Added a note on uniqueness for output-based workflow wrappers.