From 5bc26d7568a1b4145e265ca90cc961ed2bd982b1 Mon Sep 17 00:00:00 2001 From: Eliezer Graber Date: Mon, 23 Sep 2024 19:01:16 -0400 Subject: [PATCH] Update the README --- .github/workflows/publish_release.yml | 3 +- README.md | 522 +++++++++++++++++++++++++- 2 files changed, 521 insertions(+), 4 deletions(-) diff --git a/.github/workflows/publish_release.yml b/.github/workflows/publish_release.yml index 399ac1d..640f0f4 100644 --- a/.github/workflows/publish_release.yml +++ b/.github/workflows/publish_release.yml @@ -119,7 +119,8 @@ jobs: id: prepare_next_dev run: | sed -i -e 's/${{ env.CURRENT_VERSION }}/${{ env.NEXT_VERSION }}/g' gradle.properties && \ - sed -i -E -e 's/vice-core(:|\/)[0-9]+\.[0-9]+\.[0-9]+/vice-core\1${{ env.RELEASE_VERSION }}/g' README.md + sed -i -E -e 's/vice-core(:|\/)[0-9]+\.[0-9]+\.[0-9]+/vice-core\1${{ env.RELEASE_VERSION }}/g' README.md && \ + sed -i -E -e 's/vice-nav(:|\/)[0-9]+\.[0-9]+\.[0-9]+/vice-nav\1${{ env.RELEASE_VERSION }}/g' README.md - name: Commit next dev version id: commit_next_dev diff --git a/README.md b/README.md index 95eb53b..9af1de5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,14 @@ # VICE -VICE is a KMP MVI framework that is built on Compose. +[VICE] is an [MVI] (Model-View-Intent) framework that uses [UDF] (Unidirectional Data Flow) to drive a UI: + +```mermaid +graph LR; + Compositor-- composites ViewState --->View + View-- emits Intent --->Compositor +``` + +It supports all KMP targets that are supported by Compose Multiplatform. ### Setup @@ -11,11 +19,519 @@ repositories { dependencies { implementation("com.eygraber:vice-core:0.0.5") + implementation("com.eygraber:vice-nav:0.0.5") } ``` Snapshots can be found [here](https://s01.oss.sonatype.org/#nexus-search;gav~com.eygraber~vice~~~). -### Usage +## Advantages + +The advantages of VICE are: + +1. It adheres to [SRP] and UDF while remaining simple +2. It provides a natural, imperative way of working with async data +3. It provides an immutable way to describe the state of a UI that doesn't allow for backdoor mutability +4. It takes the guess work out of how to structure UI code + +## Components + +There are 5 main components of VICE: + +1. ViceView +2. Intent +3. ViceCompositor +4. ViceEffects +5. ViewState + +### ViceContainer + +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. + +## ViceView + +The `ViceView` is a `Composable` function that takes 2 parameters: + +1. `ViewState` - an immutable class representing the state of the View at the current moment +2. `onIntent` - a function for emitting `Intent` that correspond to user interactions + +```kotlin +typealias ViceView = @Composable (State, (Intent) -> Unit) -> Unit +``` + +As the user interacts with the `ViceView` it emits a corresponding `Intent` using the `onIntent` callback. +The`ViceCompositor` 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 differ[2][3][4] + +```kotlin +@Immutable +data class SampleViewState( + val title: String, + val buttonLabel: String, + val bottomNavItems: List, +) + +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 + +`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`. + +### ThrottlingIntent + +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. + +## ViceCompositor + +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`: +```kotlin +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: +```kotlin +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 Compose[5] + +### ViceSource + +`ViceSource` aims to make it simple to provide data to a `ViceCompositor`: + +```kotlin +interface ViceSource { + @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 + +`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`. + + +```kotlin +internal sealed interface MyFeatureDialogState { + data object None : MyFeatureDialogState + data class Error(val message: String) : MyFeatureDialogState +} + +@DestinationSingleton +internal class MyFeatureDialogSource : MutableStateSource { + override val initial = MyFeatureDialogState.None + + fun clearError() { + update(MyFeatureDialogState.None) + } + + fun showError(message: String) { + update( + MyFeatureDialogState.Error(message) + ) + } +} +``` + +### DerivedStateSource + +`DerivedStateSource` is similar to `MutableStateSource`, but encapsulates the content of a `derivedStateOf` call: + +```kotlin +@DestinationSingleton +internal class MyDerivedStateSource( + private val fastChangingStateSource: FastChangingStateSource, +) : DerivedStateSource { + override fun deriveState(): MyDerivedState { + val fastChangingState by fastChangingStateSource + + return fastChangingState.transformIntoSomethingElse() + } + + private fun FastChangingState.transformIntoSomethingElse(): MyDerivedState { + return TODO() + } +} +``` + +### FlowSource + +`FlowSource` is backed by a `Flow` and an `initial: T` and recomposes whenever the `Flow` emits. + +```kotlin +@DestinationSingleton +internal class MyFlowSource( + private val featureRepo: MyFeatureRepository, +) : FlowSource> { + override val initial = emptyList() + + override val flow = featureRepo + .dataFlow + .map { dataList -> + dataList.map(::MyFeatureData) + } +} +``` + +### LoadableFlowSource + +`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]. + +```kotlin +class TodoItemsSource( + db: MyDatabase, +) : LoadableFlowSource>() { + 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>, +) + +@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 + +`StateFlowSource` is backed by a `StateFlow` and a `onAttached` function that receives a `CoroutineScope` that +allows you to modify the `StateFlow` (which is usually implemented as a `MutableStateFlow`). + +```kotlin +@DestinationSingleton +internal class TimerFlowSource : StateFlowSource { + 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`: + +```kotlin +internal sealed interface TimerState { + data object Reset : TimerState + data class Running(val secondsRemaining: Int) : TimerState + data object Finished : TimeState +} + +@DestinationSingleton +internal class TimerFlowSource : StateFlowSource { + override val flow = MutableStateFlow(TimerState.Reset) + + override suspend fun onAttached(scope: CoroutineScope) { + var i = 0 + while(isActive) { + flow.value = i++ + delay(1.seconds) + } + } +} +``` + +## ViceEffects + +`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`. + +```kotlin +class WelcomeScreenEffects( + private val analytics: Analytics, + private val lifecycle: Lifecycle, +) : ViceEffects() { + override fun CoroutineScope.runEffects() { + launch { + lifecycle.repeatOnLifecycle(Lifecycle.State.RESUMED) { + analytics.trackResumeWelcomeScreen() + } + } + } +} +``` + +## Example + +Let's take a look at a simple example: + +```kotlin +sealed interface GreetingIntent : ThrottlingIntent { + data object SaidHello : GreetingIntent +} + +@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(null) + + @Composable + override fun composite() = GreetingState( + greeting = greeting, + ) + + override suspend fun onIntent(intent: GreetingIntent) { + when(intent) { + GreetingIntent.SaidHello -> greeting = "Hello!" + } + } +} +``` + +## Integrations + +VICE has an integration with [AndroidX Navigation Compose] that provides a `ViceDestination` and `NavGraphBuilder` +extensions to simplify setting up a `Navigation Compose` destination. + +```kotlin +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, +) + +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 { + @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() { + override val view: HomeView = { state, onIntent -> HomeView(state, onIntent) } + + override val compositor = HomeCompositor( + onNavigateToCreateItem, + onNavigateToUpdateItem, + onNavigateToSettings, + ) +} + +``` + +### Shared Element Transitions + +`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: + +```kotlin +SharedTransitionLayout { + CompositionLocalProvider( + LocalSharedTransitionScope provides this + ) { + NavHost(...) { + viceComposable { + ... + } + } + } +} +``` + +You can now use shared element transition APIs more easily in your `ViceView`: + +```kotlin +@Composable +fun HomeView( + state: HomeState, + onIntent: (HomeIntent) -> Unit, +) { + Box( + modifier = Modifier + .sharedElement( + sharedTransitionScope = LocalSharedTransitionScope.current, + animatedVisibilityScope = LocalAnimatedVisibilityScope.current, + state = rememberSharedContentState("foo") + ) + ) +} +``` + +[1]: https://developer.android.com/develop/ui/compose/mental-model#recomposition +[2]: https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc#781e +[3]: https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc#6ac8 +[4]: https://medium.com/androiddevelopers/new-ways-of-optimizing-stability-in-jetpack-compose-038106c283cc#8826 +[5]: https://developer.android.com/develop/ui/compose/state#use-other-types-of-state-in-jetpack-compose -More to come here. +[AndroidX Navigation Compose]: https://developer.android.com/develop/ui/compose/navigation +[Compose Placeholder]: https://github.com/eygraber/compose-placeholder/ +[Compose Shared Element Transitions]: https://developer.android.com/develop/ui/compose/animation/shared-elements +[Compose's Snapshots]: https://blog.zachklipp.com/introduction-to-the-compose-snapshot-system/ +[Compose Stability]: https://getstream.io/blog/jetpack-compose-stability +[kotlinx.coroutines.flow.combine function]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines.flow/combine.html +[Molecule's introduction]: https://github.com/cashapp/molecule?tab=readme-ov-file#introduction +[MVI]: https://www.geeksforgeeks.org/model-view-intent-mvi-pattern-in-reactive-programming-a-comprehensive-overview/ +[SRP]: https://en.wikipedia.org/wiki/Single-responsibility_principle +[UDF]: https://developer.android.com/topic/architecture/ui-layer#udf +[VICE]: https://github.com/eygraber/vice