A Kotlin multi-platform presentation layer design pattern. This is a cyclic (hence the name) uni-directional data flow (UDF) design pattern library that is closely related to the MVI (Model-View-Intent) pattern on Android. It utilizes kotlinx.coroutines Flows and is easily compatible with modern UI Frameworks, such as Jetpack Compose.
This design pattern breaks down complex application logic into three simple parts: Perform the actions, Reduce the changes, and Compose the view from the state. This simple approach to application design is easy to reason about, implement, debug, and test, and very flexible to adapt to any application's specific needs.
fun counterReducer(state: Int?, change: CounterChange): Int {
val value = state ?: 0
return when (change) {
CounterChange.INCREMENT -> value + 1
CounterChange.DECREMENT -> value - 1
}
}
@Composable
fun Counter() {
val viewModel = remember { ViewModel.create(reducer = ::counterReducer) }
val state by viewModel.stateChanges()
Text("Count = $state")
LaunchedEffect(Unit) {
viewModel.dispatch(CounterChange.INCREMENT) // 1
viewModel.dispatch(CounterChange.INCREMENT) // 2
viewModel.dispatch(CounterChange.DECREMENT) // 1
}
}
The library is provided through Repsy.io. Checkout the
releases page to get the latest version.
repositories {
maven {
url = uri("https://repo.repsy.io/mvn/chrynan/public")
}
}
implementation("com.chrynan.cycle:cycle-core:$VERSION")
implementation("com.chrynan.cycle:cycle-compose:$VERSION")
The first two parts of a cycle are Perform and Reduce which, together, invoke application logic that produces changes, which then get reduced to create a new state. This process, which is illustrated below, can ultimately be considered as state management since it involves the creation, alteration, and storage of state.
The following is an example of using a StateStore
component from this library to implement the same example shown in
the Redux Javascript Library's Documentation, but in
Kotlin.
enum class CounterChange {
INCREMENT,
DECREMENT
}
fun counterReducer(state: Int?, change: CounterChange): Int {
val value = state ?: 0
return when (change) {
CounterChange.INCREMENT -> value + 1
CounterChange.DECREMENT -> value - 1
}
}
fun testCounter(coroutineScope: CoroutineScope) {
val store = MutableStateStore(reducer = ::counterReducer)
store.subscribe(coroutineScope = coroutineScope) { state ->
println(state)
}
coroutineScope.launch {
store.dispatch(CounterChange.INCREMENT) // 1
store.dispatch(CounterChange.INCREMENT) // 2
store.dispatch(CounterChange.DECREMENT) // 1
}
}
The above example is a good simple demonstration, but it isn't very useful for more complex, "real-world" applications. While the fundamentals are the same, applications often require a more complex flow of logic. Coordinating the flow of logic efficiently between different application components is the responsibility of a design pattern.
There are many application level design patterns (MVC, MVP, MVVM, MVI, to name a few), but this library focuses on MVVM
and MVI design patterns, since those are easily reactive (using Kotlin Coroutine Flows) and easily supportive of the
UDF (uni-directional data flow) design principal. There is a ViewModel
component provided by this library which can
encapsulate component specific functionality. The above example can be updated to utilize a ViewModel
and perform
more complex actions at the call-site:
fun testCounter() {
val viewModel = ViewModel.create(reducer = ::counterReducer).apply { bind() }
viewModel.subscribe { state ->
println(state)
}
viewModel.dispatch(CounterChange.INCREMENT) // 1
viewModel.dispatch(CounterChange.INCREMENT) // 2
viewModel.dispatch(CounterChange.DECREMENT) // 1
// The provided action will be invoked and must return a Flow of changes
// 2
viewModel.perform {
flow {
emit(CounterChange.INCREMENT)
if ((viewModel.currentState ?: 0) > 2) {
emit(CounterChange.DECREMENT)
}
}
}
viewModel.unbind()
}
The above example illustrates the usage of the ViewModel.perform
function, which takes an Action
value as a
parameter. An Action
is simply a typealias
for a suspending function that takes the current State
as a parameter
and returns a Flow
of Changes
. This function is typically not invoked at the call-site, as in the example above,
but instead invoked by ViewModel
implementing classes. This forces the logic to be well-defined, encapsulated within
a single component, and easily testable. The above example re-written to use a custom ViewModel
might look like the
following:
class CounterViewModel : ViewModel<Int, CounterChange>(
stateStore = MutableStateStore(reducer = ::counterReducer)
) {
fun increment() = dispatch(CounterChange.INCREMENT)
fun decrement() = dispatch(CounterChange.DECREMENT)
fun incrementIfLessThanTwo() = perform {
flow {
emit(CounterChange.INCREMENT)
if ((currentState ?: 0) > 2) {
emit(CounterChange.DECREMENT)
}
}
}
}
fun testCounter() {
val viewModel = CounterViewModel().apply { bind() }
viewModel.subscribe { state ->
println(state)
}
// Note: The dispatch function is no longer public, so we can't access it here.
viewModel.increment() // 1
viewModel.increment() // 2
viewModel.decrement() // 1
// Note: The perform function is no longer public, so we can't access it here.
viewModel.incrementIfLessThanTwo() // 2
viewModel.unbind()
}
Another common design pattern is MVI (Model-View-Intent). With this design pattern, an Intent
model is emitted on the
ViewModel's
reactive stream, which triggers an associated Action
, resulting in a Flow
of Changes
being emitted
and reduced to produce new States
. This is similar to the above example, but instead of having separate functions on
the ViewModel
for each action, we will have a single intent(to:)
function on the ViewModel
that takes an Intent
model and performs the appropriate action based on that value. This approach can easily be implemented with this
library by extending the IntentViewModel
class:
enum class CounterIntent {
INCREMENT,
DECREMENT,
INCREMENT_IF_LESS_THAN_TWO
}
enum class CounterChange {
INCREMENTED,
DECREMENTED,
NO_CHANGE
}
fun counterReducer(state: Int?, change: CounterChange): Int {
val value = state ?: 0
return when (change) {
CounterChange.INCREMENTED -> value + 1
CounterChange.DECREMENTED -> value - 1
CounterChange.NO_CHANGE -> value
}
}
class CounterViewModel : IntentViewModel<CounterIntent, Int, CounterChange>(
stateStore = MutableStateStore(reducer = ::counterReducer)
) {
override fun performIntentAction(state: Int?, intent: CounterIntent): Flow<CounterChange> = flow {
val change = when (intent) {
CounterIntent.INCREMENT -> CounterChange.INCREMENTED
CounterIntent.INCREMENT_IF_LESS_THAN_TWO -> CounterChange.NO_CHANGE
CounterIntent.DECREMENT -> CounterChange.DECREMENTED
}
emit(change)
}
}
fun testCounter() {
val viewModel = CounterViewModel().apply { bind() }
viewModel.subscribe { state ->
println(state)
}
// Note: The dispatch function is no longer public, so we can't access it here.
viewModel.intent(to = CounterIntent.INCREMENT) // 1
viewModel.intent(to = CounterIntent.INCREMENT) // 2
viewModel.intent(to = CounterIntent.DECREMENT) // 1
// Note: The perform function is no longer public, so we can't access it here.
viewModel.intent(to = CounterIntent.INCREMENT_IF_LESS_THAN_TWO)
viewModel.unbind()
}
The third and final part of a cycle is Compose which is responsible for listening to new states and updating a UI view accordingly. This part's implementation is dependent on the UI framework used, but can easily be adapted to fit most modern UI frameworks.
The easiest way to subscribe to state changes to update the UI, is to use the subscribe
function:
viewModel.subscribe { state ->
// Update the UI or trigger a UI refresh here using the new state.
}
Note: That a ViewModel
has a lifecycle which is defined by the invocation of its bind/unbind
functions.
Therefore, the ViewModel.bind
function must be called before the ViewModel.subscribe
function is invoked, otherwise
no states will be emitted to the subscribe
function closure.
Alternatively, you can use the cycle-compose dependency when targeting Jetpack Compose for a simple
integration. Use the stateChanges()
to convert the Flow
of State
changes to a Jetpack Compose State
. This
approach also handles binding and unbinding of the ViewModel
for you.
@Composable
fun Home(viewModel: HomeViewModel) {
val state by viewModel.stateChanges()
// Use the state to construct the UI.
}
In the example above, the stateChanges
function binds the ViewModel
to the lifecycle of the composable function and
listens to changes in the State
. The type is converted from a Flow
of States
to a Jetpack Compose State
, so when
a state change occurs, it triggers recomposition of the composable function.
The View
interface represents a UI component that contains a ViewModel
and properly binds its lifecycle to that of
the UI component. This interface can be used to encapsulate lifecycle and logic within the framework defined UI
component implementation.
More detailed documentation is available in the docs folder. The entry point to the documentation can be found here.
For security vulnerabilities, concerns, or issues, please responsibly disclose the information either by opening a public GitHub Issue or reaching out to the project owner.
Outside contributions are welcome for this project. Please follow the code of conduct and coding conventions when contributing. If contributing code, please add thorough documents. and tests. Thank you!
Support this project by becoming a sponsor of my work! And make sure to give the repository a ⭐
Copyright 2021 chRyNaN
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.