Getting Started
This guide walks you through building a simple counter app with PulseDesktop.
It creates the ViewModel with rememberPulseViewModel, which the navigation artifact adds, so that one goes in alongside mvi. To drive the lifecycle yourself with mvi alone, see ViewModel.
dependencies {
implementation("com.github.kaleidot725.PulseDesktop:mvi:<version>")
implementation("com.github.kaleidot725.PulseDesktop:navigation:<version>")
}1. Define State, Action, Event, Broadcast, and Unicast
Start by defining the five types that describe your feature. They live in Contracts files beside the class that uses them: State, Action and Event in content/counter/CounterContentContracts.kt beside the ViewModel, and Broadcast and Unicast in CounterContracts.kt beside the Container. They are shown together here:
// State: the UI data rendered by your Composable
data class CounterState(val count: Int = 0) : PulseState
// Action: intents dispatched by the user
sealed class CounterAction : PulseAction {
data object Increment : CounterAction()
data object Decrement : CounterAction()
data object Reset : CounterAction()
}
// Event: one-time side effects (navigation, snackbar, etc.)
sealed class CounterEvent : PulseEvent {
data class ShowMessage(val message: String) : CounterEvent()
}
// Broadcast: messages sent from Container to all ViewModels
sealed class CounterBroadcast : PulseBroadcast {
data object Refresh : CounterBroadcast()
}
// Unicast: messages sent from ViewModel to Container
sealed class CounterUnicast : PulseUnicast {
data object ResetRequested : CounterUnicast()
}2. Create a ViewModel
PulseViewModel manages its own UI state. Override the lifecycle hooks to handle actions and broadcasts:
class CounterViewModel(
private val repository: CounterRepository,
) : PulseViewModel<CounterState, CounterAction, CounterEvent, CounterBroadcast, CounterUnicast>(
initialUiState = CounterState(),
) {
// Called once, by PulseContent, the first time it observes the ViewModel
override fun onSetup() {
coroutineScope.launch {
repository.count.collect { count ->
update { copy(count = count) }
if (count != 0 && count % 10 == 0) {
event(CounterEvent.ShowMessage("$count reached!"))
}
}
}
}
// Called when the user dispatches an action
override fun onAction(uiAction: CounterAction) {
coroutineScope.launch {
when (uiAction) {
CounterAction.Increment -> repository.increment()
CounterAction.Decrement -> repository.decrement()
CounterAction.Reset -> repository.reset()
}
}
}
// Called when the Container broadcasts a message
override fun onReceive(broadcast: CounterBroadcast) {
when (broadcast) {
CounterBroadcast.Refresh ->
event(CounterEvent.ShowMessage("Refreshed!"))
}
}
}3. Create a Container
PulseContainer takes a list of ViewModels and lets you broadcast to all of them or refresh the view:
class CounterContainer(
viewModels: List<PulseViewModel<*, *, *, CounterBroadcast, CounterUnicast>>,
) : PulseContainer<CounterBroadcast, CounterUnicast>(viewModels = viewModels)4. Connect to Compose UI
Entry point
Create the ViewModel and Container once at the top level:
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Counter") {
MaterialTheme {
val viewModel = rememberPulseViewModel { CounterViewModel(CounterRepository()) }
val container = rememberPulseContainer { CounterContainer(viewModels = listOf(viewModel)) }
CounterHost(container = container, viewModel = viewModel)
}
}
}Host composable
The host is what the route shows. It sits at the root of the feature package in a file named CounterHost.kt, beside CounterContainer, wraps the layout with PulseHost to enable refresh and broadcast, and places the contents inside:
@Composable
fun CounterHost(container: CounterContainer, viewModel: CounterViewModel) {
PulseHost(container = container) { onRefresh, onBroadcast ->
Box(modifier = Modifier.fillMaxSize().padding(16.dp)) {
Row(modifier = Modifier.align(Alignment.TopEnd)) {
Button(onClick = { onRefresh() }) {
Text("Refresh View")
}
Button(onClick = { onBroadcast(CounterBroadcast.Refresh) }) {
Text("Send Broadcast")
}
}
CounterContent(
viewModel = viewModel,
modifier = Modifier.align(Alignment.Center),
)
}
}
}Content composable
A content is the part one ViewModel answers for, and it lives in content/counter, beside CounterViewModel. It binds that ViewModel with PulseContent, which observes the state and handles the events:
@Composable
fun CounterContent(viewModel: CounterViewModel, modifier: Modifier = Modifier) {
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
Box(modifier = modifier) {
PulseContent(
viewModel = viewModel,
onEvent = { event ->
when (event) {
is CounterEvent.ShowMessage ->
scope.launch { snackbarHostState.showSnackbar(event.message) }
}
},
) { state, onAction ->
Column(horizontalAlignment = Alignment.CenterHorizontally) {
Text(text = "${state.count}", fontSize = 72.sp)
Row {
Button(onClick = { onAction(CounterAction.Decrement) }) { Text("−") }
Button(onClick = { onAction(CounterAction.Increment) }) { Text("+") }
}
OutlinedButton(onClick = { onAction(CounterAction.Reset) }) { Text("Reset") }
}
}
SnackbarHost(
hostState = snackbarHostState,
modifier = Modifier.align(Alignment.BottomCenter),
)
}
}5. Scope the ViewModel to a Navigation 3 destination
Step 4 created the ViewModel at the top level, where it lives as long as the window. Created inside a Navigation 3 destination instead, it lives exactly as long as that route is on the back stack.
Two things make that happen.
NavDisplaygetsrememberPulseNavEntryDecorators()as itsentryDecorators. That gives every back stack entry its ownViewModelStoreOwner- The ViewModel and Container are created inside the destination, not above
NavDisplay
How this works is covered in Navigation 3.
sealed interface Route : NavKey {
data object Counter : Route
}
fun main() = application {
Window(onCloseRequest = ::exitApplication, title = "Counter") {
MaterialTheme {
val backStack = remember { mutableStateListOf<Route>(Route.Counter) }
NavDisplay(
backStack = backStack,
onBack = { if (backStack.size > 1) backStack.removeAt(backStack.lastIndex) },
entryDecorators = rememberPulseNavEntryDecorators(),
entryProvider =
entryProvider {
entry<Route.Counter> {
val viewModel = rememberPulseViewModel { CounterViewModel(CounterRepository()) }
val container = rememberPulseContainer { CounterContainer(viewModels = listOf(viewModel)) }
CounterHost(container = container, viewModel = viewModel)
}
},
)
}
}
}Running the Demo
The repository includes a pulse grid demo: four areas share one Container, and a tap on one spreads to the two it shares an edge with. It is split into an app, a feature, and a domain, data and design system under core, wired with Koin, and keeps its counts in a DataStore — the layout the architecture checks describe, which it passes. Clone the repo and run:
./gradlew :demo:app:runNext Steps
- Architecture — understand the data flow in depth
- ViewModel — advanced ViewModel patterns
- Container — coordinating multiple ViewModels
- Navigation 3 — how back stack scoped lifetimes work