Finite State Machines + Android + Kotlin = Good Times

Alex Sullivan

I was recently listening to a fantastic episode of the Fragmented pocast where the guest, Ray Ryan, mentioned that they make heavy use of finite state machines at Square. Inspired by that podcast and a Github gist detailing the use of composable finite state machines in Swift, I decided to try and utilize a similar concept to build an Android application, with the goal being to come up with an architecture that both explicitly manages UI state and allows screen complexity to grow in a maintanable manner.

What is a finite state machine?

If you’re not familiar with the concept of a finite state machine, you can think of them as a set of States with pre-defined edges between them. An Action is fed into the finite state machine, at which point the finite state machine produces a new State. They’re typically drawn as circles representing states with lines connecting them representing transitions:

So in the above example of a turnstile finite state machine we have two States - Locked and Un-locked. We also have two possible Actions -Push and Coin, which represents depositing a coin into the turnstile. All finite state machines follow the following simple formula we alluded to above:

State + Action = new State.

So in the turnstile example above, we can model our finite state machine with the following formulas:

Locked + Push = Locked - A locked turnstile remains locked when pushed

Locked + Coin = Unlocked - A locked turnstile unlocks when money is inserted

Unlocked + Push = Locked - An unlocked turnstile locks after its used

Unlocked + Coin = Unlocked - An unlocked turnstile remains unlocked if you insert more money

How does this fit into Android development?

When building up a screen in an Android app, we often find ourselves wrestling with growing UI state. A view can be loading, populated, scrolling, animating, pending and so on. As our views grow, the complexity grows exponentially and we often find ourselves struggling to find a way to properly organize and navigate the increasingly convoluted landscape. Finite state machines offer a unique answer to this problem - they convert your previously unmanaged implicit state into managed explicit state.

Designing our application

For this demo we’ll build a minimal single Activity app focused on managing a users love of sandwiches (side note - it turns out I had no idea how to spell sandwich before I wrote this blog post). Our app will have 3 screens:

  1. A listing screen, where a user can see their favorite sandwiches and initiate a flow to add a new sandwich:

  2. A sandwich selection screen, where the user can either select a predefined sandwich or select a category for their own, new sandwich: (Note that I only have three images of sandwiches, so please excuse the duplicate deliciousness!)

  3. And finally, a sandwich naming screen, where the user can name the new sandwich they started to build on screen #2

You’re probably assuming that I’ve won multiple design awards for the layout of this app. Shockingly I have not.

Defining our finite state machine

It’s worth taking a moment to think through what problem we’re hoping our finite state machine will solve. Currently, our activity has three distinct states that need some degree of coordination - we need to figure out when to hide the sandwich list screen and show the add sandwich screen and so on. Our finite state machine should be the engine that makes these decisions.

Now that we know what problem we’re trying to solve, we can move on to building our finite state machine.

Defining our States

First, we need to decide on a set of States for the app.

Since we want our finite state machine to drive the UI, it makes sense to create a State in our state machine for each of the screens listed above. Our states will thus be SandwichList for the listing screen, AddSandwich for the sandwich selection screen, and NameSandwich for the sandwich naming screen.

So our set of states is: SandwichList, AddSandwich, and NameSandwich.

Defining our Actions

Next we need to figure out what our Actions are.

Since our finite state machine is the engine driving our view, it makes sense that interactions with that view would affect the finite state machine. So we can model user interactions as Actions being fed into our finite state machine!

On the first screen, the only action the user can take is clicking the “Add Sandwich” button - so we’ll want an AddSandwichClicked Action. On the sandwich selection screen, the user can either select a predefined sandwich or select a sandwich category. So we’ll want two more actions - SandwichTypeSelected and PredefinedSandwichSelected. Finally, on the sandwich naming screen, the only action the user can take is clicking the “Submit” button, so we’ll need a SubmitSandwichClicked action.

Our set of Actions doesn’t necessarily need to be limited to user interaction; if we received a notification or something along those lines that could be an Action for the finite state machine as well.

So our set of actions is: AddSandwichClicked, SandwichTypeSelected, PredefinedSandwichSelected, and SubmitSandwichClicked.

Putting everything together

The final step in constructing our finite state machine is figuring out the set of transitions that we have - we want to setup a list of equations for our sandwich app in the same way we set them up for the turnstile above.

SandwichList + AddSandwichClicked = AddSandwich - After the user clicks “add sandwich” we want to transition to the AddSandwich state.

AddSandwich + PredefinedSandwichSelected = SandwichList - If the user selects a predefined sandwich, we want to take them right back to the SandwichList state with their newly selected sandwich added to the list.

AddSandwich + SandwichTypeSelected = NameSandwich - After the user selects a sandwich type, we want to take them to the NameSandwich state to choose a name for their new sandwich.

NameSandwich + SubmitSandwichClicked = SandwichList - After the user submits their sandwich name, we’ll combine that data with the sandwich type they selected before to create their new sandwich and add it to the list.

The following equations lead us to this finite state machine diagram:

Time to write some code

Now that we’ve constructed the theoretical underpinnings of our app, let’s actually write some code.

The first thing we’ll create is an interface for a State in our state machine:

interface SandwichState {
  fun consumeAction(action: Action): SandwichState
}

This interface models our equation above - State + Action = new State. Any State that implements this interface will have to supply a new SandwichState when provided with an Action.

Next up is to define our Action. We’ll model our Actions as a sealed class to utilize Kotlins exhaustive when statements later on:

sealed class Action {
  class AddSandwichClicked : Action()
  class SandwichTypeSelected(val type: SandwichType): Action()
  class PredefinedSandwichSelected(val sandwich: Sandwich): Action()
  class SubmitSandwichClicked(val sandwichName: String): Action()
}

Since we’re using a sealed class instead of an enum we can pass data through our Actions.

Next up we’ll define a few simple model objects:

data class Sandwich(val name: String, val type: SandwichType)

enum class SandwichType {
  GRINDER,
  WRAP
}

A sandwich object consists of a name and a SandwichType, which is either GRINDER or WRAP.

Implementing our States

Now we can work on actually defining our SandwichState implementations.

First up we’ll create a new SandwichList class that implements SandwichState. This class will represent the SandwichList State we reasoned about earlier.

class SandwichList(private val sandwiches: List<Sandwich>): SandwichState {
  override fun consumeAction(action: Action): SandwichState {
    TODO("Fill in the details")
  }
}

The List<Sandwich> that we pass in represents the sandwiches the user has already added to their list of favorite sandwiches. We’re going to be building this list up as we flow through our finite state machine.

We can refer to the equations we wrote above to figure out how to handle our Actions. The only formula relevant to the SandwichList state is the following:

SandwichList + AddSandwichClicked = AddSandwich

The only Action we’re going to handle in the SandwichList state is the AddSandwichClicked Action. When we encounter it we want to proceed to the AddSandwich state, which we’ll define shortly:

class SandwichList(private val sandwiches: List<Sandwich>): SandwichState {
  override fun consumeAction(action: Action): SandwichState {
    return when(action) {
      is Action.AddSandwichClicked -> AddSandwich(sandwiches)
      else -> throw IllegalStateException("Invalid action $action passed to state $this")
    }
  }
}

If we receive a different Action than the one we’re expecting we know something has gone wrong and we’ll throw our hands up. An alternative approach could be to just return this current state.

Next up we’ll implement the AddSandwich State:

class AddSandwich(private val sandwiches: List<Sandwich>): SandwichState {
  override fun consumeAction(action: Action): SandwichState {
    TODO("Fill in the details")
  }
}

Again, we can utilize the relevant formulas we wrote to fill in the consumeAction method:

AddSandwich + PredefinedSandwichSelected = SandwichList

AddSandwich + SandwichTypeSelected = NameSandwich

class AddSandwich(private val sandwiches: List<Sandwich>): SandwichState {
  override fun consumeAction(action: Action): SandwichState {
    return when (action) {
      is Action.SandwichTypeSelected -> NameSandwich(sandwiches, action.type)
      is Action.PredefinedSandwichSelected -> {
        SandwichList(sandwiches + action.sandwich)
      }
      else -> throw IllegalStateException("Invalid action $action passed to state $this")
    }
  }
}

This State is slightly more complicated than SandwichList was. The logic breaks down as follows:

  1. When the SandwichTypeSelected Action is provided, we’re going to transition to the NameSandwich State, passing in the SandwichType that the user selected so the NameSandwich class can actually construct a new Sandwich

  2. When the PredefinedSandwichSelected Action is provided, we want to add the Sandwich the user selected to our existing list of Sandwiches. Since the user selected a fully formed sandwich rather than opting for creating their own, we don’t need to transition to the NameSandwich State. Instead, we just loop back to the SandwichList State.

Last but not least we’ll define the NameSandwich State. Again, we’ll use the formulas defined above to inform our implementation:

class NameSandwich(private val sandwiches: List<Sandwich>,
                   private val newSandwichType: SandwichType): SandwichState {
  override fun consumeAction(action: Action): SandwichState {
    return when (action) {
      is Action.SubmitSandwichClicked -> {
        val newSandwich = Sandwich(action.sandwichName, newSandwichType)
        SandwichList(sandwiches + newSandwich)
      }
    }
  }
}

This state is fairly straightforward - once the user clicks submit, we use the SandwichType they selected in the previous screen and the sandwich name they entered to create a new Sandwich and add it to our list of existing sandwiches. We’ll then transition to our new state, SandwichList, with the updated list of sandwiches.

Building our Activity

We now have a fully functional finite state machine that builds up lists of sandwiches. The last thing we need to do is connect our finite state machine to an Activity to actually drive our UI.

class SandwichActivity : AppCompatActivity() {
  var currentState by Delegates.observable<SandwichState>(SandwichList(emptyList()), { _, old, new ->
    renderViewState(new, old)
  })

  override fun onCreate(savedInstanceState: Bundle?) {
    super.onCreate(savedInstanceState)
    setContentView(R.layout.activity_sandwhich)
  }

  private fun renderViewState(newState: SandwichState, oldState: SandwichState) {
    when (newState) {
      is SandwichList -> showSandwichList(newState.sandwiches)
      is AddSandwich -> showAddSandwichView(predefinedSandwiches)
      is NameSandwich -> showSandwichInputFields()
    }

    when (oldState) {
      is SandwichList -> hideAddSandwichView()
      is AddSandwich -> hideSandwichInputFields()
      is NameSandwich -> hideSandwichList()
    }
  }
}

We’ve defined a new SandwichActivity that holds onto the current state of our state machine via an observable delegate. That means that whenever we update the value of currentState, we’ll call into renderViewState with both the old and new values of our state machine. The renderViewState method will update our view depending on what states we transitioned to and from. Let’s take a look at the showSandwichList method:

private fun showSandwichList(sandwiches: List<Sandwich>) {
  sandwich_list_container.visibility = View.VISIBLE
  added_sandwich_list.adapter = SandwichAdapter(sandwiches)
  add_sandwich_button.setOnClickListener {
    currentState = currentState.consumeAction(AddSandwichClicked())
  }
}

Most of this method is normal Android code. The most interesting piece is the click listener added to the add_sandwich_button:

currentState = currentState.consumeAction(AddSandwichClicked())

This is where we’re passing an Action to our finite state machine. Specifically, when the user clicks the add_sandwich_button, we pass the AddSandwichClicked Action into our state machine and update our currentState to be whatever the new state is. Since we’re using an observable delegate, we’ll be notified in our renderViewState method about both the old value of the state machine and the new one!

The rest of the methods in our Activity look very similar:

private fun hideSandwichList() {
  sandwich_list_container.visibility = View.GONE
}

private fun showAddSandwichView(predefinedSandwiches: List<Sandwich>) {
  add_sandwich.visibility = View.VISIBLE
  wrap_button.setOnClickListener {
    currentState = currentState.consumeAction(SandwichTypeSelected(WRAP))
  }
  grinder_button.setOnClickListener {
    currentState = currentState.consumeAction(SandwichTypeSelected(GRINDER))
  }
  predefined_sandwich_list.adapter = SandwichAdapter(predefinedSandwiches, {
    currentState = currentState.consumeAction(PredefinedSandwichSelected(it))
  })
}

private fun hideAddSandwichView() {
  add_sandwich.visibility = View.GONE
}

private fun showSandwichInputFields() {
  sandwich_inputs.visibility = View.VISIBLE
  submit_button.setOnClickListener {
    val sandwichName = sandwich_name.text.toString()
    currentState = currentState.consumeAction(SubmitSandwichClicked(sandwichName))
  }
}

private fun hideSandwichInputFields() {
  sandwich_inputs.visibility = View.GONE
}

Now our view is properly forwarding Actions to our finite state machine, and the shifting of our States in the state machine are being reflected in the view!

For those that are using the MVP architecture pattern, instead of passing the Actions in through the Activity you could delegate that responsibility to the Presenter, preserving testability.

We’ve now built a functioning application whose logic is primarily driven by a finite state machine! But there’s still improvements/concerns to be addressed. How do we handle mutations/logic that needs to be addressed outside of the finite state machine? Is there a cleaner, more type safe way to translate our states into views? How well does this approach scale?

This blog post has already gotten quite long, so we’ll address those questions and more in a future post expanding on using finite state machines in Android development!