Skip to content

Commit

Permalink
Added discardSavedState and isStateSavingAllowed to retainedComponent
Browse files Browse the repository at this point in the history
  • Loading branch information
arkivanov committed Jan 10, 2024
1 parent fadc086 commit 6b8f21f
Show file tree
Hide file tree
Showing 6 changed files with 367 additions and 63 deletions.
8 changes: 4 additions & 4 deletions decompose/api/android/decompose.api
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ public abstract interface annotation class com/arkivanov/decompose/InternalDecom
}

public final class com/arkivanov/decompose/RetainedComponentKt {
public static final fun retainedComponent (Landroidx/activity/ComponentActivity;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static final fun retainedComponent (Landroidx/fragment/app/Fragment;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static synthetic fun retainedComponent$default (Landroidx/activity/ComponentActivity;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object;
public static synthetic fun retainedComponent$default (Landroidx/fragment/app/Fragment;Ljava/lang/String;ZLkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object;
public static final fun retainedComponent (Landroidx/activity/ComponentActivity;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static final fun retainedComponent (Landroidx/fragment/app/Fragment;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;)Ljava/lang/Object;
public static synthetic fun retainedComponent$default (Landroidx/activity/ComponentActivity;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object;
public static synthetic fun retainedComponent$default (Landroidx/fragment/app/Fragment;Ljava/lang/String;ZZLkotlin/jvm/functions/Function0;Lkotlin/jvm/functions/Function1;ILjava/lang/Object;)Ljava/lang/Object;
}

public final class com/arkivanov/decompose/UtilsKt {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.arkivanov.essenty.lifecycle.subscribe
import com.arkivanov.essenty.statekeeper.SerializableContainer
import com.arkivanov.essenty.statekeeper.StateKeeperDispatcher
import com.arkivanov.essenty.statekeeper.stateKeeper
import kotlinx.serialization.builtins.serializer

/**
* Returns (creating if needed) a component that is retained over configuration changes.
Expand All @@ -34,17 +35,26 @@ import com.arkivanov.essenty.statekeeper.stateKeeper
*
* @param key a key of the component, must be unique within the `Activity`.
* @param handleBackButton a flag that determines whether back button handling is enabled or not, default is `true`.
* @param discardSavedState a flag indicating whether any previously saved state should be discarded or not,
* default value is `false`. Can be useful for handling deep links in `onCreate`, so that the navigation state
* is not restored and initial state from the deep link is applied instead.
* @param isStateSavingAllowed called before saving the state. When `true` then the state will be saved,
* otherwise it won't. Default value is `true`.
* @param factory a function that returns a new instance of the component.
*/
@ExperimentalDecomposeApi
fun <T> ComponentActivity.retainedComponent(
key: String = "RootRetainedComponent",
handleBackButton: Boolean = true,
discardSavedState: Boolean = false,
isStateSavingAllowed: () -> Boolean = { true },
factory: (ComponentContext) -> T,
): T =
retainedComponent(
key = key,
onBackPressedDispatcher = if (handleBackButton) onBackPressedDispatcher else null,
discardSavedState = discardSavedState,
isStateSavingAllowed = isStateSavingAllowed,
isChangingConfigurations = ::isChangingConfigurations,
factory = factory,
)
Expand All @@ -57,30 +67,43 @@ fun <T> ComponentActivity.retainedComponent(
*
* @param key a key of the component, must be unique within the `Fragment`.
* @param handleBackButton a flag that determines whether back button handling is enabled or not, default is `true`.
* @param discardSavedState a flag indicating whether any previously saved state should be discarded or not,
* default value is `false`. Can be useful for handling deep links in `onCreate`, so that the navigation state
* is not restored and initial state from the deep link is applied instead.
* @param isStateSavingAllowed called before saving the state. When `true` then the state will be saved,
* otherwise it won't. Default value is `true`.
* @param factory a function that returns a new instance of the component.
*/
@ExperimentalDecomposeApi
fun <T> Fragment.retainedComponent(
key: String = "RootRetainedComponent",
handleBackButton: Boolean = true,
discardSavedState: Boolean = false,
isStateSavingAllowed: () -> Boolean = { true },
factory: (ComponentContext) -> T,
): T =
retainedComponent(
key = key,
onBackPressedDispatcher = if (handleBackButton) requireActivity().onBackPressedDispatcher else null,
discardSavedState = discardSavedState,
isStateSavingAllowed = isStateSavingAllowed,
isChangingConfigurations = { activity?.isChangingConfigurations ?: false },
factory = factory,
)

private fun <T, O> O.retainedComponent(
internal fun <T, O> O.retainedComponent(
key: String,
onBackPressedDispatcher: OnBackPressedDispatcher?,
discardSavedState: Boolean,
isStateSavingAllowed: () -> Boolean,
isChangingConfigurations: () -> Boolean,
factory: (ComponentContext) -> T,
): T where O : LifecycleOwner, O : SavedStateRegistryOwner, O : ViewModelStoreOwner {
val lifecycle = essentyLifecycle()
val stateKeeper = stateKeeper()
val instanceKeeper = instanceKeeper()
val stateKeeper = stateKeeper(discardSavedState = discardSavedState, isSavingAllowed = isStateSavingAllowed)
val marker = stateKeeper.consume(key = KEY_STATE_MARKER, strategy = String.serializer())
stateKeeper.register(key = KEY_STATE_MARKER, strategy = String.serializer()) { "marker" }
val instanceKeeper = instanceKeeper(discardRetainedInstances = marker == null)

check(!stateKeeper.isRegistered(key = key)) { "Another retained component is already registered with the key: $key" }

Expand Down Expand Up @@ -124,6 +147,8 @@ private fun <T, O> O.retainedComponent(
return holder.component
}

private const val KEY_STATE_MARKER = "RetainedComponent_state_marker"

private class DelegateOnBackPressedCallback(
private val dispatcher: OnBackPressedDispatcher,
) : OnBackPressedCallback(enabled = dispatcher.hasEnabledCallbacks()) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.arkivanov.decompose

import com.arkivanov.essenty.lifecycle.Lifecycle
import com.arkivanov.essenty.lifecycle.subscribe

fun Lifecycle.logEvents(): MutableList<String> =
ArrayList<String>().apply {
subscribe(
onCreate = { add("onCreate") },
onStart = { add("onStart") },
onResume = { add("onResume") },
onPause = { add("onPause") },
onStop = { add("onStop") },
onDestroy = { add("onDestroy") },
)
}
Original file line number Diff line number Diff line change
@@ -1,23 +1,12 @@
package com.arkivanov.decompose

import android.os.Bundle
import android.os.Parcel
import androidx.activity.OnBackPressedDispatcher
import androidx.activity.OnBackPressedDispatcherOwner
import androidx.lifecycle.Lifecycle
import androidx.lifecycle.LifecycleOwner
import androidx.lifecycle.LifecycleRegistry
import androidx.lifecycle.ViewModelStore
import androidx.lifecycle.ViewModelStoreOwner
import androidx.savedstate.SavedStateRegistry
import androidx.savedstate.SavedStateRegistryController
import androidx.savedstate.SavedStateRegistryOwner
import com.arkivanov.decompose.router.TestInstance
import com.arkivanov.essenty.backhandler.BackCallback
import com.arkivanov.essenty.instancekeeper.getOrCreate
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import kotlin.test.Test
import kotlin.test.assertContentEquals
import kotlin.test.assertEquals
import kotlin.test.assertFalse
import kotlin.test.assertNotSame
Expand All @@ -30,12 +19,59 @@ import kotlin.test.assertTrue
class DefaultComponentContextBuilderTest {

@Test
fun saves_and_restores_state() {
fun WHEN_created_THEN_lifecycle_resumed() {
val owner = TestOwner()
val ctx = owner.defaultComponentContext()
val events = ctx.lifecycle.logEvents()

assertContentEquals(listOf("onCreate", "onStart", "onResume"), events)
}

@Test
fun WHEN_recreated_THEN_old_lifecycle_destroyed() {
var owner = TestOwner()
val ctx = owner.defaultComponentContext()
val events = ctx.lifecycle.logEvents()
events.clear()

owner = owner.recreate(isChangingConfigurations = false)
owner.defaultComponentContext()

assertContentEquals(listOf("onPause", "onStop", "onDestroy"), events)
}

@Test
fun WHEN_recreated_THEN_new_lifecycle_resumed() {
var owner = TestOwner()
owner.defaultComponentContext()

owner = owner.recreate(isChangingConfigurations = false)
val ctx = owner.defaultComponentContext()
val events = ctx.lifecycle.logEvents()

assertContentEquals(listOf("onCreate", "onStart", "onResume"), events)
}

@Test
fun WHEN_recreated_THEN_saves_and_restores_state() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
ctx.stateKeeper.register(key = "key") { "saved_state" }

owner = owner.recreate()
owner = owner.recreate(isChangingConfigurations = false)
ctx = owner.defaultComponentContext()
val restoredState = ctx.stateKeeper.consume<String>(key = "key")

assertEquals("saved_state", restoredState)
}

@Test
fun WHEN_configuration_changed_THEN_saves_and_restores_state() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
ctx.stateKeeper.register(key = "key") { "saved_state" }

owner = owner.recreate(isChangingConfigurations = true)
ctx = owner.defaultComponentContext()
val restoredState = ctx.stateKeeper.consume<String>(key = "key")

Expand All @@ -56,7 +92,7 @@ class DefaultComponentContextBuilderTest {
}

@Test
fun GIVEN_isStateSavingAllowed_is_false_on_save_THEN_state_not_saved() {
fun GIVEN_isStateSavingAllowed_is_false_on_save_WHEN_configuration_changed_THEN_state_not_saved() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
ctx.stateKeeper.register(key = "key") { "saved_state" }
Expand All @@ -69,7 +105,7 @@ class DefaultComponentContextBuilderTest {
}

@Test
fun GIVEN_isStateSavingAllowed_is_false_on_save_THEN_instances_not_retained() {
fun GIVEN_isStateSavingAllowed_is_false_on_save_WHEN_configuration_changed_THEN_instances_not_retained() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)
Expand All @@ -82,9 +118,9 @@ class DefaultComponentContextBuilderTest {
}

@Test
fun GIVEN_isStateSavingAllowed_is_false_on_save_THEN_old_instances_destroyed() {
fun GIVEN_isStateSavingAllowed_is_false_on_save_WHEN_configuration_changed_THEN_old_instances_destroyed() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
val ctx = owner.defaultComponentContext(isStateSavingAllowed = { false })
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)

owner = owner.recreate()
Expand All @@ -94,7 +130,7 @@ class DefaultComponentContextBuilderTest {
}

@Test
fun GIVEN_discardSavedState_is_true_on_restore_THEN_discards_saved_state() {
fun WHEN_configuration_changed_and_discardSavedState_is_true_on_restore_THEN_discards_saved_state() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
ctx.stateKeeper.register(key = "key") { "saved_state" }
Expand All @@ -107,7 +143,7 @@ class DefaultComponentContextBuilderTest {
}

@Test
fun GIVEN_discardSavedState_is_true_on_restore_THEN_instances_not_retained() {
fun WHEN_configuration_changed_and_discardSavedState_is_true_on_restore_THEN_instances_not_retained() {
var owner = TestOwner()
var ctx = owner.defaultComponentContext()
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)
Expand All @@ -120,7 +156,7 @@ class DefaultComponentContextBuilderTest {
}

@Test
fun GIVEN_discardSavedState_is_true_on_restore_THEN_old_instances_destroyed() {
fun WHEN_configuration_changed_and_discardSavedState_is_true_on_restore_THEN_old_instances_destroyed() {
var owner = TestOwner()
val ctx = owner.defaultComponentContext()
val instance1 = ctx.instanceKeeper.getOrCreate(key = "key", factory = ::TestInstance)
Expand Down Expand Up @@ -154,39 +190,4 @@ class DefaultComponentContextBuilderTest {

assertFalse(isCalled)
}

private class TestOwner(
savedState: Bundle = Bundle(),
override val viewModelStore: ViewModelStore = ViewModelStore(),
) : LifecycleOwner, SavedStateRegistryOwner, ViewModelStoreOwner, OnBackPressedDispatcherOwner {
private val savedStateRegistryController: SavedStateRegistryController = SavedStateRegistryController.create(this)
override val lifecycle: Lifecycle = LifecycleRegistry(this)
override val savedStateRegistry: SavedStateRegistry get() = savedStateRegistryController.savedStateRegistry
override val onBackPressedDispatcher: OnBackPressedDispatcher = OnBackPressedDispatcher()

init {
savedStateRegistryController.performRestore(savedState)
}

fun recreate(): TestOwner {
val bundle = Bundle()
savedStateRegistryController.performSave(bundle)

return TestOwner(savedState = bundle.parcelize().deparcelize(), viewModelStore = viewModelStore)
}

private fun Bundle.parcelize(): ByteArray {
val parcel = Parcel.obtain()
parcel.writeBundle(this)
return parcel.marshall()
}

private fun ByteArray.deparcelize(): Bundle {
val parcel = Parcel.obtain()
parcel.unmarshall(this, 0, size)
parcel.setDataPosition(0)

return requireNotNull(parcel.readBundle())
}
}
}
Loading

0 comments on commit 6b8f21f

Please sign in to comment.