/ State

A state machine library for Kotlin

A state machine library for Kotlin

State Machine

A state machine library for Kotlin, with extensions for Android.

States and Events

A state machine takes three generic parameters: the events it takes in, the states it spits out,
and the arguments it finishes with. These could be sealed classes, or plain classes & objects.

sealed class Event {
  data class InputText(val text: String) : Event()
  
  object ClickedButton : Event()
}

sealed class State {
  abstract val showProgress: Boolean

  data class Idle(val currentInput: String = "") : State() {
     override val showProgress: Boolean = false
  }
  
  data class Submitting(val currentInput: String) : State() {
     override val showProgress: Boolean = true
  }
  
  object Success : State() {
     override val showProgress: Boolean = false
  }
  
  data class Failure(val reason: String) : State() {
     override val showProgress: Boolean = false
  }
}

data class Result(val success: Boolean)

Building a Machine

A basic machine only requires the core module without any extensions.

Gradle Dependency

Core

dependencies {
   ...
   implementation "com.afollestad:statemachine:0.0.1-alpha1"
}

Basics

Your state machine should be a subclass of the StateMachine class.

When the react method is called, your machine should setup handlers for states. Each state
handles a set of events, each event returns a state. The state should propagate to the UI, and the
UI should send in events when there is interaction. It's a loop, which is basically what a state
machine is.

class MyStateMachine : StateMachine<Event, State, Result>() {
  override suspend fun react() {
    onState<Idle> {
      onEvent<InputText> {
        state.copy(currentInput = event.text)
      }
      onEvent<ClickedButton> {
        Submitting(currentInput = state.currentInput)
      }
    }
    onState<Submitting> {
      onEnter { doServerWork(state) }
    }
    onState<Success> {
      onEnter {
        delay(2000) // A synthetic delay
        finish(Result(true)) // Kills the state machine.
      }
    }
    onState<Failure> {
      onEnter {
        delay(5000) 
        Idle() 
      }
    }
  }

  private suspend fun doServerWork(state: Submitting): State {
    delay(2000)
    return if (state.currentInput == "fail") {
      Failure(reason = "Your wish is my command.")
    } else {
      Success
    }
  }
}

Usage

This example below demonstrates usage from a JVM command-line program.

fun main() {
  val stateMachine = MyStateMachine()
  
  // Listen for state changes in the background
  GlobalScope.launch {
    stateMachine.stateFlow()
        .collect { state ->
          println("Current state: $state")
        }
  }
  
  // Start the machine with an initial state, launches the event looper.
  stateMachine.start(Idle()) { args ->
    // This callback is invoked when the machine is finished.
    // It cannot be re-used after this point.
    println("Goodbye.")
  }

  val inputReader = Scanner(System.`in`)
  while (stateMachine.isActive) {
    when (val input = inputReader.nextLine()) {
      "send" -> {
        // The invoke operator on the machine streams in events.
        stateMachine(ClickedButton)
      }
      "exit" -> {
        // The finish() method destroys the state machine.
        // This releases resources and shuts down the event looper.
        stateMachine.finish()
      }
      else -> stateMachine(InputText(input))
    }
  }
}

Using a Machine on Android

On Android, there are extension modules that you can use to make connecting your states and UI much
easier. Annotation processing is used to generate view models from your state.

Gradle Dependency

Core Android

On Android, your application should depend on the core-android module as an implementation
dependency, and the core-android-processor module as an annotation processor (with kapt).

dependencies {
   ...
   implementation "com.afollestad:statemachine-android:0.0.1-alpha1"
   kapt "com.afollestad:statemachine-android-processor:0.0.1-alpha1"
}

States and View Models

Your state must be tagged with the @StateToViewModel annotation. Only the parent class should be tagged.

@StateToViewModel
sealed class MyState {
  abstract val buttonEnabled: Boolean

  data class Idle(val currentInput: String = "") : State() {
     override val buttonEnabled = true
  }
  data class Submitting(val currentInput: String) : State() {
     override val buttonEnabled = false
  }
  object Success : State() {
     override val buttonEnabled = false
  }
  data class Failure(val errorMessage: String) : State() {
     override val buttonEnabled = true
  }
}

This will generate a ViewModel that you can use in your UI. From the above state,
you'd get something similar to this (some details are omitted because they aren't relevant):

class MyStateViewModel : ViewModel(), StateConsumer<MyState> {
  val onButtonEnabled: LiveData<Boolean> = // ...
  val onCurrentInput: LiveData<String> = // ...
  val onErrorMessage: LiveData<String> = // ...
  
  override fun accept(state: MyState) {
    ...
     // This is filled automatically to propagate state changes into the live data fields above.
  }
}

Dispatchers

On Android, being thread conscious is important. StateMachine's constructor takes two coroutine
dispatchers - one for background execution, and one for the main thread.

class MyStateMachine : StateMachine<Event, State, Result>(
   executionContext = Dispatchers.IO,
   mainContext = Dispatchers.Main
) {
  ...
}

Usage

In your Activity, Fragment, etc. you can use the generated ViewModels like you would without
this library.

class MyActivity : AppCompatActivity() {
  private lateinit var button: Button
  private lateinit var input: EditText
  private lateinit var error: TextView

  private lateinit var stateMachine: MyStateMachine

  override fun onCreate(savedInstanceState: Bundle?) {
      super.onCreate(savedInstanceState)
      // ...
      
      stateMachine = MyStateMachine()
      
      val viewModel = ViewModelProviders.of(this)
         .get(MyStateViewModel::class.java)
      
      viewModel.onButtonEnabled
         .observe(this, button::setEnabled)
      viewModel.onCurrentInput
         /** Filtering here is important to we don't end up with an input/output loop */
         .filter { it != input.text.toString() }
         .observe(this, input::setText)
      viewModel.onErrorMessage
         .observe(this, error::setTextAndVisibility)
          
      input.onTextChanged { text -> 
         stateMachine(InputText(text = text)) 
      }
      button.setOnClickListener { 
         stateMachine(ClickedSubmit) 
      }
      
      // This is important on Android! This will automatically finish your state machine
      // when `this` (a LifecycleOwner) is destroyed. You could of course use plain `start()` 
      // here and remember to call `finish` somewhere below later, e.g. on `onDestroy()`.
      startMachine.startWithOwner(this)
  }
  
  private fun TextView.setTextAndVisibility(text: String) {
     setText(text)
     visibility = if (text.isNotEmpty()) VISIBLE else GONE
  }
}

Your UI will automatically receive propagated values from your states as they come in. The
generated view model will automatically distinct values as well, so you won't receive duplicate
emissions (which could result in unnecessary UI invalidation).

If you're familiar with LiveData, you'll also know that this implementation is
totally lifecycle safe. LiveData will not emit if the lifecycle owner is no longer
started.


Unit Testing Machines

A core-test module is provided to aid in unit testing your state machines.

Gradle Dependency

Core Testing

dependencies {
   ...
   testImplementation "com.afollestad:statemachine-test:0.0.1-alpha1"
}

Usage

Here's a simple example of a JUnit test, for the state machine above.

class MyTests {
  // The core-test module exposes a `test()` extension method.
  private val stateMachine = MyStateMachine().test()
  
  @Test fun `idle state accepts input`() {
     val initialState = Idle()
     stateMachine.start(initialState)
     // send an event in
     stateMachine(InputText(text))
     // assert the initial state and new state
     stateMachine.assertStates(initialState, Idle(text))
  }
  
  @Test fun `click button in idle submits`() {
     val initialState = Idle("hello world")
     stateMachine.start(initialState)
     // send an event in
     stateMachine(ClickedButton)
     // assert the initial state and new states
     // Submitting's `onEnter` brings us to the Success state.
     stateMachine.assertStates(initialState, Submitting(text), Success)
  }
  
  @After fun shutdown() {
     // Ensure resources are released and the event looper is stopped.
     stateMachine.destroy()
  }
}

GitHub