diff --git a/build.gradle.kts b/build.gradle.kts index d1637f9e3..3fbbec021 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -100,6 +100,8 @@ allprojects { "**/FilterList.kt", "**/Remove.kt", "**/Pets.kt", + "**/RetainedStateHolder*.kt", + "**/RetainedStateRestorationTester.kt", "**/SystemUiController.kt", ) } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt index 899353632..d54d5fe0e 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/CircuitContent.kt @@ -42,32 +42,7 @@ public fun CircuitContent( circuit.onUnavailableContent, key: Any? = screen, ) { - val navigator = - remember(onNavEvent) { - object : Navigator { - override fun goTo(screen: Screen) { - onNavEvent(NavEvent.GoTo(screen)) - } - - override fun resetRoot( - newRoot: Screen, - saveState: Boolean, - restoreState: Boolean, - ): ImmutableList { - onNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState)) - return persistentListOf() - } - - override fun pop(result: PopResult?): Screen? { - onNavEvent(NavEvent.Pop(result)) - return null - } - - override fun peek(): Screen = screen - - override fun peekBackStack(): ImmutableList = persistentListOf(screen) - } - } + val navigator = remember(onNavEvent) { Navigator.navEventNavigator(screen, onNavEvent) } CircuitContent(screen, navigator, modifier, circuit, unavailableContent, key) } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt index 5f39c0886..96a88ba15 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavEvent.kt @@ -20,6 +20,35 @@ public fun Navigator.onNavEvent(event: NavEvent) { } } +public fun Navigator.Companion.navEventNavigator( + screen: Screen, + onNavEvent: (event: NavEvent) -> Unit, +): Navigator { + return object : Navigator { + override fun goTo(screen: Screen) { + onNavEvent(NavEvent.GoTo(screen)) + } + + override fun resetRoot( + newRoot: Screen, + saveState: Boolean, + restoreState: Boolean, + ): List { + onNavEvent(NavEvent.ResetRoot(newRoot, saveState, restoreState)) + return emptyList() + } + + override fun pop(): Screen? { + onNavEvent(NavEvent.Pop) + return null + } + + override fun peek(): Screen = screen + + override fun peekBackStack(): List = listOf(screen) + } +} + /** A sealed navigation interface intended to be used when making a navigation callback. */ public sealed interface NavEvent : CircuitUiEvent { /** Corresponds to [Navigator.pop]. */ diff --git a/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml b/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml index ada8acbc5..73df06725 100644 --- a/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml +++ b/circuit-retained/src/androidInstrumentedTest/AndroidManifest.xml @@ -2,5 +2,6 @@ + \ No newline at end of file diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt new file mode 100644 index 000000000..51410093e --- /dev/null +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateHolderTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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. + */ +package com.slack.circuit.retained.android + +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.MutableState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import com.google.common.truth.Truth.assertThat +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateHolder +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder +import leakcanary.DetectLeaksAfterTestSuccess.Companion.detectLeaksAfterTestSuccessWrapping +import org.junit.Rule +import org.junit.Test +import org.junit.rules.RuleChain + +// TODO adapt for retained more +class RetainedStateHolderTest { + + private val composeTestRule = createAndroidComposeRule() + + @get:Rule + val rule = + RuleChain.emptyRuleChain().detectLeaksAfterTestSuccessWrapping(tag = "ActivitiesDestroyed") { + around(composeTestRule) + } + + private val restorationTester = RetainedStateRestorationTester(composeTestRule) + + @Test + fun stateIsRestoredWhenGoBackToScreen1() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var numberOnScreen1 = -1 + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(screen) { + if (screen == Screens.Screen1) { + numberOnScreen1 = remember { increment++ } + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(0) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + screen = Screens.Screen2 + } + + // wait for the screen switch to apply + composeTestRule.runOnIdle { + numberOnScreen1 = -1 + restorableNumberOnScreen1 = -1 + // switch back to screen1 + screen = Screens.Screen1 + } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(2) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + } + } + + @Test + fun simpleRestoreOnlyOneScreen() { + var increment = 0 + var number = -1 + var restorableNumber = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(Screens.Screen1) { + number = remember { increment++ } + restorableNumber = rememberRetained { increment++ } + } + } + + composeTestRule.runOnIdle { + assertThat(number).isEqualTo(0) + assertThat(restorableNumber).isEqualTo(1) + number = -1 + restorableNumber = -1 + } + + restorationTester.emulateRetainedInstanceStateRestore() + + composeTestRule.runOnIdle { + assertThat(number).isEqualTo(2) + assertThat(restorableNumber).isEqualTo(1) + } + } + + @Test + fun switchToScreen2AndRestore() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var numberOnScreen2 = -1 + var restorableNumberOnScreen2 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(screen) { + if (screen == Screens.Screen2) { + numberOnScreen2 = remember { increment++ } + restorableNumberOnScreen2 = rememberRetained { increment++ } + } + } + } + + composeTestRule.runOnIdle { screen = Screens.Screen2 } + + // wait for the screen switch to apply + composeTestRule.runOnIdle { + assertThat(numberOnScreen2).isEqualTo(0) + assertThat(restorableNumberOnScreen2).isEqualTo(1) + numberOnScreen2 = -1 + restorableNumberOnScreen2 = -1 + } + + restorationTester.emulateRetainedInstanceStateRestore() + + composeTestRule.runOnIdle { + assertThat(numberOnScreen2).isEqualTo(2) + assertThat(restorableNumberOnScreen2).isEqualTo(1) + } + } + + @Test + fun stateOfScreen1IsSavedAndRestoredWhileWeAreOnScreen2() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var numberOnScreen1 = -1 + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(screen) { + if (screen == Screens.Screen1) { + numberOnScreen1 = remember { increment++ } + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(0) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + screen = Screens.Screen2 + } + + // wait for the screen switch to apply + composeTestRule.runOnIdle { + numberOnScreen1 = -1 + restorableNumberOnScreen1 = -1 + } + + restorationTester.emulateRetainedInstanceStateRestore() + + // switch back to screen1 + composeTestRule.runOnIdle { screen = Screens.Screen1 } + + composeTestRule.runOnIdle { + assertThat(numberOnScreen1).isEqualTo(2) + assertThat(restorableNumberOnScreen1).isEqualTo(1) + } + } + + @Test + fun weCanSkipSavingForCurrentScreen() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var restorableStateHolder: RetainedStateHolder? = null + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + restorableStateHolder = holder + holder.RetainedStateProvider(screen) { + if (screen == Screens.Screen1) { + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(restorableNumberOnScreen1).isEqualTo(0) + restorableNumberOnScreen1 = -1 + restorableStateHolder!!.removeState(Screens.Screen1) + screen = Screens.Screen2 + } + + composeTestRule.runOnIdle { + // switch back to screen1 + screen = Screens.Screen1 + } + + composeTestRule.runOnIdle { assertThat(restorableNumberOnScreen1).isEqualTo(1) } + } + + @Test + fun weCanRemoveAlreadySavedState() { + var increment = 0 + var screen by mutableStateOf(Screens.Screen1) + var restorableStateHolder: RetainedStateHolder? = null + var restorableNumberOnScreen1 = -1 + restorationTester.setContent { + val holder = rememberRetainedStateHolder() + restorableStateHolder = holder + holder.RetainedStateProvider(screen) { + if (screen == Screens.Screen1) { + restorableNumberOnScreen1 = rememberRetained { increment++ } + } else { + // screen 2 + remember { 100 } + } + } + } + + composeTestRule.runOnIdle { + assertThat(restorableNumberOnScreen1).isEqualTo(0) + restorableNumberOnScreen1 = -1 + screen = Screens.Screen2 + } + + composeTestRule.runOnIdle { + // switch back to screen1 + restorableStateHolder!!.removeState(Screens.Screen1) + screen = Screens.Screen1 + } + + composeTestRule.runOnIdle { assertThat(restorableNumberOnScreen1).isEqualTo(1) } + } + + @Test + fun restoringStateOfThePreviousPageAfterCreatingBundle() { + var showFirstPage by mutableStateOf(true) + var firstPageState: MutableState? = null + + composeTestRule.setContent { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(showFirstPage) { + if (showFirstPage) { + firstPageState = rememberRetained { mutableStateOf(0) } + } + } + } + + composeTestRule.runOnIdle { + assertThat(firstPageState!!.value).isEqualTo(0) + // change the value, so we can assert this change will be restored + firstPageState!!.value = 1 + firstPageState = null + showFirstPage = false + } + + composeTestRule.runOnIdle { + composeTestRule.activity.doFakeSave() + showFirstPage = true + } + + composeTestRule.runOnIdle { assertThat(firstPageState!!.value).isEqualTo(1) } + } + + @Test + fun saveNothingWhenNoRememberRetainedIsUsedInternally() { + var showFirstPage by mutableStateOf(true) + val registry = RetainedStateRegistry(emptyMap()) + + composeTestRule.setContent { + CompositionLocalProvider(LocalRetainedStateRegistry provides registry) { + val holder = rememberRetainedStateHolder() + holder.RetainedStateProvider(showFirstPage) {} + } + } + + composeTestRule.runOnIdle { showFirstPage = false } + + composeTestRule.runOnIdle { + val savedData = registry.saveAll() + assertThat(savedData).isEqualTo(emptyMap>()) + } + } + + class Activity : ComponentActivity() { + fun doFakeSave() { + onSaveInstanceState(Bundle()) + } + } +} + +enum class Screens { + Screen1, + Screen2, +} diff --git a/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt new file mode 100644 index 000000000..203d0b04c --- /dev/null +++ b/circuit-retained/src/androidInstrumentedTest/kotlin/com/slack/circuit/retained/android/RetainedStateRestorationTester.kt @@ -0,0 +1,134 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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. + */ +package com.slack.circuit.retained.android + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.test.junit4.ComposeContentTestRule +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.RetainedValueProvider +import com.slack.circuit.retained.rememberRetained + +/** + * Helps to test the retained state restoration for your Composable component. + * + * Instead of calling [ComposeContentTestRule.setContent] you need to use [setContent] on this + * object, then change your state so there is some change to be restored, then execute + * [emulateRetainedInstanceStateRestore] and assert your state is restored properly. + * + * Note that this tests only the restoration of the local state of the composable you passed to + * [setContent] and useful for testing [rememberRetained] integration. It is not testing the + * integration with any other life cycles or Activity callbacks. + */ +// TODO recreate for more realism? Need to save the content function to do that, or call it after +// TODO make this available in test utils? +class RetainedStateRestorationTester(private val composeTestRule: ComposeContentTestRule) { + + private var registry: RestorationRegistry? = null + + /** + * This functions is a direct replacement for [ComposeContentTestRule.setContent] if you are going + * to use [emulateRetainedInstanceStateRestore] in the test. + * + * @see ComposeContentTestRule.setContent + */ + fun setContent(composable: @Composable () -> Unit) { + composeTestRule.setContent { + InjectRestorationRegistry { registry -> + this.registry = registry + composable() + } + } + } + + /** + * Saves all the state stored via [rememberRetained], disposes current composition, and composes + * again the content passed to [setContent]. Allows to test how your component behaves when the + * state restoration is happening. Note that the state stored via regular state() or remember() + * will be lost. + */ + fun emulateRetainedInstanceStateRestore() { + val registry = checkNotNull(registry) { "setContent should be called first!" } + composeTestRule.runOnIdle { registry.saveStateAndDisposeChildren() } + composeTestRule.runOnIdle { registry.emitChildrenWithRestoredState() } + composeTestRule.runOnIdle { + // we just wait for the children to be emitted + } + } + + @Composable + private fun InjectRestorationRegistry(content: @Composable (RestorationRegistry) -> Unit) { + val original = + requireNotNull(LocalRetainedStateRegistry.current) { + "StateRestorationTester requires composeTestRule.setContent() to provide " + + "a RetainedStateRegistry implementation via LocalRetainedStateRegistry" + } + val restorationRegistry = remember { RestorationRegistry(original) } + CompositionLocalProvider(LocalRetainedStateRegistry provides restorationRegistry) { + if (restorationRegistry.shouldEmitChildren) { + content(restorationRegistry) + } + } + } + + private class RestorationRegistry(private val original: RetainedStateRegistry) : + RetainedStateRegistry { + + var shouldEmitChildren by mutableStateOf(true) + private set + + private var currentRegistry: RetainedStateRegistry = original + private var savedMap: Map> = emptyMap() + + fun saveStateAndDisposeChildren() { + savedMap = currentRegistry.saveAll() + shouldEmitChildren = false + } + + fun emitChildrenWithRestoredState() { + currentRegistry = RetainedStateRegistry(values = savedMap) + shouldEmitChildren = true + } + + override fun consumeValue(key: String): Any? { + return currentRegistry.consumeValue(key) + } + + override fun registerValue( + key: String, + valueProvider: RetainedValueProvider, + ): RetainedStateRegistry.Entry { + return currentRegistry.registerValue(key, valueProvider) + } + + override fun saveAll(): Map> { + return currentRegistry.saveAll() + } + + override fun saveValue(key: String) { + currentRegistry.saveValue(key) + } + + override fun forgetUnclaimedValues() { + currentRegistry.forgetUnclaimedValues() + } + } +} diff --git a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt index d942617fb..8c50d39f3 100644 --- a/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt +++ b/circuit-retained/src/androidMain/kotlin/com/slack/circuit/retained/AndroidContinuity.kt @@ -27,9 +27,7 @@ internal class ContinuityViewModel : ViewModel(), RetainedStateRegistry { return delegate.registerValue(key, valueProvider) } - override fun saveAll() { - delegate.saveAll() - } + override fun saveAll() = delegate.saveAll() override fun saveValue(key: String) { delegate.saveValue(key) diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt new file mode 100644 index 000000000..9ded193fb --- /dev/null +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateHolder.kt @@ -0,0 +1,111 @@ +/* + * Copyright 2020 The Android Open Source Project + * + * 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. + */ +package com.slack.circuit.retained + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.ReusableContent +import androidx.compose.runtime.remember + +/** + * Allows to retain the state defined with [rememberRetained] for the subtree before disposing it to + * make it possible to compose it back next time with the restored state. It allows different + * navigation patterns to keep the ui state like scroll position for the currently not composed + * screens from the backstack. + * + * The content should be composed using [RetainedStateProvider] while providing a key representing + * this content. Next time [RetainedStateProvider] will be used with the same key its state will be + * restored. + */ +public interface RetainedStateHolder { + /** + * Put your content associated with a [key] inside the [content]. This will automatically retain + * all the states defined with [rememberRetained] before disposing the content and will restore + * the states when you compose with this key again. + * + * @param key to be used for saving and restoring the states for the subtree. Note that on Android + * you can only use types which can be stored inside the Bundle. + */ + @Composable public fun RetainedStateProvider(key: Any, content: @Composable () -> Unit) + + /** Removes the retained state associated with the passed [key]. */ + public fun removeState(key: Any) +} + +/** Creates and remembers the instance of [RetainedStateHolder]. */ +@Composable +public fun rememberRetainedStateHolder(): RetainedStateHolder = + rememberRetained { RetainedStateHolderImpl() } + .apply { parentRetainedStateRegistry = LocalRetainedStateRegistry.current } + +private class RetainedStateHolderImpl( + private val retainedStates: MutableMap>> = mutableMapOf() +) : RetainedStateHolder { + private val registryHolders = mutableMapOf() + var parentRetainedStateRegistry: RetainedStateRegistry? = null + + @Composable + override fun RetainedStateProvider(key: Any, content: @Composable () -> Unit) { + ReusableContent(key) { + val registryHolder = remember { RegistryHolder(key) } + CompositionLocalProvider( + LocalRetainedStateRegistry provides registryHolder.registry, + content = content, + ) + DisposableEffect(Unit) { + require(key !in registryHolders) { "Key $key was used multiple times " } + retainedStates -= key + registryHolders[key] = registryHolder + onDispose { + registryHolder.saveTo(retainedStates) + registryHolders -= key + } + } + } + } + + private fun saveAll(): MutableMap>>? { + val map = retainedStates.toMutableMap() + registryHolders.values.forEach { it.saveTo(map) } + return map.ifEmpty { null } + } + + override fun removeState(key: Any) { + val registryHolder = registryHolders[key] + if (registryHolder != null) { + registryHolder.shouldSave = false + } else { + retainedStates -= key + } + } + + inner class RegistryHolder(val key: Any) { + var shouldSave = true + val registry: RetainedStateRegistry = RetainedStateRegistry(retainedStates[key].orEmpty()) + + fun saveTo(map: MutableMap>>) { + if (shouldSave) { + val retainedData = registry.saveAll() + if (retainedData.isEmpty()) { + map -= key + } else { + map[key] = retainedData + } + } + } + } +} diff --git a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt index 2bbb9e462..1812a68ed 100644 --- a/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt +++ b/circuit-retained/src/commonMain/kotlin/com/slack/circuit/retained/RetainedStateRegistry.kt @@ -41,7 +41,7 @@ public interface RetainedStateRegistry { * Executes all the registered value providers and combines these values into a map. We have a * list of values for each key as it is allowed to have multiple providers for the same key. */ - public fun saveAll() + public fun saveAll(): Map> /** Executes the value providers registered with the given [key], and saves them for retrieval. */ public fun saveValue(key: String) @@ -109,7 +109,7 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } } - override fun saveAll() { + override fun saveAll(): Map> { val values = valueProviders.mapValues { (_, list) -> // If we have multiple providers we should store null values as well to preserve @@ -126,6 +126,7 @@ internal class RetainedStateRegistryImpl(retained: MutableMap } // Clear the value providers now that we've stored the values valueProviders.clear() + return values } override fun saveValue(key: String) { @@ -158,7 +159,7 @@ internal object NoOpRetainedStateRegistry : RetainedStateRegistry { override fun registerValue(key: String, valueProvider: RetainedValueProvider): Entry = NoOpEntry - override fun saveAll() {} + override fun saveAll(): Map> = emptyMap() override fun saveValue(key: String) {} diff --git a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt index 0b8525985..31546e9c7 100644 --- a/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt +++ b/circuit-runtime/src/commonMain/kotlin/com/slack/circuit/runtime/Navigator.kt @@ -96,6 +96,8 @@ public interface Navigator : GoToNavigator { restoreState: Boolean, ): ImmutableList = persistentListOf() } + + public companion object } /** diff --git a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt index d20da2482..59d88e2c0 100644 --- a/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt +++ b/samples/star/src/commonMain/kotlin/com/slack/circuit/star/home/HomeScreen.kt @@ -16,14 +16,20 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import com.slack.circuit.codegen.annotations.CircuitInject import com.slack.circuit.foundation.CircuitContent +import com.slack.circuit.foundation.LocalCircuit import com.slack.circuit.foundation.NavEvent +import com.slack.circuit.foundation.navEventNavigator import com.slack.circuit.foundation.onNavEvent +import com.slack.circuit.foundation.rememberPresenter +import com.slack.circuit.foundation.rememberUi import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.retained.rememberRetainedStateHolder import com.slack.circuit.runtime.CircuitUiEvent import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator @@ -69,6 +75,7 @@ fun HomePresenter(navigator: Navigator): HomeScreen.State { @Composable fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) { var contentComposed by rememberRetained { mutableStateOf(false) } + Scaffold( modifier = modifier.fillMaxWidth(), contentWindowInsets = WindowInsets(0, 0, 0, 0), @@ -81,13 +88,29 @@ fun HomeContent(state: HomeScreen.State, modifier: Modifier = Modifier) { } }, ) { paddingValues -> + val saveableStateHolder = rememberSaveableStateHolder() + val currentScreen = state.navItems[state.selectedIndex].screen + saveableStateHolder.SaveableStateProvider(currentScreen) { + val circuit = requireNotNull(LocalCircuit.current) + val ui = rememberUi(currentScreen, factory = circuit::ui) + val presenter = + rememberPresenter( + currentScreen, + navigator = + Navigator.navEventNavigator(currentScreen) { event -> + state.eventSink(ChildNav(event)) + }, + factory = circuit::presenter, + ) + + CircuitContent( + screen = currentScreen, + modifier = Modifier.padding(paddingValues), + presenter = presenter!!, + ui = ui!!, + ) + } contentComposed = true - val screen = state.navItems[state.selectedIndex].screen - CircuitContent( - screen, - modifier = Modifier.padding(paddingValues), - onNavEvent = { event -> state.eventSink(ChildNav(event)) }, - ) } Platform.ReportDrawnWhen { contentComposed } }