In mobile applications, there are forms with complex multi-step filling - for example, questionnaires or applications. The design of such features usually causes a headache for developers: a large amount of data is transferred between screens and rigid connections are formed - who, to whom, in what order should this data be transmitted and which screen to open next after itself.
In this article I will share a convenient way to organize the work of a step-by-step feature. With its help, it is possible to minimize connections between screens and easily make changes to the order of steps: add new screens, change their sequence and the logic of displaying to the user.
* By the word "feature" in this article, I mean a set of screens in a mobile application that are logically connected and represent one function for the user.
Typically, filling out forms and submitting applications in mobile applications consists of several sequential screens. Data from one screen may be needed on another, and the step chains sometimes change depending on the answers. Therefore, it is useful to enable the user to save the data "in draft" so that he can return to the process later.
There can be many screens, but in fact the user fills in one large object with data. In this article I will tell you how to conveniently organize work with a chain of screens that are one scenario.
Let's say a user is applying for a job and filling out a form. If it is interrupted in the middle, the entered data will be saved in the draft. When the user returns to filling out, the information from the draft will be automatically substituted into the fields of the questionnaire - he does not need to fill out everything from scratch.
When the user fills out the entire questionnaire, his response will be sent to the server.
The questionnaire consists of:
- Step 1 - Full name, type of education, work experience,
- Step 2 - place of study
- Step 3 - place of work or essay about yourself,
- Step 4 - the reasons why the vacancy is interested.
The questionnaire will change depending on whether the user has education and work experience. If there is no education, we exclude the step with filling the place of study. If there is no work experience, ask the user to write a little about himself.
At the design stage, we have to answer several questions:
- How to make the feature script flexible and be able to easily add and remove steps.
- How to ensure that when you open a step, the required data will already be filled in (for example, the "Education" screen at the entrance is waiting for an already known type of education to rebuild the composition of its fields).
- How to aggregate data into a common model for transferring to the server after the final step.
- How to save an application to a "draft" so that the user can interrupt filling and return to it later.
As a result, we want to get the following functionality: The
whole example is in my repository on GitHub
An obvious solution
If you develop a feature "in full power saving mode", the most obvious thing is to create an application object and transfer it from screen to screen, refilling it at each step.
Light gray color will mark the data that is not needed at a particular step. At the same time, they are transmitted to each screen in order to eventually enter the final application.
Of course, all this data should be packed into one application object. Let's see how it will look:
class Application(
val name: String?,
val surname: String?,
val educationType : EducationType?,
val workingExperience: Boolean?
val education: Education?,
val experience: Experience?,
val motivation: List<Motivation>?
)
BUT!
Working with such an object, we doom our code to be covered with an extra unnecessary number of null checks. For example, this data structure does not in any way guarantee that the field
educationType
will already be filled in on the "Education" screen.
How to do better
I recommend moving the data management into a separate object, which will provide the necessary non-nullable data as input to each step and save the result of each step to a draft. We will call this object an interactor. It corresponds to the Use Case layer from the pure architecture of Robert Martin and for all screens is responsible for providing data collected from various sources (network, database, data from previous steps, data from a draft proposal ...).
On our projects, we at Surf use Dagger. For a number of reasons, it is customary to make interactors @PerApplication scopes: this makes our interactor a singleton within the application. In fact, the interactor can be a singleton within a feature, or even an activation - if all your steps are fragments. It all depends on the overall architecture of your application.
Further in the examples, we will assume that we have a single instance of the interactor for the entire application. Therefore, all data must be cleared when the script ends.
When setting the task, in addition to centralized data storage, we wanted to organize easy management of the composition and order of steps in the application: depending on what the user has already filled in, they can change. Therefore, we need one more entity - the Scenario. Her area of โโresponsibility is keeping the order of the steps that the user must go through.
The organization of a step-by-step feature using scripts and an interactor allows:
- It is painless to change the steps in the script: for example, overlapping further work, if during the execution it turns out that the user cannot submit requests or add steps if more information is needed.
- Set contracts: what data must be at the input and output of each step.
- Organize saving the application to a draft if the user has not completed all the screens.
Prefill screens with data saved in draft.
Basic entities
The mechanism of the feature will consist of:
- A set of models for describing a step, inputs and outputs.
- Scenario - an entity that describes which steps (screens) the user needs to go through.
- Interaktora (ProgressInteractor) - a class responsible for storing information about the current active step, aggregating the filled information after the completion of each step and issuing input data to start a new step.
- Draft (ApplicationDraft) - a class responsible for storing filled information.
The class diagram represents all the underlying entities from which concrete implementations will inherit. Let's see how they are related.
For the Scenario entity, we will set an interface in which we describe what logic we expect for any scenario in the application (contain a list of necessary steps and rebuild it after completing the previous step, if necessary.
The application can have several features, consisting of many sequential screens, and each will We will move all the general logic that does not depend on the feature or specific data into the base class ProgressInteractor.
ApplicationDraft is not present in the base classes, since saving the data that the user has filled in to a draft may not be required. Therefore, a concrete implementation of ProgressInteractor will work with the draft. Screen presenters will interact with it.
Class diagram for specific implementations of base classes:
All these entities will interact with each other and with screen presenters as follows: There are
quite a few classes, so let's analyze each block separately using the example of the feature from the beginning of the article.
Description of steps
Let's start with the first point. We need entities to describe the steps:
// , ,
interface Step
For the feature from our job application example, the steps are as follows:
/**
*
*/
enum class ApplicationSteps : Step {
PERSONAL_INFO, //
EDUCATION, //
EXPERIENCE, //
ABOUT_ME, // " "
MOTIVATION //
}
We also need to describe the input data for each step. To do this, we will use sealed classes for their intended purpose - to create a limited class hierarchy.
How it will look in code
:
//
interface StepInData
:
//,
sealed class ApplicationStepInData : StepInData
//
class EducationStepInData(val educationType: EducationType) : ApplicationStepInData()
//
class MotivationStepInData(val values: List<Motivation>) : ApplicationStepInData()
We describe the output in a similar way:
How it will look in code
// ,
interface StepOutData
//,
sealed class ApplicationStepOutData : StepOutData
// " "
class PersonalInfoStepOutData(
val info: PersonalInfo
) : ApplicationStepOutData()
// ""
class EducationStepOutData(
val education: Education
) : ApplicationStepOutData()
// " "
class ExperienceStepOutData(
val experience: WorkingExperience
) : ApplicationStepOutData()
// " "
class AboutMeStepOutData(
val info: AboutMe
) : ApplicationStepOutData()
// " "
class MotivationStepOutData(
val motivation: List<Motivation>
) : ApplicationStepOutData()
If we didnโt set the goal of keeping unfilled applications in drafts, we could limit ourselves to this. But since each screen can open not only empty, but also filled from the draft, both input data and data from the draft will come to the input from the interactor - if the user has already entered something.
Therefore, we need another set of models to bring this data together. Some steps do not need information to enter and only provide a field for data from the draft
How it will look in code
/**
* + ,
*/
interface StepData<I : StepInData, O : StepOutData>
sealed class ApplicationStepData : StepData<ApplicationStepInData, ApplicationStepOutData> {
class PersonalInfoStepData(
val outData: PersonalInfoStepOutData?
) : ApplicationStepData()
class EducationStepData(
val inData: EducationStepInData,
val outData: EducationStepOutData?
) : ApplicationStepData()
class ExperienceStepData(
val outData: ExperienceStepOutData?
) : ApplicationStepData()
class AboutMeStepData(
val outData: AboutMeStepOutData?
) : ApplicationStepData()
class MotivationStepData(
val inData: MotivationStepInData,
val outData: MotivationStepOutData?
) : ApplicationStepData()
}
We act according to the script
With the description of the steps and input / output data sorted out. Now let's fix the order of these steps in the feature script in the code. The Scenario entity is responsible for managing the current order of steps. The script will look like this:
/**
* , ,
*/
interface Scenario<S : Step, O : StepOutData> {
//
val steps: List<S>
/**
*
*
*/
fun reactOnStepCompletion(stepOut: O)
}
In the implementation for our example, the script will be like this:
class ApplicationScenario : Scenario<ApplicationStep, ApplicationStepOutData> {
override val steps: MutableList<ApplicationStep> = mutableListOf(
PERSONAL_INFO,
EDUCATION,
EXPERIENCE,
MOTIVATION
)
override fun reactOnStepCompletion(stepOut: ApplicationStepOutData) {
when (stepOut) {
is PersonalInfoStepOutData -> {
changeScenarioAfterPersonalStep(stepOut.info)
}
}
}
private fun changeScenarioAfterPersonalStep(personalInfo: PersonalInfo) {
applyExperienceToScenario(personalInfo.hasWorkingExperience)
applyEducationToScenario(personalInfo.education)
}
/**
* -
*/
private fun applyEducationToScenario(education: EducationType) {...}
/**
* ,
*
*/
private fun applyExperienceToScenario(hasWorkingExperience: Boolean) {...}
}
It should be borne in mind that any change in the script must be two-way. Let's say you remove a step. Make sure that if the user goes back and selects a different option, the step is added to the script.
How, for example, does the code look like the reaction to the presence or absence of work experience
/**
* ,
*
*/
private fun applyExperienceToScenario(hasWorkingExperience: Boolean) {
if (hasWorkingExperience) {
steps.replaceWith(
condition = { it == ABOUT_ME },
newElem = EXPERIENCE
)
} else {
steps.replaceWith(
condition = { it == EXPERIENCE },
newElem = ABOUT_ME
)
}
}
How Interactor works
Consider the next building block in the architecture of a step-by-step feature - an interactor. As we said above, its main responsibility is to service the switching between steps: give the necessary data to the input to the steps and aggregate the output data into a draft request.
Let's create a base class for our interactor and put in it the behavior common to all step-by-step features.
/**
*
* S -
* I -
* O -
*/
abstract class ProgressInteractor<S : Step, I : StepInData, O : StepOutData>
The interactor must work with the current scenario: notify him about the completion of the next step so that the scenario can rebuild its set of steps. Therefore, we will declare an abstract field for our script. Now each specific interactor will be required to provide its own implementation.
// ,
protected abstract val scenario: Scenario<S, O>
The interactor is also responsible for storing the state of which step is currently active and switching to the next or previous one. It must promptly notify the root screen of the step change so that it can switch to the desired fragment. All this can be easily organized using event broadcasting, i.e. a reactive approach. Also, the methods of our interactor will often perform asynchronous operations (loading data from the network or database), so we will use RxJava to communicate with the interactor with the presenters. If you are not already familiar with this tool, read the introductory article series .
Let's create a model that describes the information required by the screens about the current step and its position in the script:
/**
*
*/
class StepWithPosition<S : Step>(
val step: S,
val position: Int,
val allStepsCount: Int
)
Let's start a BehaviorSubject in the interactor to freely emit information about the new active step into it.
private val stepChangeSubject = BehaviorSubject.create<StepWithPosition<S>>()
So that screens can subscribe to this stream of events, we will create a public variable stepChangeObservable, which is a wrapper over our stepChangeSubject.
val stepChangeObservable: Observable<StepWithPosition<S>> = stepChangeSubject.hide()
During the work of the interactor, it is often necessary to know the position of the current active step. I recommend creating a separate property in the interactor - currentStepIndex and overriding the get () and set () methods. This gives us convenient access to this information from subject.
How it looks in code
//
private var currentStepIndex: Int
get() = stepChangeSubject.value?.position ?: 0
set(value) {
stepChangeSubject.onNext(
StepWithPosition(
step = scenario.steps[value],
position = value,
allStepsCount = scenario.steps.count()
)
)
}
Let's write a general part that will work the same regardless of the specific implementation of the interactor for the feature.
Let's add methods for initializing and terminating the work of the interactor, making them open for extension in descendants:
Methods for initialization and shutdown
/**
*
*/
@CallSuper
open fun initProgressFeature() {
currentStepIndex = 0
}
/**
*
*/
@CallSuper
open fun closeProgressFeature() {
currentStepIndex = 0
}
Let's add the functions that any step-by-step feature interactor should perform:
- getDataForStep (step: S) - provide data as input to step S;
- completeStep (stepOut: O) - save the O output and move the script to the next step;
- toPreviousStep () โ- Move the script to the previous step.
Let's start with the first function - processing input data. Each interactor will determine for itself how and where to get the input data from. Let's add an abstract method responsible for this:
/**
*
*/
protected abstract fun resolveStepInData(step: S): Single<out StepData<I, O>>
For presenters of specific screens, add a public method that will call
resolveStepInData() :
/**
*
*/
fun getDataForStep(step: S): Single<out StepData<I, O>> = resolveStepInData(step)
You can simplify this code by making the method public
resolveStepInData()
. The method is getDataForStep()
added for analogy with the step completion methods, which we will discuss below.
To complete a step, we similarly create an abstract method in which each specific interactor will save the result of the step.
/**
*
*/
protected abstract fun saveStepOutData(stepData: O): Completable
And a public method. In it we will call the saving of the output information. When it finishes, tell the script to adjust to the information from the ending step. We will also notify subscribers that we are moving one step forward.
/**
*
*/
fun completeStep(stepOut: O): Completable {
return saveStepOutData(stepOut).doOnComplete {
scenario.reactOnStepCompletion(stepOut)
if (currentStepIndex != scenario.steps.lastIndex) {
currentStepIndex += 1
}
}
}
Finally, we implement a method to return to the previous step.
/**
*
*/
fun toPreviousStep() {
if (currentStepIndex != 0) {
currentStepIndex -= 1
}
}
Let's look at the implementation of the interactor for our job application example. As we remember, it is important for our feature to save data to a draft request, therefore, in the ApplicationProgressInteractor class, we will create an additional field under the draft.
/**
*
*/
@PerApplication
class ApplicationProgressInteractor @Inject constructor(
private val dataRepository: ApplicationDataRepository
) : ProgressInteractor<ApplicationSteps, ApplicationStepInData, ApplicationStepOutData>() {
//
override val scenario = ApplicationScenario()
//
private val draft: ApplicationDraft = ApplicationDraft()
//
fun applyDraft(draft: ApplicationDraft) {
this.draft.apply {
clear()
outDataMap.putAll(draft.outDataMap)
}
}
...
}
What a draft class looks like
:
/**
*
*/
class ApplicationDraft(
val outDataMap: MutableMap<ApplicationSteps, ApplicationStepOutData> = mutableMapOf()
) : Serializable {
fun getPersonalInfoOutData() = outDataMap[PERSONAL_INFO] as? PersonalInfoStepOutData
fun getEducationStepOutData() = outDataMap[EDUCATION] as? EducationStepOutData
fun getExperienceStepOutData() = outDataMap[EXPERIENCE] as? ExperienceStepOutData
fun getAboutMeStepOutData() = outDataMap[ABOUT_ME] as? AboutMeStepOutData
fun getMotivationStepOutData() = outDataMap[MOTIVATION] as? MotivationStepOutData
fun clear() {
outDataMap.clear()
}
}
Let's start implementing the abstract methods declared in the parent class. Let's start with the step completion function - it's pretty simple. We save the output data of a certain type to a draft under the required key:
/**
*
*/
override fun saveStepOutData(stepData: ApplicationStepOutData): Completable {
return Completable.fromAction {
when (stepData) {
is PersonalInfoStepOutData -> {
draft.outDataMap[PERSONAL_INFO] = stepData
}
is EducationStepOutData -> {
draft.outDataMap[EDUCATION] = stepData
}
is ExperienceStepOutData -> {
draft.outDataMap[EXPERIENCE] = stepData
}
is AboutMeStepOutData -> {
draft.outDataMap[ABOUT_ME] = stepData
}
is MotivationStepOutData -> {
draft.outDataMap[MOTIVATION] = stepData
}
}
}
}
Now let's look at the method for obtaining input data for a step:
/**
*
*/
override fun resolveStepInData(step: ApplicationStep): Single<ApplicationStepData> {
return when (step) {
PERSONAL_INFO -> ...
EXPERIENCE -> ...
EDUCATION -> Single.just(
EducationStepData(
inData = EducationStepInData(
draft.getPersonalInfoOutData()?.info?.educationType
?: error("Not enough data for EDUCATION step")
),
outData = draft.getEducationStepOutData()
)
)
ABOUT_ME -> Single.just(
AboutMeStepData(
outData = draft.getAboutMeStepOutData()
)
)
MOTIVATION -> dataRepository.loadMotivationVariants().map { reasonsList ->
MotivationStepData(
inData = MotivationStepInData(reasonsList),
outData = draft.getMotivationStepOutData()
)
}
}
}
When opening a step, there are two options:
- the user opens the screen for the first time;
- the user has already filled out the screen, and we have saved data in the draft.
For steps that do not require anything to enter, we will pass the information from the draft (if any).
ABOUT_ME -> Single.just(
AboutMeStepData(
stepOutData = draft.getAboutMeStepOutData()
)
)
If we need data from previous steps as input, we will pull it out of the draft (we made sure to save it there at the end of each step). And similarly, we will transfer data to outData with which we can pre-fill the screen.
EDUCATION -> Single.just(
EducationStepData(
inData = EducationStepInData(
draft.getPersonalInfoOutData()?.info?.educationType
?: error("Not enough data for EDUCATION step")
),
outData = draft.getEducationStepOutData()
)
)
There is also a more interesting situation: the last step, where you need to indicate why the user is interested in this particular vacancy, requires a list of possible reasons to be downloaded from the network. This is one of the most convenient moments in this architecture. We can send a request and, when we receive an answer, combine it with the data from the draft and send it to the screen as input. The screen doesn't even need to know where the data comes from and how many sources it is collecting.
MOTIVATION -> {
dataRepository.loadMotivationVariants().map { reasonsList ->
MotivationStepData(
inData = MotivationStepInData(reasonsList),
outData = draft.getMotivationStepOutData()
)
}
}
Such situations are another argument in favor of working through interactors. Sometimes, to provide a step with data, you need to combine several data sources, for example, a download from the web and the results of previous steps.
In our method, we can combine data from many sources and provide the screen with everything we need. It may be difficult to get a feel for why this is great in this example. In real forms - for example, when applying for a loan - the screen potentially needs to submit a lot of reference books, information about the user from the internal database, data that he filled out 5 steps back, and a collection of the most popular anecdotes from 1970.
The presenter code is much easier when the aggregation is done by a separate interactor method that produces only the result: data or an error. It is easier for developers to make changes and adjustments if it is immediately clear where to look for everything.
But that's not all there is in the interactor. Of course, we need a method to send the final application - when all the steps have been passed. Let's describe the final application and the ability to create it using the "Builder" pattern
Class for submitting the final application
/**
*
*/
class Application(
val personal: PersonalInfo,
val education: Education?,
val experience: Experience,
val motivation: List<Motivation>
) {
class Builder {
private var personal: Optional<PersonalInfo> = Optional.empty()
private var education: Optional<Education?> = Optional.empty()
private var experience: Optional<Experience> = Optional.empty()
private var motivation: Optional<List<Motivation>> = Optional.empty()
fun personalInfo(value: PersonalInfo) = apply { personal = Optional.of(value) }
fun education(value: Education) = apply { education = Optional.of(value) }
fun experience(value: Experience) = apply { experience = Optional.of(value) }
fun motivation(value: List<Motivation>) = apply { motivation = Optional.of(value) }
fun build(): Application {
return try {
Application(
personal.get(),
education.getOrNull(),
experience.get(),
motivation.get()
)
} catch (e: NoSuchElementException) {
throw ApplicationIsNotFilledException(
"""Some fields aren't filled in application
personal = {${personal.getOrNull()}}
experience = {${experience.getOrNull()}}
motivation = {${motivation.getOrNull()}}
""".trimMargin()
)
}
}
}
}
The method of sending the application itself:
/**
*
*/
fun sendApplication(): Completable {
val builder = Application.Builder().apply {
draft.outDataMap.values.forEach { data ->
when (data) {
is PersonalInfoStepOutData -> personalInfo(data.info)
is EducationStepOutData -> education(data.education)
is ExperienceStepOutData -> experience(data.experience)
is AboutMeStepOutData -> experience(data.info)
is MotivationStepOutData -> motivation(data.motivation)
}
}
}
return dataRepository.loadApplication(builder.build())
}
How to use it all on screens
Now it's worth going down to the presentation level and seeing how the screen presenters interact with this interactor.
Our feature is an activity with a stack of fragments inside.
Successful submission of the application opens a separate activity, where the user is informed about the success of the submission. The main activity will be responsible for showing the desired fragment, depending on the command of the interactor, and also for displaying how many steps have already been taken in the toolbar. To do this, in the root activity presenter, subscribe to the subject from the interactor and implement the logic for switching fragments in the stack.
progressInteractor.stepChangeObservable.subscribe { stepData ->
if (stepData.position > currentPosition) {
// FragmentManager
} else {
//
}
// -
}
Now, in the presenter of each fragment, at the start of the screen, we will ask the interactor to give us input data. It is better to transfer receiving data into a separate stream, because, as mentioned earlier, it can be associated with downloading from the network.
For example, let's take the screen for filling in education information.
progressInteractor.getDataForStep(EducationStep)
.filter<ApplicationStepData.EducationStepData>()
.subscribeOn(Schedulers.io())
.subscribe {
val educationType = it.stepInData.educationType
// todo:
it.stepOutData?.education?.let {
// todo:
}
}
Suppose we complete the step "about education" and the user wants to go further. All we need to do is form an object with the output and pass it to the interactor.
progressInteractor.completeStep(EducationStepOutData(education)).subscribe {
// ( )
}
The interactor will save the data itself, initiate changes in the script, if necessary, and signal the root activity to switch to the next step. Thus, fragments do not know anything about their position in the script: and they can be easily rearranged if, for example, the design of a feature has changed.
On the last fragment, as a reaction to the successful saving of data, we will add the sending of the final request, as we remember, we created a method for this
sendApplication()
in the interactor.
progressInteractor.sendApplication()
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe(
{
//
activityNavigator.start(ThankYouRoute())
},
{
//
}
)
On the final screen with information that the request was successfully sent, we will clear the interactor so that the process can be restarted from scratch.
progressInteractor.closeProgressFeature()
That's all. We've got a feature consisting of five screens. The screen "about education" can be skipped, the screen with filling in work experience - replaced with a screen for writing an essay. We can interrupt filling at any step and continue later, and everything that we have entered will be saved in the draft.
Special thanks to Vasya Beglyanin @icebail - the author of the first implementation of this approach in the project. And also Misha Zinchenko @midery - for help in bringing the draft architecture to the final version, which is described in this article.