Skip to content

Commit

Permalink
View subscription status mapper
Browse files Browse the repository at this point in the history
  • Loading branch information
motorro committed Aug 11, 2022
1 parent b84a2ca commit 3b9d14f
Show file tree
Hide file tree
Showing 3 changed files with 106 additions and 15 deletions.
60 changes: 54 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ logical states:
Each logical state should be able to:

- Update `UiState`
- Process some **relevant** user interactions - `Gestures` while ignoring irrelevant
- Process some **relevant** user interactions - `Gestures` ignoring irrelevant
- Hold some internal data state
- Transition to another logical state passing some of the shared data between

Expand Down Expand Up @@ -209,7 +209,12 @@ open class FlowStateMachine<G: Any, U: Any>(
/**
* ExportedUI state
*/
val uiState: SharedFlow<U> = mediator
val uiState: SharedFlow<U> = mediator.asSharedFlow()

/**
* Subscription count to allow special actions on view connect/disconnect
*/
val subscriptionCount: StateFlow<Int> = mediator.subscriptionCount

final override fun setUiState(uiState: U) {
mediator.tryEmit(uiState)
Expand Down Expand Up @@ -478,7 +483,7 @@ developers may choose the most suitable tools to implement each one without affe
example above is a very basic one. However you could do things a bit more clean by using some of the
additional abstractions (see below).

## Handy abstractions to mix-in
## Tools and Handy abstractions to mix-in

In the basic example above all the work was done by the state objects. They did:

Expand Down Expand Up @@ -620,7 +625,7 @@ interface LoginContext {
}
```

Than you could provide it to your state through the constructor parameters. To make things even
Then you could provide it to your state through the constructor parameters. To make things even
easier let's make some [base state](login/src/main/java/com/motorro/statemachine/login/model/state/LoginState.kt)
for the state-machine assembly and use a delegation to provide each context dependency:

Expand Down Expand Up @@ -672,8 +677,8 @@ class CredentialsCheckState(
As I've already mentioned, creating new states explicitly to pass them to the state-machine later
(like in the basic example) is not a good idea in terms of coupling and dependency provision.

Let's move it away from our machine states by introducing a common [factory interface](login/src/main/java/com/motorro/statemachine/login/model/state/LoginStateFactory.kt):
that fill take the responsibility to provide dependencies and abstract our state creation logic:
Let's move it away from our machine states by introducing a common [factory interface](login/src/main/java/com/motorro/statemachine/login/model/state/LoginStateFactory.kt)
that will take the responsibility to provide dependencies and abstract our state creation logic:

```kotlin
interface LoginStateFactory {
Expand Down Expand Up @@ -800,6 +805,49 @@ class LoginViewModel @Inject constructor(private val factory: LoginStateFactory)

```

### View lifecycle with `FlowStateMachine`

Imaging we have a resource-consuming operation, like location tracking, running in our state. It may
save client's resources if we choose to pause tracking when the view is inactive - app goes to
background or the Android activity is paused. In that case I suggest to create some special gestures
and pass them to state-machine for processing. For example, the [FlowStateMachine](coroutines/src/commonMain/kotlin/com/motorro/commonstatemachine/coroutines/FlowStateMachine.kt)
exports the `uiStateSubscriptionCount` property that is a flow of number of subscribers listening to
the `uiState` property. If you use some [repeatOnLifecycle](https://developer.android.com/reference/kotlin/androidx/lifecycle/package-summary#(androidx.lifecycle.Lifecycle).repeatOnLifecycle(androidx.lifecycle.Lifecycle.State,kotlin.coroutines.SuspendFunction1))
to subscribe `uiState`, you could use this property to figure out some special processing. For
convenience there is an `mapUiSubscriptions` extension function available to reduces boilerplate.
It accepts two gesture-producing functions and updates the state-machine with them when subscriber's
state changes:

```kotlin
class WithIdleViewModel : ViewModel() {
/**
* Creates initial state for state-machine
* You could process a deep-link here or restore from a saved state
*/
private fun initStateMachine(): CommonMachineState<SomeGesture, SomeUiState> = InitialState()

/**
* State-machine instance
*/
private val stateMachine = FlowStateMachine(::initStateMachine)

/**
* UI State
*/
val state: SharedFlow<SomeUiState> = stateMachine.uiState

init {
// Subscribes to active subscribers count and updates state machine with corresponding
// gestures
stateMachine.mapUiSubscriptions(
viewModelScope,
onActive = { SomeGesture.OnActive },
onInactive = { SomeGesture.OnInactive }
)
}
}
```

## Multi-module applications

Let's take a more complicated example with a multi-screen flow like the [customer on-boarding](welcome).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@ package com.motorro.commonstatemachine.coroutines

import com.motorro.commonstatemachine.CommonMachineState
import com.motorro.commonstatemachine.CommonStateMachine
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.channels.BufferOverflow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.*

/**
* State machine that emits UI state as Flow
Expand All @@ -35,7 +35,12 @@ open class FlowStateMachine<G: Any, U: Any>(init: () -> CommonMachineState<G, U>
/**
* UI state
*/
val uiState: SharedFlow<U> = mediator
val uiState: SharedFlow<U> = mediator.asSharedFlow()

/**
* Subscription count of [uiState] to allow special actions on view connect/disconnect
*/
val uiStateSubscriptionCount: StateFlow<Int> = mediator.subscriptionCount

/**
* Updates UI state
Expand All @@ -44,4 +49,26 @@ open class FlowStateMachine<G: Any, U: Any>(init: () -> CommonMachineState<G, U>
final override fun setUiState(uiState: U) {
mediator.tryEmit(uiState)
}
}

/**
* Watches UI-state subscriptions and updates state machine with gestures produced by [onActive]
* and [onInactive].
* May be used to suspend expensive operations in machine-states when no active subscribers
* present on [FlowStateMachine.uiState]
* @param scope Scope to run mapper
* @param onActive Produces a gesture when view is active
* @param onInactive Produces a gesture when view is inactive
*/
fun <G: Any, U: Any> FlowStateMachine<G, U>.mapUiSubscriptions(
scope: CoroutineScope,
onActive: (() -> G)? = null,
onInactive: (() -> G)? = null
) {
uiStateSubscriptionCount
.map { count -> count > 0 }
.distinctUntilChanged()
.mapNotNull { active -> if (active) onActive?.invoke() else onInactive?.invoke() }
.onEach(::process)
.launchIn(scope)
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,9 @@

package com.motorro.commonstatemachine.coroutines

import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.UnconfinedTestDispatcher
import kotlinx.coroutines.test.runTest
import kotlin.test.Test
Expand All @@ -28,10 +28,10 @@ class FlowStateMachineTest {
private val stateMachine = FlowStateMachine { state }

@Test
fun updatesUiStateCollector() = runTest {
fun updatesUiStateCollector() = runTest(UnconfinedTestDispatcher()) {

val values = mutableListOf<Int>()
val collectJob = launch(UnconfinedTestDispatcher()) {
val collectJob = launch {
stateMachine.uiState.toList(values)
}

Expand All @@ -48,13 +48,13 @@ class FlowStateMachineTest {
}

@Test
fun updatesLateUiStateCollector() = runTest {
fun updatesLateUiStateCollector() = runTest(UnconfinedTestDispatcher()) {
state.doSetUiState(1)
state.doSetUiState(2)
state.doSetUiState(3)

val values = mutableListOf<Int>()
val collectJob = launch(UnconfinedTestDispatcher()) {
val collectJob = launch {
stateMachine.uiState.toList(values)
}

Expand All @@ -63,4 +63,20 @@ class FlowStateMachineTest {

collectJob.cancel()
}

@Test
fun notifiesOnActiveAndInactive() = runTest(UnconfinedTestDispatcher()) {
val activeScope = CoroutineScope(SupervisorJob() + UnconfinedTestDispatcher())
stateMachine.mapUiSubscriptions(activeScope, onActive = { 1 }, onInactive = { 2 })

assertEquals(listOf(2), state.processed)

val collectJob = launch {
stateMachine.uiState.collect()
}
collectJob.cancel()
activeScope.cancel()

assertEquals(listOf(2, 1, 2), state.processed)
}
}

0 comments on commit 3b9d14f

Please sign in to comment.