Skip to content

Build a flow of screens

Maxim Kostenko edited this page Oct 30, 2022 · 3 revisions

In the Kompot framework, every screen has a clear contract that defines its input and output. This way, our screens are independent of the context of their usage, and we can easily build any flow with reusable UI.

We'll show an example by mocking a flow of adding a contact. That is a simple flow you can find in any messenger or a contact app.

For our simple case of creating a contact, we'll need to input their first and last names and show success to the user.

But first, let's start with the flow components overview.

Flow components

Flow

Flow is in many points similar to the screen. They both inherit the Controller class and therefore could be used to render views and respond to the user's interaction with the UI. But the main goal of the flow is to be a container for the nested screens. Flow can display some common UI parts for all the screens it hosts, but the main part of the flow's view is dedicated to a screen container. The same approach is used in the NavHostFragment from the jetpack.

Flow model

The flow model hosts all the logic of the flow. Its main goal is to instantiate screens and to control their navigation. It has a set of predefined commands for the navigation and an API to keep a state of the user's interaction with the screens. Like the screen model, flow can also respond to UI events and do some basic logic as loading data, performing calculation etc.

Input and output

Flow follows the same concept as the screen to have a clear input and output. Like the screens, flows can be nested in the other flows to give you a flexible way to reuse your logic. That is why every flow has InputData and OutputData defined in its contract or uses the default IOData.EmptyInput and IOData.EmptyOutput if no alternatives provided.

Implementing AddContactFlow

The complete project with the flow described in this section is available HERE.

AddContactFlow contract

All components of the flow need to be declared in the corresponding contract. You can find it very similar to the screens contract. The main reason is that both Flow and Screen inherit Controller class and have many common points.

interface AddContactFlowContract {

    interface FlowModelApi : FlowModel<Step, IOData.EmptyOutput>

    @Parcelize
    data class State(
        val firstName: String? = null
    ) : FlowState

    sealed class Step : FlowStep {
        @Parcelize
        object InputFirstName : Step()

        @Parcelize
        object InputLastName : Step()

        @Parcelize
        data class Success(
            val firstName: String,
            val lastName: String
        ): Step()
    }
}

Let's take a detailed look at the contract components. FlowModelApi describes the behaviour of the flow model. Flow uses this interface to communicate with the model without knowing too many details about it. The same concept is used with the screen.

The flow hosts a sequence of screens, and you may want to keep the result of the user's interaction with the screens in a single place. For that purpose, you can declare the desired State structure in the contract and use it to keep your critical data (e.g. screen results, data for the next screens in the flow etc.) In our case, we will need to store the user's input from the screens to display the success with the result of adding a contact. The State class has firstName field for that purpose.

The flow contract also declares a Step. Each step is associated with the screen that our flow can show. The flow of adding a contact contains three steps:

Input first name -> Input last name -> Success.

We define those steps in the contract to use them as commands in our flow model.

AddContactFlow model

internal class AddContactFlowModel @Inject constructor() : BaseRootFlowModel<State, Step>(),
    AddContactFlowContract.FlowModelApi {

    override val initialStep = Step.InputFirstName
    override val initialState = State()

    override fun getController(step: Step): Controller = when (step) {
        is Step.InputFirstName -> InputScreen(InputData(InputType.FIRST_NAME)).apply {
            onScreenResult = { output ->
                currentState = currentState.copy(firstName = output.text)
                next(Step.InputLastName, addCurrentStepToBackStack = true)
            }
        }
        is Step.InputLastName -> InputScreen(InputData(InputType.LAST_NAME)).apply {
            onScreenResult =  { output ->
                val firstName = currentState.firstName.orEmpty()
                val lastName = output.text
                next(Step.Success(firstName, lastName), addCurrentStepToBackStack = true)
            }
        }
        is Step.Success -> TextScreen(getSuccessText(step.firstName, step.lastName))
    }

    private fun getSuccessText(firstName: String, lastName: String) =
        "New contact created: $firstName $lastName"

}

Main purpose of the flow model is to control screens navigation. In the getController() method we tell AddContractFlowModel how to instantiate our screens for each of the steps. With the help of the initialStep, our flow model decides which screen to show first.

When the screen returns a result, the flow model uses its output to update the state and command which screen to show next. You can see this logic in onScreenResult block.

After we get the first name of the contact, we store it in the AddContactFlowModel's state:

currentState = currentState.copy(firstName = output.text)

After that the following command tells the model to go to the next screen:

next(Step.InputLastName, addCurrentStepToBackStack = true)

addCurrentStepToBackStack = true params decide whether we need to store a screen in the back stack.

The last step of the flow is the success screen. We pass all the data collected from the previous screens to the TextScreen to show a result of adding a contact.

AddContactFlow

Our AddContactFlow is quite simple. Since we use it only as a navigation host, there is no UI logic that we need to manage. That is why AddContactFlow contains only the logic of getting the components:

class AddContactFlow : RootFlow<AddContactFlowContract.Step, IOData.EmptyInput>(IOData.EmptyInput) {

    override val layoutId = R.layout.flow_root
    override val fitStatusBar: Boolean = true

    override val component: AddContactFlowComponent by lazy(LazyThreadSafetyMode.NONE) {
        (activity.application as App)
            .appComponent
            .addContactFlowComponent
            .flow(this)
            .build()
    }

    override val flowModel by lazy(LazyThreadSafetyMode.NONE) {
        component.flowModel
    }

    override val containerForModalNavigation: ControllerContainerFrameLayout
        get() = view.findViewById(R.id.containerModal)
}

Here Dagger2 is used for providing the AddContactFlowModel that will manage the navigation. You can find more details on how to setup Dagger for your screens and flows on our Build your first screen page.

This flow doesn't operate with the views. All functionality to manage the container and screen/flows navigation is available from the box. But if you need to do some UI changes for each step of the flow, you can use updateUi method and access the views there.

Please note that the add contact flow presented above is also a root flow of this sample app. Therefore there is a bit more boilerplate written. In the regular flow, there is no container for the modal navigation.