Skip to content

chRyNaN/cycle

Repository files navigation

presentation

cycle

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.

Perform > Reduce > 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.

GitHub tag (latest by date)

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
    }
}

Getting Started 🏁

The library is provided through Repsy.io. Checkout the releases page to get the latest version.

GitHub tag (latest by date)

Repository

repositories {
    maven {
        url = uri("https://repo.repsy.io/mvn/chrynan/public")
    }
}

Dependencies

core

implementation("com.chrynan.cycle:cycle-core:$VERSION")

compose

implementation("com.chrynan.cycle:cycle-compose:$VERSION")

Usage 👨‍💻

State Management (Perform and Reduce)

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()
}

UI Management (Compose)

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.

View

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.

Documentation 📃

More detailed documentation is available in the docs folder. The entry point to the documentation can be found here.

Security 🛡️

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.

Contributing ✍️

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!

Sponsorship ❤️

Support this project by becoming a sponsor of my work! And make sure to give the repository a ⭐

License ⚖️

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.