-
Notifications
You must be signed in to change notification settings - Fork 4
Build a flow of screens
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 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.
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.
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.
The complete project with the flow described in this section is available HERE.
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.
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.
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.