Skip to content
This repository has been archived by the owner on Nov 1, 2022. It is now read-only.

Commit

Permalink
Issue #10335: Introduce Jetpack Compose bindings for lib-state.
Browse files Browse the repository at this point in the history
  • Loading branch information
pocmo committed Jun 1, 2021
1 parent 7ec20a5 commit f00bf58
Show file tree
Hide file tree
Showing 5 changed files with 235 additions and 2 deletions.
1 change: 1 addition & 0 deletions buildSrc/src/main/java/Dependencies.kt
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,7 @@ object Dependencies {
const val androidx_cardview = "androidx.cardview:cardview:${Versions.AndroidX.cardview}"
const val androidx_compose_ui = "androidx.compose.ui:ui:${Versions.AndroidX.compose}"
const val androidx_compose_ui_test = "androidx.compose.ui:ui-test-junit4:${Versions.AndroidX.compose}"
const val androidx_compose_ui_test_manifest = "androidx.compose.ui:ui-test-manifest:${Versions.AndroidX.compose}"
const val androidx_compose_ui_tooling = "androidx.compose.ui:ui-tooling:${Versions.AndroidX.compose}"
const val androidx_compose_foundation = "androidx.compose.foundation:foundation:${Versions.AndroidX.compose}"
const val androidx_compose_material = "androidx.compose.material:material:${Versions.AndroidX.compose}"
Expand Down
15 changes: 15 additions & 0 deletions components/lib/state/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,22 @@ android {
proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
}
}

buildFeatures {
compose true
}

composeOptions {
kotlinCompilerVersion = Versions.kotlin
kotlinCompilerExtensionVersion = Versions.compose_version
}
}

dependencies {
implementation Dependencies.kotlin_stdlib
implementation Dependencies.kotlin_coroutines
implementation Dependencies.androidx_fragment
implementation Dependencies.androidx_compose_ui
implementation Dependencies.androidx_lifecycle_process

implementation project(':support-base')
Expand All @@ -37,9 +47,14 @@ dependencies {

testImplementation Dependencies.androidx_test_core
testImplementation Dependencies.androidx_test_junit
testImplementation Dependencies.androidx_compose_ui_test
testImplementation Dependencies.testing_robolectric
testImplementation Dependencies.testing_coroutines
testImplementation Dependencies.testing_mockito

androidTestImplementation Dependencies.androidx_test_junit
androidTestImplementation Dependencies.androidx_compose_ui_test_manifest
androidTestImplementation Dependencies.androidx_compose_ui_test
}

apply from: '../../../publish.gradle'
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.lib.state.ext

import androidx.compose.ui.test.junit4.createComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import kotlinx.coroutines.runBlocking
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class ComposeExtensionsKtTest {
@get:Rule
val rule = createComposeRule()

@Test
fun usingInitialValue() {
val store = Store(
initialState = TestState(counter = 42),
reducer = ::reducer
)

var value: Int? = null

rule.setContent {
val composeState = store.observeAsState { state -> state.counter * 2 }
value = composeState.value
}

assertEquals(84, value)
}

@Test
fun receivingUpdates() {
val store = Store(
initialState = TestState(counter = 42),
reducer = ::reducer
)

var value: Int? = null

rule.setContent {
val composeState = store.observeAsState { state -> state.counter * 2 }
value = composeState.value
}

store.dispatchBlockingOnIdle(TestAction.IncrementAction)

rule.runOnIdle {
assertEquals(86, value)
}
}

@Test
fun receivingUpdatesForPartialStateUpdateOnly() {
val store = Store(
initialState = TestState(counter = 42),
reducer = ::reducer
)

var value: Int? = null

rule.setContent {
val composeState = store.observeAsState(
map = { state -> state.counter * 2 },
observe = { state -> state.text }
)
value = composeState.value
}

assertEquals(84, value)

store.dispatchBlockingOnIdle(TestAction.IncrementAction)

rule.runOnIdle {
// State value didn't change because value returned by `observer` function did not change
assertEquals(84, value)
}

store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World"))

rule.runOnIdle {
// Now, after the value from the observer function changed, we are seeing the new value
assertEquals(86, value)
}

store.dispatchBlockingOnIdle(TestAction.SetValueAction(23))

rule.runOnIdle {
// Observer function result is the same, no state update
assertEquals(86, value)
}

store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World"))

rule.runOnIdle {
// Text was updated to the same value, observer function result is the same, no state update
assertEquals(86, value)
}

store.dispatchBlockingOnIdle(TestAction.SetTextAction("Hello World Again"))

rule.runOnIdle {
// Now, after the value from the observer function changed, we are seeing the new value
assertEquals(46, value)
}
}

private fun Store<TestState, TestAction>.dispatchBlockingOnIdle(action: TestAction) {
rule.runOnIdle {
val job = dispatch(action)
runBlocking { job.join() }
}
}
}

fun reducer(state: TestState, action: TestAction): TestState = when (action) {
is TestAction.IncrementAction -> state.copy(counter = state.counter + 1)
is TestAction.DecrementAction -> state.copy(counter = state.counter - 1)
is TestAction.SetValueAction -> state.copy(counter = action.value)
is TestAction.SetTextAction -> state.copy(text = action.text)
}

data class TestState(
val counter: Int,
val text: String = ""
) : State

sealed class TestAction : Action {
object IncrementAction : TestAction()
object DecrementAction : TestAction()
data class SetValueAction(val value: Int) : TestAction()
data class SetTextAction(val text: String) : TestAction()
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

package mozilla.components.lib.state.ext

import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.ui.platform.LocalLifecycleOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import mozilla.components.lib.state.Action
import mozilla.components.lib.state.State
import mozilla.components.lib.state.Store
import androidx.compose.runtime.State as ComposeState

/**
* Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState].
*
* Every time the mapped [Store] state changes, the returned [ComposeState] will be updated causing
* recomposition of every [ComposeState.value] usage.
*
* The [Store] observer will automatically be removed when this composable disposes or the current
* [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state.
*/
@Composable
fun <S : State, A : Action, R> Store<S, A>.observeAsState(map: (S) -> R): ComposeState<R?> {
val lifecycleOwner = LocalLifecycleOwner.current
val state = remember { mutableStateOf<R?>(map(state)) }

DisposableEffect(this, lifecycleOwner) {
val subscription = observe(lifecycleOwner) { browserState ->
state.value = map(browserState)
}
onDispose { subscription?.unsubscribe() }
}

return state
}

/**
* Starts observing this [Store] and represents the mapped state (using [map]) via [ComposeState].
*
* Everytime the [Store] state changes and the result of the [observe] function changes for this
* state, the returned [ComposeState] will be updated causing recomposition of every
* [ComposeState.value] usage.
*
* The [Store] observer will automatically be removed when this composable disposes or the current
* [LifecycleOwner] moves to the [Lifecycle.State.DESTROYED] state.
*/
@Composable
fun <S : State, A : Action, O, R> Store<S, A>.observeAsState(
observe: (S) -> O,
map: (S) -> R
): ComposeState<R?> {
val lifecycleOwner = LocalLifecycleOwner.current
var lastValue = observe(state)
val state = remember { mutableStateOf<R?>(map(state)) }

DisposableEffect(this, lifecycleOwner) {
val subscription = observe(lifecycleOwner) { browserState ->
val newValue = observe(browserState)
if (newValue != lastValue) {
state.value = map(browserState)
lastValue = newValue
}
}
onDispose { subscription?.unsubscribe() }
}

return state
}
Original file line number Diff line number Diff line change
Expand Up @@ -39,17 +39,19 @@ import mozilla.components.lib.state.Store
fun <S : State, A : Action> Store<S, A>.observe(
owner: LifecycleOwner,
observer: Observer<S>
) {
): Store.Subscription<S, A>? {
if (owner.lifecycle.currentState == Lifecycle.State.DESTROYED) {
// This owner is already destroyed. No need to register.
return
return null
}

val subscription = observeManually(observer)

subscription.binding = SubscriptionLifecycleBinding(owner, subscription).apply {
owner.lifecycle.addObserver(this)
}

return subscription
}

/**
Expand Down

0 comments on commit f00bf58

Please sign in to comment.