VICE is an MVI (Model-View-Intent) framework that uses UDF (Unidirectional Data Flow) to drive a UI:
graph LR;
Compositor-- composites ViewState --->View
View-- emits Intent --->Compositor
It supports all KMP targets that are supported by Compose Multiplatform.
repositories {
mavenCentral()
}
dependencies {
implementation("com.eygraber:vice-core:0.9.4")
implementation("com.eygraber:vice-nav:0.9.4")
}
Snapshots can be found here.
The advantages of VICE are:
- It adheres to SRP and UDF while remaining simple
- It provides a natural, imperative way of working with async data
- It provides an immutable way to describe the state of a UI that doesn't allow for backdoor mutability
- It takes the guess work out of how to structure UI code
There are 5 main components of VICE:
- ViceView
- Intent
- ViceCompositor
- ViceEffects
- ViewState
These are all wired together using a ViceContainer
. Calling ViceContainer.Vice()
kicks off the UDF loop, and
everything after that is managed through the VICE components.
The ViceView
is a Composable
function that takes 2 parameters:
ViewState
- an immutable class representing the state of the View at the current momentonIntent
- a function for emittingIntent
that correspond to user interactions
typealias ViceView<Intent, State> = @Composable (State, (Intent) -> Unit) -> Unit
As the user interacts with the ViceView
it emits a corresponding Intent
using the onIntent
callback.
TheViceCompositor
reacts to the Intent
by performing an action and/or changing the ViewState
.
To leverage the performance benefits of Compose's function skipping, the ViceView
is broken up into many smaller
Composable
functions. These functions only receive the parts of the ViewState
that they need to
render themselves, and pass events back up through the use of callback lambdas. As the ViewState
changes causing the
ViceView
to recompose, only those functions that have parameters with new values will be called. The others will be
skipped, and Compose will use their most recent emission to create the next UI frame.1
For this to work, Compose Stability needs to be taken into account. For that reason, it is suggested that
ViewState
be an @Immutable data class
.
However keep in mind that your use case might differ234
@Immutable
data class SampleViewState(
val title: String,
val buttonLabel: String,
val bottomNavItems: List<String>,
)
typealias SampleView = @Composable (SampleViewState, (SampleIntent) -> Unit) -> Unit
@Composable
fun SampleView(
state: SampleViewState,
onIntent: (SampleIntent) -> Unit,
) {
Scaffold(
topBar = { SampleTopBar(state.title) },
bottomBar = { SampleBottomBar(state.bottomNavItems) }
) { contentPadding ->
Box(modifier = Modifier.padding(contentPadding)) {
SampleButton(
onClick = { onIntent(SampleIntent.ButtonClicked) },
label = state.buttonLabel,
)
}
}
}
Intent
models an action that the user has taken (e.g. clicked a button, entered text, acknowledged a dialog, etc...).
It will usually be a sealed hierarchy or enum class
.
If you want to throttle the rate that your Intent
can be emitted, have it implement ThrottlingIntent
.
By default it will only allow one Intent
with a matching this::class
to emit every 500 milliseconds.
If you want to implement your own custom behavior for an Intent
, create an implementation of ViceIntentFilter
and add it to your ViceContainer
when creating it.
Note
ThrottlingIntentFilter
is added to ViceContainer
by default. If you provide your own ViceIntentFilter
and you want ThrottlingIntentFilter
to be used as well you'll have to manually include it.
The ViceCompositor
combines data into a ViewState
.
Instead of the traditional way of combining this data (e.g. using a kotlinx.coroutines.flow.combine function),
the ViceCompositor
leverages Compose's Snapshots to allow working with the data in a more natural, imperative manner.
Borrowing from Molecule's introduction:
Using combine
:
combine(
db.users().onStart { emit(null) },
db.balances().onStart { emit(0L) },
) { user, balance ->
when(user) {
null -> Loading
else -> Data(user.name, balance)
}
}
vs
Using Compose:
val user by userFlow.collectAsStateWithLifecycle(null)
val balance by balanceFlow.collectAsStateWithLifecycle(0L)
when(user) {
null -> Loading
else -> Data(user.name, balance)
}
Tip
Flow.collectAsStateWithLifecycle
is the suggested way to collect a Flow
in Compose5
ViceSource
aims to make it simple to provide data to a ViceCompositor
:
interface ViceSource<T> {
@Composable
fun currentState(): T
}
currentState()
can be backed by any source that can cause the function to recompose when data is changed.
It should provide a single piece of data for the ViewState
, and should be read in ViceCompositor.composite
in order to create the ViewState
.
There are several implementations provided by VICE that cover common use cases.
MutableStateSource
wraps a MutableState
, and provides an update
function for implementations to
mutate it. Useful for encapsulating behavior around the MutableState
instead of managing it in the ViceCompositor
.
internal sealed interface MyFeatureDialogState {
data object None : MyFeatureDialogState
data class Error(val message: String) : MyFeatureDialogState
}
@DestinationSingleton
internal class MyFeatureDialogSource : MutableStateSource<MyFeatureDialogState> {
override val initial = MyFeatureDialogState.None
fun clearError() {
update(MyFeatureDialogState.None)
}
fun showError(message: String) {
update(
MyFeatureDialogState.Error(message)
)
}
}
DerivedStateSource
is similar to MutableStateSource
, but encapsulates the content of a derivedStateOf
call:
@DestinationSingleton
internal class MyDerivedStateSource(
private val fastChangingStateSource: FastChangingStateSource,
) : DerivedStateSource<MyDerivedState> {
override fun deriveState(): MyDerivedState {
val fastChangingState by fastChangingStateSource
return fastChangingState.transformIntoSomethingElse()
}
private fun FastChangingState.transformIntoSomethingElse(): MyDerivedState {
return TODO()
}
}
FlowSource
is backed by a Flow<T>
and an initial: T
and recomposes whenever the Flow
emits.
@DestinationSingleton
internal class MyFlowSource(
private val featureRepo: MyFeatureRepository,
) : FlowSource<List<MyFeatureData>> {
override val initial = emptyList()
override val flow = featureRepo
.dataFlow
.map { dataList ->
dataList.map(::MyFeatureData)
}
}
LoadableFlowSource
is similar to a FlowSource
, but allows you to differentiate between
an initial placeholder value, and future values emitted from the provided Flow
. If the Flow
doesn't emit within a (configurable) amount of time, then any emissions for a (configurable) amount of time
after that will be delayed (so that there isn't a "flash" when transitioning from the placeholder to the actual data).
This can be very powerful when combined with a skeleton loading UI like Compose Placeholder.
class TodoItemsSource(
db: MyDatabase,
) : LoadableFlowSource<List<TodoItem>>() {
override val placeholder = listOf(
TodoItem(
id = "placeholder",
title = "_".repeat(20),
description = "_".repeat(75),
)
)
override val dataFlow =
db
.todoItemQueries
.findAll()
.asFlow()
.mapToList()
}
@Immutable
data class TodoItemsViewState(
val items: Loadable<List<TodoItem>>,
)
@Composable
fun TodoListView(
state: TodoItemsViewState,
onIntent: (TodoItemsIntent) -> Unit,
) {
lazyColumn {
items(
items = state.items.value,
key = { it.id },
) { todoItem ->
Column {
Text(
text = todoItem.title,
modifier = Modifier.placeholder(visible = state.items.isLoading)
)
Text(
text = todoItem.description,
modifier = Modifier.placeholder(visible = state.items.isLoading)
)
}
}
}
}
StateFlowSource
is backed by a StateFlow<T>
and a onAttached
function that receives a CoroutineScope
that
allows you to modify the StateFlow
(which is usually implemented as a MutableStateFlow
).
@DestinationSingleton
internal class TimerFlowSource : StateFlowSource<Int> {
override val flow = MutableStateFlow(0)
override suspend fun onAttached(scope: CoroutineScope) {
var i = 0
while(isActive) {
flow.value = i++
delay(1.seconds)
}
}
}
It can also function as a way to encapsulate external mutations to the StateFlow
:
internal sealed interface TimerState {
data object Reset : TimerState
data class Running(val secondsRemaining: Int) : TimerState
data object Finished : TimeState
}
@DestinationSingleton
internal class TimerFlowSource : StateFlowSource<TimerState> {
override val flow = MutableStateFlow(TimerState.Reset)
override suspend fun onAttached(scope: CoroutineScope) {
var i = 0
while(isActive) {
flow.value = i++
delay(1.seconds)
}
}
}
ViceEffects
handle non UI related aspects of a Destination
, like analytics, lifecycle handling, etc…
There is a convenient no-op implementation available at ViceEffects.None
.
class WelcomeScreenEffects(
private val analytics: Analytics,
private val lifecycle: Lifecycle,
) : ViceEffects() {
override fun CoroutineScope.runEffects() {
launch {
lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) {
analytics.trackResumeWelcomeScreen()
}
}
}
}
Let's take a look at a simple example:
sealed interface GreetingIntent {
data object SaidHello : GreetingIntent, ThrottlingIntent
}
@Immutable
data class GreetingState(
val greeting: String?,
)
@Composable
fun GreetingView(state: GreetingState, onIntent: (GreetingIntent) -> Unit) {
Column {
if(state.greeting != null) {
Text(state.greeting)
}
Button(
onClick = { onIntent(GreetingIntent.SaidHello) },
) {
Text("Say Hello")
}
}
}
class GreetingCompositor : ViceCompositor {
// remember isn't needed since we're not in a Composable context
private var greeting by mutableStateOf<String>(null)
@Composable
override fun composite() = GreetingState(
greeting = greeting,
)
override suspend fun onIntent(intent: GreetingIntent) {
when(intent) {
GreetingIntent.SaidHello -> greeting = "Hello!"
}
}
}
VICE has an integration with AndroidX Navigation Compose that provides a ViceDestination
and NavGraphBuilder
extensions to simplify setting up a Navigation Compose
destination.
sealed interface HomeIntent {
data object AddItem : HomeIntent
data class ToggleItemCompletion(val item: TodoItem) : HomeIntent
data class NavigateToDetails(val id: String) : HomeIntent
data object NavigateToSettings : HomeIntent
}
data class HomeViewState(
val items: List<TodoItem>,
)
typealias HomeView = @Composable (HomeViewState, (HomeIntent) -> Unit) -> Unit
fun HomeView(
state: HomeViewState,
onIntent: (HomeIntent) -> Unit,
) {
TODO()
}
class HomeCompositor(
private val onNavigateToCreateItem: () -> Unit,
private val onNavigateToUpdateItem: (String) -> Unit,
private val onNavigateToSettings: () -> Unit,
) : ViceCompositor<HomeIntent, HomeViewState> {
@Composable
override fun composite() = HomeViewState(
items = TodoRepo.items.collectAsState().value,
)
override suspend fun onIntent(intent: HomeIntent) {
when(intent) {
HomeIntent.AddItem -> onNavigateToCreateItem()
is HomeIntent.ToggleItemCompletion -> TodoRepo.updateItem(
newItem = intent.item.copy(
completed = !intent.item.completed,
),
)
is HomeIntent.NavigateToDetails -> onNavigateToUpdateItem(intent.id)
HomeIntent.NavigateToSettings -> onNavigateToSettings()
}
}
}
class HomeDestination(
onNavigateToCreateItem: () -> Unit,
onNavigateToUpdateItem: (String) -> Unit,
onNavigateToSettings: () -> Unit,
) : SampleDestination<HomeIntent, HomeCompositor, HomeViewState>() {
override val view: HomeView = { state, onIntent -> HomeView(state, onIntent) }
override val compositor = HomeCompositor(
onNavigateToCreateItem,
onNavigateToUpdateItem,
onNavigateToSettings,
)
}
LocalAnimatedVisibilityScope
provides the AnimatedVisibilityScope
from the NavGraphBuilder.destination
calls
to simplify working with Compose Shared Element Transitions.
You need to wrap your NavHost
in SharedTransitionLayout
for this to work:
SharedTransitionLayout {
CompositionLocalProvider(
LocalSharedTransitionScope provides this
) {
NavHost(...) {
viceComposable<Routes.Home> {
...
}
}
}
}
You can now use shared element transition APIs more easily in your ViceView
:
@Composable
fun HomeView(
state: HomeState,
onIntent: (HomeIntent) -> Unit,
) {
Box(
modifier = Modifier
.sharedElement(
sharedTransitionScope = LocalSharedTransitionScope.current,
animatedVisibilityScope = LocalAnimatedVisibilityScope.current,
state = rememberSharedContentState("foo")
)
)
}