Blog Infos
Author
Published
Topics
,
Published
We are going to see how to create a State Machine and implement it on Jetpack Compose

 

Why do we need a state machine on Jetpack Compose? Let’s start from the basics!

Jetpack Compose

Jetpack Compose is the new framework released by Google that will replace the current XML view system introduced a decade ago. It’s very different from the legacy one because we are moving from the Imperative Programming approach to Declarative Programming, where the UI components are functions, and every argument represents a property or even a state of our component.

The state: this is the most important part of this article because basically Compose was designed to react to a change of state, this means that our primary goal is to find the best way to manipulate this state.

When I say “best” I mean:

  • maintaining this state consistent during the execution
  • making the code readable/maintainable without spaghetti code 🍝
  • making it testable (please don’t skip this point 😈 )
Case study

The purpose of this article is to explain how to implement a State Machine by using this library https://github.com/freeletics/FlowRedux, how to integrate it with Jetpack Compose and last but not least, how to write tests on it.

Our case of study will be a small app which allows the user to type a Github username and fetch the related repositories list, so our domain layer will expose this Interface:

interface GithubRepository {
suspend fun repositoryByOwner(owner: String): Either<AppError, List<Repository>>
}

Otherwise from a UI point of view, our app will be composed of a TextField, a confirm Button and a LazyColumnlike this snippet:

Column {
Row {
TextField(
placeholder = { Text(text = "Owner") },
value = contentState.owner, onValueChange = { /* On type */ }
)
Button(
content = { Text(text = "OK") },
onClick = { /* Confirm */ }
)
}
LazyColumn {
// item {} to render items
}
}
view raw Screen.kt hosted with ❤ by GitHub

Now we need to find better glue for the UI and the domain layer: a stage machine. My suggestion is to start thinking about the states, which set of states will represent my UI? I have thought of three of them:

  • Content state: where basically the TextField is valorised with the username and the LazyColumn with the list of repositories (LazyColumn works like a RecyclerView)
  • Loading State: used when the app is making the HTTP request
  • Error State: is the case something goes wrong

Now events/actions which can manipulate our state:

  • The user is typing the username: in compose exists the concept of unidirectional data flow (read more here), so we need to persist the TextField’s value inside our state.
  • Confirm button tapped: the event is triggered when a user taps on the search button.
  • User prompts a retry: triggered by the retry button in the Error State.

If we draw the state machine it should be something similar to this:

App’s State Machine

From a code point of view, these could be our States and Actions:

sealed interface GithubState {
data class Load(val owner: String) : GithubState
data class Error(val e: Throwable, val owner: String) : GithubState
data class ContentState(
val repositories: List<Repository> = emptyList(),
val owner: String = "",
) : GithubState
}
sealed interface GithubAction {
object Confirm : GithubAction
object RetryLoadingAction : GithubAction
data class TypeOwner(val input: String) : GithubAction
}

States & Actions

 

The State Machine

Now the fun part, we are going to implement the state machine and we need to define the supported actions for each of these states. FlowRedux will help us in this iteration.

FlowRedux is a library which allows you to create a State Machine where for each State we can declare (in a DSL way) which actions are supported. Each action is able to do logic and manipulate the state in different ways:

  • override the state with a new one
  • mutate the state properties without changing the type
  • keep the same state without changes

Look at the sample below.

@ViewModelScoped
class GithubStateMachine @Inject constructor(
private val githubRepository: GithubRepository
) : FlowReduxStateMachine<GithubState, GithubAction>(
initialState = GithubState.ContentState(owner = "", repositories = emptyList())
) {
init {
spec {
// spec
}
}
}

Job Offers

Job Offers

There are currently no vacancies.

OUR VIDEO RECOMMENDATION

, ,

State Machines and Hopeful Dreams

Building out an Architecture for your application can be quite a difficult task. Thankfully (or Unfortunately) there are many solutions out there trying to solve this problem, acting as Architecture containers that create an opinionated…
Watch Video

State Machines and Hopeful Dreams

Rikin Marfatia
Android Engineer
Pinterest

State Machines and Hopeful Dreams

Rikin Marfatia
Android Engineer
Pinterest

State Machines and Hopeful Dreams

Rikin Marfatia
Android Engineer
Pinterest

Jobs

Basically, we are creating a FlowReduxStateMachine with a state based on GithubState and a set of actions based on the GithubActionthen we need to define the initialStatewhich is going to be the initial state, in our case is the ContentState (where input and repositories are empty).

Now we need to describe the State Machine behaviour inside the spec {}FlowRedux allows you to define different behaviours for any actions based on the current state.

Starting from the ContentState:

spec {
// ContentState
inState<GithubState.ContentState> {
on { action: GithubAction.TypeOwner, state: State<GithubState.ContentState> ->
state.mutate { copy(owner = action.input) }
}
on { _: GithubAction.Confirm, state: State<GithubState.ContentState> ->
val owner = state.snapshot.owner
if (owner.isNotBlank()) {
state.override { GithubState.Load(owner = owner) }
} else {
state.noChange()
}
}
}
//...
}
view raw ContentState.kt hosted with ❤ by GitHub

In this state, we are saying that every time the UI emits:

  • GithubAction.TypeOwner we mutate the ContentState in order to persist the user’s input.
  • GithubAction.Confirm we override it with the LoadingState which contains the username typed by the user (if it’s not empty, otherwise we don’t do anything).

 

ContentState’s actions

 

And the Load state?

inState<GithubState.Load> {
onEnter { state: State<GithubState.Load> ->
val owner = state.snapshot.owner
githubRepository.repositoryByOwner(owner = owner).fold(
ifLeft = {
GithubState.Error(Throwable("Fail"), owner = owner)
},
ifRight = {
GithubState.ContentState(repositories = it, owner = owner)
}
).let { state.override { it } }
}
}
view raw LoadingState.kt hosted with ❤ by GitHub

It’s a bit different because we are using the onEnter {} function, this function is called every time the state transits to Load, and this is perfect because we can call the GithubRepository and get the repositories list.

Loading and Error States actions

In case we have a success we override the state with the ContentState otherwise we switch to an Error state, where basically we can retry the call:

// Error
inState<GithubState.Error> {
on { _: GithubAction.RetryLoadingAction, state: State<GithubState.Error> ->
state.override { GithubState.Load(owner = state.snapshot.owner) }
}
}
view raw ErrorState.kt hosted with ❤ by GitHub
How to integrate the StateMachine on Jetpack Compose

The state machine is ready, now we need to attach it to our Composable UI. There are many options, I have decided to encapsulate the State Machine inside a ViewModel and pass it to the UI.

Basically, I have an abstract AbsStateViewModel which exposes the state and allows the emitting of the actions by using the viewModelScope (because the dispatch method of the FlowReduxStateMachine<S, A> is a suspend function).

@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
abstract class AbsStateViewModel<State: Any, Action: Any>(
private val stateMachine: FlowReduxStateMachine<State, Action>
): BaseViewModel() {
@Composable
fun rememberState() = stateMachine.rememberState()
fun dispatch(action: Action) = viewModelScope.launch {
stateMachine.dispatch(action = action)
}
}

so I have created a GithubViewModel which extends this class:

@HiltViewModel
class GithubViewModel @Inject constructor(
githubStateMachine: GithubStateMachine
): AbsStateViewModel<GithubState, GithubAction>(githubStateMachine)

And finally, I can pass the ViewModel inside the Composable function, this is the result:

@Composable
fun RepoInfoScreen(
githubViewModel: GithubViewModel
) {
val uiState by githubViewModel.rememberState()
Column(
modifier = Modifier
.fillMaxSize()
.padding(16.dp)
) {
when (uiState) {
is GithubState.ContentState -> {
Column {
val contentState = (uiState as GithubState.ContentState)
Row(
modifier = Modifier
.fillMaxWidth()
.padding(bottom = 16.dp),
verticalAlignment = Alignment.CenterVertically,
) {
TextField(placeholder = {
Text(text = "Owner")
}, value = contentState.owner, onValueChange = {
githubViewModel.dispatch(GithubAction.TypeOwner(it))
})
Button(
modifier = Modifier.padding(start = 16.dp),
onClick = { githubViewModel.dispatch(GithubAction.Confirm) },
content = { Text(text = "OK") }
)
}
LazyColumn {
items(contentState.repositories)
}
}
}
is GithubState.Error -> Column {
Text(text = "FAIL")
Button(onClick = { githubViewModel.dispatch(GithubAction.RetryLoadingAction) }) {
Text(text = "Retry")
}
}
is GithubState.Load -> {
Text(text = "Loading stuff")
}
null -> Unit
}
}
}
Testing

Hey! Where are you going? Now your state machine is ready but you need to write tests as well! It’s pretty simple, given the state’s updates travel on a Kotlin Flow you need a library which helps you to test the emitting, I suggest https://github.com/cashapp/turbine which fits our scopes.

I have also used https://github.com/mockk/mockk for the mocking and if you are interested I wrote an article on it as well.

In this test for example we are testing the username typing:

@Test
fun `Test typing`() = coroutineTestRule.scope.runTest {
// When / Then
stateMachine.state.test {
stateMachine.dispatch(GithubAction.TypeOwner("mcatta"))
assertEquals("", (awaitItem() as GithubState.ContentState).owner)
assertEquals("mcatta", (awaitItem() as GithubState.ContentState).owner)
}
}

I’m testing the typing

 

in this other one, we are testing the behaviour in the case in case the API call fails:

@Test
fun `Test confirm upon a failure`() = coroutineTestRule.scope.runTest {
// Given
coEvery { githubRepository.repositoryByOwner(any()) } returns Either.Left(AppError.Network)
// When / Then
stateMachine.state.test {
stateMachine.dispatch(GithubAction.TypeOwner("mcatta"))
stateMachine.dispatch(GithubAction.Confirm)
assertIs<GithubState.ContentState>(awaitItem()) // Initial State
assertIs<GithubState.ContentState>(awaitItem()) // State after typing
assertIs<GithubState.Load>(awaitItem()) // Confirm Action
assertIs<GithubState.Error>(awaitItem()) // Error upon API failure
coVerify { githubRepository.repositoryByOwner(eq("mcatta")) }
}
}

I’m testing the search upon a failure

 

Conclusions

FlowRedux is a very cool library, is it the best solution? Maybe yes or maybe no, we love the “it depends” sentence, in past I worked a bit also with MVI pattern, and my feeling is that it works more or less in the same way but we have more control because we can easily define which actions are available for a specific state and have a better hierarchy. Another pro is related to the declaration, the DSL syntax simplifies the code and makes it more readable and maintainable.

I hope you have enjoyed reading this article! Please leave 1/2/tons of 👏 if you liked it, feedbacks are welcome as well!

You can also find the full source code here 👇.

This article was originally published on proandroiddev.com on October 27, 2022

YOU MAY BE INTERESTED IN

YOU MAY BE INTERESTED IN

blog
It’s one of the common UX across apps to provide swipe to dismiss so…
READ MORE
blog
In this part of our series on introducing Jetpack Compose into an existing project,…
READ MORE
blog
In the world of Jetpack Compose, where designing reusable and customizable UI components is…
READ MORE
blog

How to animate BottomSheet content using Jetpack Compose

Early this year I started a new pet project for listening to random radio…
READ MORE

Leave a Reply

Your email address will not be published. Required fields are marked *

Fill out this field
Fill out this field
Please enter a valid email address.

Menu