High-level Workflow
The general workflow for Ballast involves the following steps:
- Define a Contract
- Write the InputHandler
- Write the EventHandler
- Combine everything into a ViewModel
- Inject the ViewModel to your UI and start using it
These steps are described in more depth below, and while this workflow does involve a bit of boilerplate the Intellij plugin can help you in quickly scaffolding out all of these classes.
Ballast Workflow
This section goes more in-depth into the individual components needed for the full Ballast Workflow. For a quick, high-level listing of the classes needed, see High-level Workflow.
Define a Contract
The first step for using Ballast on any screen is to define the Contract. The Contract provides a structure for what data will be changing in your screen (the State), and how you will be interacting with it (Inputs), which gives you a single place to go to understand everything you need to know about any given screen. By having a dedicated Contract, you won't have any hidden or undocumented functionality that is difficult to reproduce.
If you're using Ballast in a multiplatform project, the Contract should be in the commonMain
sourceSet.
See more about defining your contract in Thinking in Ballast MVI.
object LoginScreenContract {
data class State(
val username: TextFieldValue,
val password: TextFieldValue,
)
sealed interface Inputs {
data class UsernameChanged(val newValue: TextFieldValue) : Inputs
data class PasswordChanged(val newValue: TextFieldValue) : Inputs
data object LoginButtonClicked : Inputs
data object RegisterButtonClicked : Inputs
}
sealed interface Events {
data object NavigateToDashboard : Events
data object NavigateToRegistration : Events
}
}
Write the InputHandler
After defining the contract, you should then write the InputHandler to process the Inputs as they are received. The InputHandler is the class that will be talking to your Repository layer, so any necessary Repositories should be provided through the InputHandler's contructor
If you're using Ballast in a multiplatform project, the InputHandler should be in the commonMain
sourceSet.
See more about writing your InputHandler in Features.
import LoginScreenContract.*
class LoginScreenInputHandler(
private val loginRepository: LoginRepository,
) : InputHandler<Inputs, Events, State> {
override suspend fun InputHandlerScope<Inputs, Events, State>.handleInput(
input: Inputs
) = when (input) {
is UsernameChanged -> { }
is PasswordChanged -> { }
is LoginButtonClicked -> { }
is RegisterButtonClicked -> { }
}
}
Connect to the Platform UI
The last step is to actually use Ballast to build out your interactive UI. This typically involves several steps that will all be specific to the target you're running Ballast on, but compared to the effort involved with the Contract and InputHandler, are relatively simple. So even though there is some platform-specific functionality you'll need to write, you will still be sharing the majority of the business-logic code in your application.
If you are using Ballast in a multiplatform application, the following pieces will typically be defined in the
platform-specific sourceSets rather than in commonMain
.
ViewModel
The first step is to define the ViewModel class for each platform. This will vary slightly depending on which
platform you target, so that the ViewModel integrates well with the platform's normal lifecycle. For example, on
Android, you'll make your screen's ViewModel extend AndroidViewModel
, which is an instance of
androidx.lifecycle.ViewModel
that can be provided via Hilt or Navigation-Compose. For platforms that don't have their
own specific ViewModel implementation, or for use-cases where you want to manually control the ViewModel's lifecycle
through a CoroutineScope
, you can use BasicViewModel
as the base class.
All ViewModel implementations will look pretty similar, regardless of the base class used. You'll need to create a
BallastViewModelConfiguration
and pass it to the base class's constructor, along with any additional parameters needed
for the specific implementation, if any (for example, the CoroutineScope
of a BasicViewModel
). This is easiest to
do with BallastViewModelConfiguration.Builder
, but you can also structure everything with Dependency Injection, too
(see section below on Dependency Injection). The BallastViewModelConfiguration.Builder
is
where you will specify the InputHandler and initial State for the ViewModel, as well as providing other more generic
configuration such as loggers or interceptors.
Despite each platform's native ViewModel being named the same and looking very similar, you typically wouldn't define it
with actual/expect
declarations in a multiplatform project because there's usually no need to share the ViewModel
itself in common code, so it just creates unnecessary overhead. Furthermore, the base classes for each platform
typically have different constructors, so it's difficult to provide an actual/expect
that is actually useful in common
code for simplifying any DI. It's best to just provide the ViewModel implementations from the platform-specific DI
modules.
// androidMain/ui/login/LoginScreenViewModel.kt
class LoginScreenViewModel() : AndroidViewModel<
LoginScreenContract.Inputs,
LoginScreenContract.Events,
LoginScreenContract.State>(
config = BallastViewModelConfiguration.Builder()
.apply {
this += LoggingInterceptor()
logger = { AndroidBallastLogger(it) }
}
.withViewModel(
initialState = LoginScreenContract.State(),
inputHandler = LoginScreenInputHandler(),
name = "LoginScreen",
)
.build()
)
// jsMain/ui/login/LoginScreenViewModel.kt
class LoginScreenViewModel(
viewModelCoroutineScope: CoroutineScope
) : BasicViewModel<
LoginScreenContract.Inputs,
LoginScreenContract.Events,
LoginScreenContract.State>(
config = BallastViewModelConfiguration.Builder()
.apply {
this += LoggingInterceptor()
logger = { JsConsoleBallastLogger(it) }
}
.withViewModel(
initialState = LoginScreenContract.State(),
inputHandler = LoginScreenInputHandler(),
name = "LoginScreen",
)
.build(),
eventHandler = LoginScreenEventHandler(),
coroutineScope = viewModelCoroutineScope,
)
EventHandler
The next step is to define an EventHandler
for your ViewModel. The implementation will look very similar to an
InputHandler, except that it will typically need a different implementation on each platform for handling things like
navigation requests (though this may not always be the case if you have your routing/navigation implemented entirely in
common code).
import LoginScreenContract.*
class LoginScreenEventHandler : EventHandler<Inputs, Events, State> {
override suspend fun EventHandlerScope<Inputs, Events, State>.handleEvent(
event: Events
) = when (event) {
is Events.Notification -> { }
}
}
You may have noticed from the example ViewModel code above that the BasicViewModel
has you providing the EventHandler
directly in its constructor, while the AndroidViewModel
does not. This is because EventHandlers are closely related to
the lifecycle of the ViewModel, but don't necessarily follow the exact same lifecycle. The EventHandler typically lives
as long as the screen is active, but the ViewModel itself may be retained across multiple times of the screen being
stopped and started.
For a BasicViewModel
, the lifecycle of the Screen, ViewModel, and EventHandler are all the same, and they're all
controlled by the lifetime of the CoroutineScope
. When moving to a new screen, the screen's CoroutineScope
is
cancelled, the ViewModel's processing is stopped, and the EventHandler detached. For this reason, the EventHandler
is
provided through the BasicViewModel
's constructor, to make sure they all respect the same lifecycle.
But on Android, it is not possible to use Hilt to inject a ViewModel with anything that depends on the Activity, since a
ViewModel lives longer than the Activity. Since the EventHandler
is commonly used for handling Navigation requests,
and navigation is done by the activity through Activity.startActivity()
or findNavController().navigate()
, it is
impossible to inject the EventHandler
directly into the ViewModel, but instead it must be attached dynamically after
the ViewModel has been injected. See the Android platform page for specific instructions.
UI
The final piece of the Ballast puzzle is actually defining your UI given the Ballast State. This typically involves
creating or accessing an instance of your ViewModel and observing its State as a StateFlow
with
viewModel.observeStates()
. On each emission of that StateFlow, you will update the entire UI of the screen with the
new State, as per for the platform-specific requirements.
On platforms that require the native programming language to use rather than Kotlin (SwiftUI, for example), there may be
some boilerplate needed to wrap the Kotlin coroutines and StateFlow
into something that the platform's native code can
integrate with. But on Android, and using Compose for Desktop or Web, this is easily done in Kotlin. See each
platform's instructions for how to connect to the actual UI toolkit.