From bb09273ae5e26c4b33aefb7a5b521ee0255f1b43 Mon Sep 17 00:00:00 2001 From: Chris Banes Date: Sun, 26 May 2024 20:05:50 +0100 Subject: [PATCH] Add lifecycle aware Presenters (#1282) This PR adds a new `Lifecycle` interface, which presenters and UI can observe to know when they are 'paused'. The API is rudimentary at the moment and will change before this is ready to land. A bundled `PauseablePresenter` class can be extended, enabling clients to automatically add pausing ability to their presenters. This impl will simply return the last emitted `UiState` when the presenter is paused. Again, name TBD. ### Other things: - `GestureNavigationRetainedStateTest` and `GestureNavigationSaveableStateTest` have been combined into a parameterized `GestureNavigationStateTest`. This new test also now tests `CupertinoGestureNavigationDecoration` so we get extra coverage. - Changed `rememberRetained`'s `key` param to `Any` to be consistent with all of the other `remember` functions. ### TODO: - [x] Saveable is currently broken by these changes. This needs to be fixed before landing. - [x] Remove all of the logging code. - [x] Add comments - [x] Fix the last remaining failing test --- CHANGELOG.md | 2 + ...vigableCircuitViewModelStateAndroidTest.kt | 131 ++++++++++-------- .../circuit/foundation/CircuitContent.kt | 6 +- .../foundation/NavigableCircuitContent.kt | 88 ++++++------ .../slack/circuit/foundation/PausableState.kt | 75 ++++++++++ .../circuit/foundation/RecordLifecycle.kt | 44 ++++++ .../foundation/KeyedCircuitContentTests.kt | 101 +++++++------- ...roidPredictiveBackNavigationDecoration.kt} | 10 +- .../GestureNavigationSaveableStateTest.kt | 129 ----------------- ...eTest.kt => GestureNavigationStateTest.kt} | 99 +++++++++---- .../slack/circuitx/gesturenavigation/Utils.kt | 14 +- .../gesturenavigation/TransitionUtils.kt | 3 + 12 files changed, 392 insertions(+), 310 deletions(-) create mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt create mode 100644 circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt rename circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/{GestureNavigationDecoration.kt => AndroidPredictiveBackNavigationDecoration.kt} (95%) delete mode 100644 circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationSaveableStateTest.kt rename circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/{GestureNavigationRetainedStateTest.kt => GestureNavigationStateTest.kt} (64%) diff --git a/CHANGELOG.md b/CHANGELOG.md index 318de6849..a5a09ce88 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,8 @@ Changelog - **New**: Add `FakeNavigator` functions to check for the lack of pop/resetRoot events. - **New**: Add `FakeNavigator` constructor param to add additional screens to the backstack. - **New**: Add support for static UIs. In some cases, a UI may not need a presenter to compute or manage its state. Examples of this include UIs that are stateless or can derive their state from a single static input or an input [Screen]'s properties. In these cases, make your _screen_ implement the `StaticScreen` interface. When a `StaticScreen` is used, Circuit will internally allow the UI to run on its own and won't connect it to a presenter if no presenter is provided. +- **New**: Add `RecordLifecycle` and `LocalRecordLifecycle` composition local, allowing UIs and presenters to observe when they are 'active'. Currently, a record is 'active' when it is the top record on the back stack. +- **Behaviour Change**: Presenters are now 'paused' and replay their last emitted `CircuitUiState` when they are not active. Presenters can opt-out of this behavior by implementing `NonPausablePresenter`. - **Behaviour Change**: `NavigatorImpl.goTo` no longer navigates if the `Screen` is equal to `Navigator.peek()`. - **Behaviour Change**: `Presenter.present` is now annotated with `@ComposableTarget("presenter")`. This helps prevent use of Compose UI in the presentation logic as the compiler will emit a warning if you do. Note this does not appear in the IDE, so it's recommended to use `allWarningsAsErrors` to fail the build on this event. - **Change**: `Navigator.goTo` now returns a Bool indicating navigation success. diff --git a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateAndroidTest.kt b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateAndroidTest.kt index a36c3f163..b2d188d48 100644 --- a/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateAndroidTest.kt +++ b/circuit-foundation/src/androidUnitTest/kotlin/com/slack/circuit/foundation/NavigableCircuitViewModelStateAndroidTest.kt @@ -2,6 +2,7 @@ // SPDX-License-Identifier: Apache-2.0 package com.slack.circuit.foundation +import androidx.compose.ui.test.MainTestClock import androidx.compose.ui.test.SemanticsMatcher import androidx.compose.ui.test.assertAll import androidx.compose.ui.test.assertAny @@ -36,13 +37,12 @@ class NavigableCircuitViewModelStateAndroidTest { @Test fun retainedStateScopedToBackstackWithRecreations() { composeTestRule.run { - mainClock.autoAdvance = false + mainClock.autoAdvance = true // Current: Screen A. Increase count to 1 onNodeWithTag(TAG_LABEL).assertTextEquals("A") onNodeWithTag(TAG_COUNT).assertTextEquals("0") onNodeWithTag(TAG_INCREASE_COUNT).performClick() - mainClock.advanceTimeByFrame() onNodeWithTag(TAG_COUNT).assertTextEquals("1") // Now recreate the Activity and assert that the values were retained @@ -52,11 +52,9 @@ class NavigableCircuitViewModelStateAndroidTest { // Navigate to Screen B. Increase count to 1 onNodeWithTag(TAG_GO_NEXT).performClick() - mainClock.advanceTimeBy(1_000) onNodeWithTag(TAG_LABEL).assertTextEquals("B") onNodeWithTag(TAG_COUNT).assertTextEquals("0") onNodeWithTag(TAG_INCREASE_COUNT).performClick() - mainClock.advanceTimeByFrame() onNodeWithTag(TAG_COUNT).assertTextEquals("1") // Now recreate the Activity and assert that the values were retained @@ -66,11 +64,9 @@ class NavigableCircuitViewModelStateAndroidTest { // Navigate to Screen C. Increase count to 1 onNodeWithTag(TAG_GO_NEXT).performClick() - mainClock.advanceTimeBy(1_000) onNodeWithTag(TAG_LABEL).assertTextEquals("C") onNodeWithTag(TAG_COUNT).assertTextEquals("0") onNodeWithTag(TAG_INCREASE_COUNT).performClick() - mainClock.advanceTimeByFrame() onNodeWithTag(TAG_COUNT).assertTextEquals("1") // Now recreate the Activity and assert that the values were retained @@ -78,37 +74,43 @@ class NavigableCircuitViewModelStateAndroidTest { onNodeWithTag(TAG_LABEL).assertTextEquals("C") onNodeWithTag(TAG_COUNT).assertTextEquals("1") - // Pop to Screen B. Increase count from 1 to 2. - onNodeWithTag(TAG_POP).performClick() - - // Part-way through pop, both screens should be visible - onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { - onAllNodesWithTag(TAG_LABEL) - .assertCountEquals(2) - .assertAny(hasTextExactly("C")) - .assertAny(hasTextExactly("B")) - onAllNodesWithTag(TAG_COUNT).assertCountEquals(2).assertAll(hasTextExactly("1")) + mainClock.withAutoAdvance(false) { + // Pop to Screen B + onNodeWithTag(TAG_POP).performClick() + + // Part-way through pop, both screens should be visible + onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { + onAllNodesWithTag(TAG_LABEL) + .assertCountEquals(2) + .assertAny(hasTextExactly("C")) + .assertAny(hasTextExactly("B")) + onAllNodesWithTag(TAG_COUNT).assertCountEquals(2).assertAll(hasTextExactly("1")) + } } + + // Increase count from 1 to 2. onNodeWithTag(TAG_LABEL).assertTextEquals("B") onNodeWithTag(TAG_COUNT).assertTextEquals("1") onNodeWithTag(TAG_INCREASE_COUNT).performClick() - mainClock.advanceTimeByFrame() onNodeWithTag(TAG_COUNT).assertTextEquals("2") - // Navigate to Screen C. Assert that it's state was not retained - onNodeWithTag(TAG_GO_NEXT).performClick() - - // Part-way through push, both screens should be visible - onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { - onAllNodesWithTag(TAG_LABEL) - .assertCountEquals(2) - .assertAny(hasTextExactly("C")) - .assertAny(hasTextExactly("B")) - onAllNodesWithTag(TAG_COUNT) - .assertCountEquals(2) - .assertAny(hasTextExactly("0")) - .assertAny(hasTextExactly("2")) + mainClock.withAutoAdvance(false) { + // Navigate to Screen C + onNodeWithTag(TAG_GO_NEXT).performClick() + + // Part-way through push, both screens should be visible + onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { + onAllNodesWithTag(TAG_LABEL) + .assertCountEquals(2) + .assertAny(hasTextExactly("C")) + .assertAny(hasTextExactly("B")) + onAllNodesWithTag(TAG_COUNT) + .assertCountEquals(2) + .assertAny(hasTextExactly("0")) + .assertAny(hasTextExactly("2")) + } } + // Assert that Screen C's state was retained onNodeWithTag(TAG_LABEL).assertTextEquals("C") onNodeWithTag(TAG_COUNT).assertTextEquals("0") @@ -117,20 +119,23 @@ class NavigableCircuitViewModelStateAndroidTest { onNodeWithTag(TAG_LABEL).assertTextEquals("C") onNodeWithTag(TAG_COUNT).assertTextEquals("0") - // Pop to Screen B. Assert that it's state was retained - onNodeWithTag(TAG_POP).performClick() - - // Part-way through pop, both screens should be visible - onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { - onAllNodesWithTag(TAG_LABEL) - .assertCountEquals(2) - .assertAny(hasTextExactly("C")) - .assertAny(hasTextExactly("B")) - onAllNodesWithTag(TAG_COUNT) - .assertCountEquals(2) - .assertAny(hasTextExactly("0")) - .assertAny(hasTextExactly("2")) + mainClock.withAutoAdvance(false) { + // Pop to Screen B + onNodeWithTag(TAG_POP).performClick() + + // Part-way through pop, both screens should be visible + onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { + onAllNodesWithTag(TAG_LABEL) + .assertCountEquals(2) + .assertAny(hasTextExactly("C")) + .assertAny(hasTextExactly("B")) + onAllNodesWithTag(TAG_COUNT) + .assertCountEquals(2) + .assertAny(hasTextExactly("0")) + .assertAny(hasTextExactly("2")) + } } + // Assert that Screen B's state was retained onNodeWithTag(TAG_LABEL).assertTextEquals("B") onNodeWithTag(TAG_COUNT).assertTextEquals("2") @@ -139,20 +144,23 @@ class NavigableCircuitViewModelStateAndroidTest { onNodeWithTag(TAG_LABEL).assertTextEquals("B") onNodeWithTag(TAG_COUNT).assertTextEquals("2") - // Pop to Screen A. Assert that it's state was retained - onNodeWithTag(TAG_POP).performClick() - - // Part-way through pop, both screens should be visible - onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { - onAllNodesWithTag(TAG_LABEL) - .assertCountEquals(2) - .assertAny(hasTextExactly("B")) - .assertAny(hasTextExactly("A")) - onAllNodesWithTag(TAG_COUNT) - .assertCountEquals(2) - .assertAny(hasTextExactly("2")) - .assertAny(hasTextExactly("1")) + mainClock.withAutoAdvance(false) { + // Pop to Screen A + onNodeWithTag(TAG_POP).performClick() + + // Part-way through pop, both screens should be visible + onEachFrameWhileMultipleScreens(hasTestTag(TAG_LABEL)) { + onAllNodesWithTag(TAG_LABEL) + .assertCountEquals(2) + .assertAny(hasTextExactly("B")) + .assertAny(hasTextExactly("A")) + onAllNodesWithTag(TAG_COUNT) + .assertCountEquals(2) + .assertAny(hasTextExactly("2")) + .assertAny(hasTextExactly("1")) + } } + // Assert that Screen B's state was retained onNodeWithTag(TAG_LABEL).assertTextEquals("A") onNodeWithTag(TAG_COUNT).assertTextEquals("1") @@ -163,7 +171,6 @@ class NavigableCircuitViewModelStateAndroidTest { // Navigate to Screen B. Assert that it's state was not retained onNodeWithTag(TAG_GO_NEXT).performClick() - mainClock.advanceTimeBy(1_000) onNodeWithTag(TAG_LABEL).assertTextEquals("B") onNodeWithTag(TAG_COUNT).assertTextEquals("0") } @@ -188,3 +195,13 @@ class NavigableCircuitViewModelStateAndroidTest { } } } + +private fun MainTestClock.withAutoAdvance(value: Boolean, block: () -> Unit) { + val currentAutoAdvance = this.autoAdvance + try { + this.autoAdvance = value + block() + } finally { + this.autoAdvance = currentAutoAdvance + } +} 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 24422a6dc..921383151 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 @@ -147,7 +147,11 @@ public fun CircuitContent( onDispose(eventListener::onDisposePresent) } - val state = presenter.present() + val state = + when (presenter) { + is NonPausablePresenter -> presenter.present() + else -> presenter.presentWithLifecycle() + } // TODO not sure why stateFlow + LaunchedEffect + distinctUntilChanged doesn't work here SideEffect { eventListener.onState(state) } diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt index 11bdc0c68..12c7b9fef 100644 --- a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/NavigableCircuitContent.kt @@ -58,7 +58,8 @@ public fun NavigableCircuitContent( circuit.onUnavailableContent, ) { val activeContentProviders = - backStack.buildCircuitContentProviders( + buildCircuitContentProviders( + backStack = backStack, navigator = navigator, circuit = circuit, unavailableRoute = unavailableRoute, @@ -103,33 +104,15 @@ public fun NavigableCircuitContent( CompositionLocalProvider(LocalRetainedStateRegistry provides outerRegistry) { decoration.DecoratedContent(activeContentProviders, backStack.size, modifier) { provider -> - // We retain the record's retained state registry for as long as the back stack - // contains the record val record = provider.record - val recordInBackStackRetainChecker = - remember(backStack, record) { - CanRetainChecker { backStack.containsRecord(record, includeSaved = true) } - } - CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { - // Remember the `providedValues` lookup because this composition can live longer than - // the record is present in the backstack, if the decoration is animated for example. - val values = remember(record) { providedValues[record] }?.provideValues() - val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } + // Remember the `providedValues` lookup because this composition can live longer than + // the record is present in the backstack, if the decoration is animated for example. + val values = remember(record) { providedValues[record] }?.provideValues() + val providedLocals = remember(values) { values?.toTypedArray() ?: emptyArray() } - // Now provide a new registry to the content for it to store any retained state in, - // along with a retain checker which is always true (as upstream registries will - // maintain the lifetime), and the other provided values - val recordRetainedStateRegistry = - rememberRetained(key = record.registryKey) { RetainedStateRegistry() } - CompositionLocalProvider( - LocalRetainedStateRegistry provides recordRetainedStateRegistry, - LocalCanRetainChecker provides CanRetainChecker.Always, - LocalBackStack provides backStack, - *providedLocals, - ) { - provider.content(record) - } + CompositionLocalProvider(LocalBackStack provides backStack, *providedLocals) { + provider.content(record) } } } @@ -163,37 +146,60 @@ public class RecordContentProvider( } @Composable -private fun BackStack.buildCircuitContentProviders( +private fun buildCircuitContentProviders( + backStack: BackStack, navigator: Navigator, circuit: Circuit, unavailableRoute: @Composable (screen: Screen, modifier: Modifier) -> Unit, ): ImmutableList> { val previousContentProviders = remember { mutableMapOf>() } + val lastBackStack by rememberUpdatedState(backStack) val lastNavigator by rememberUpdatedState(navigator) val lastCircuit by rememberUpdatedState(circuit) val lastUnavailableRoute by rememberUpdatedState(unavailableRoute) - return iterator() + fun createRecordContent() = + movableContentOf { record -> + val recordInBackStackRetainChecker = + remember(lastBackStack, record) { + CanRetainChecker { lastBackStack.containsRecord(record, includeSaved = true) } + } + + val lifecycle = + remember { MutableRecordLifecycle() }.apply { isActive = lastBackStack.topRecord == record } + + CompositionLocalProvider(LocalCanRetainChecker provides recordInBackStackRetainChecker) { + // Now provide a new registry to the content for it to store any retained state in, + // along with a retain checker which is always true (as upstream registries will + // maintain the lifetime), and the other provided values + val recordRetainedStateRegistry = + rememberRetained(key = record.registryKey) { RetainedStateRegistry() } + + CompositionLocalProvider( + LocalRetainedStateRegistry provides recordRetainedStateRegistry, + LocalCanRetainChecker provides CanRetainChecker.Always, + LocalRecordLifecycle provides lifecycle, + ) { + CircuitContent( + screen = record.screen, + navigator = lastNavigator, + circuit = lastCircuit, + unavailableContent = lastUnavailableRoute, + key = record.key, + ) + } + } + } + + return lastBackStack + .iterator() .asSequence() .map { record -> // Query the previous content providers map, so that we use the same // RecordContentProvider instances across calls. previousContentProviders.getOrPut(record.key) { - RecordContentProvider( - record = record, - content = - movableContentOf { record -> - CircuitContent( - screen = record.screen, - modifier = Modifier, - navigator = lastNavigator, - circuit = lastCircuit, - unavailableContent = lastUnavailableRoute, - key = record.key, - ) - }, - ) + RecordContentProvider(record = record, content = createRecordContent()) } } .toImmutableList() diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt new file mode 100644 index 000000000..d9e018e4f --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/PausableState.kt @@ -0,0 +1,75 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +@file:Suppress("NOTHING_TO_INLINE") + +package com.slack.circuit.foundation + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveableStateHolder +import androidx.compose.runtime.setValue +import com.slack.circuit.retained.LocalRetainedStateRegistry +import com.slack.circuit.retained.RetainedStateRegistry +import com.slack.circuit.retained.rememberRetained +import com.slack.circuit.runtime.CircuitUiState +import com.slack.circuit.runtime.presenter.Presenter + +/** + * By default [CircuitContent] will wrap presenters so that the last emitted [CircuitUiState] is + * replayed when the presenter is paused. If this behavior is not wanted, the [Presenter] should + * implement this interface to disable the behavior. + */ +@Stable public interface NonPausablePresenter : Presenter + +/** + * Presents the UI state when the lifecycle is resumed, otherwise will replay the last emitted UI + * state. + * + * The [CircuitUiState] class returned by the [Presenter] is required to implement [equals] and + * [hashCode] methods correctly, otherwise this function can create composition loops. + * + * @param key A unique key for the pausable state + * @param isActive Whether the presenter is active or not. + */ +@Composable +public inline fun Presenter.presentWithLifecycle( + key: String? = null, + isActive: Boolean = LocalRecordLifecycle.current.isActive, +): UiState = pausableState(key, isActive) { present() } + +/** + * Wraps a composable state producer, which will replay the last emitted state instance when + * [isActive] is `false`. + * + * The class [T] returned from [produceState] is required to implement [equals] and [hashCode] + * methods correctly, otherwise this function can create composition loops. + * + * @param key A unique key for the pausable state. + * @param isActive Whether the state producer should be active or not. + * @param produceState A composable lambda function which produces the state + */ +@Composable +public fun pausableState( + key: String? = null, + isActive: Boolean = LocalRecordLifecycle.current.isActive, + produceState: @Composable () -> T, +): T { + var state: T? by remember(key) { mutableStateOf(null) } + + val saveableStateHolder = rememberSaveableStateHolder() + + if (isActive || state == null) { + val retainedStateRegistry = rememberRetained(key = key) { RetainedStateRegistry() } + CompositionLocalProvider(LocalRetainedStateRegistry provides retainedStateRegistry) { + saveableStateHolder.SaveableStateProvider(key = key ?: "pausable_state") { + state = produceState() + } + } + } + + return state!! +} diff --git a/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt new file mode 100644 index 000000000..064fa3662 --- /dev/null +++ b/circuit-foundation/src/commonMain/kotlin/com/slack/circuit/foundation/RecordLifecycle.kt @@ -0,0 +1,44 @@ +// Copyright (C) 2024 Slack Technologies, LLC +// SPDX-License-Identifier: Apache-2.0 +package com.slack.circuit.foundation + +import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.compose.runtime.staticCompositionLocalOf + +/** + * Represents the lifecycle of a navigation record in a [NavigableCircuitContent]. Will typically be + * stored in the [LocalRecordLifecycle] composition local, allowing presenters and UIs to observe + * whether the navigation record is active. + */ +@Stable +public interface RecordLifecycle { + /** + * Whether the record is currently active. Typically this will return true when the record is the + * top record in the back stack. + */ + public val isActive: Boolean +} + +internal class MutableRecordLifecycle(initial: Boolean = false) : RecordLifecycle { + override var isActive: Boolean by mutableStateOf(initial) +} + +/** + * Holds the current lifecycle for a record in a [NavigableCircuitContent]. + * + * For static [CircuitContent]s used outside of [NavigableCircuitContent], the default value will be + * a [RecordLifecycle] which always returned active. + */ +public val LocalRecordLifecycle: ProvidableCompositionLocal = + staticCompositionLocalOf { + staticRecordLifecycle(true) + } + +private fun staticRecordLifecycle(isActive: Boolean): RecordLifecycle = + object : RecordLifecycle { + override val isActive: Boolean = isActive + } diff --git a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt index ed3aaf711..1386606fb 100644 --- a/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt +++ b/circuit-foundation/src/jvmTest/kotlin/com/slack/circuit/foundation/KeyedCircuitContentTests.kt @@ -8,6 +8,7 @@ import androidx.compose.runtime.Composable 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.Modifier import androidx.compose.ui.platform.testTag @@ -16,7 +17,6 @@ import androidx.compose.ui.test.junit4.ComposeContentTestRule import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithTag import com.slack.circuit.backstack.rememberSaveableBackStack -import com.slack.circuit.retained.rememberRetained import com.slack.circuit.runtime.CircuitUiState import com.slack.circuit.runtime.Navigator import com.slack.circuit.runtime.presenter.Presenter @@ -25,8 +25,8 @@ import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith -private const val TAG_UI_RETAINED = "TAG_UI_RETAINED" -private const val TAG_PRESENTER_RETAINED = "TAG_PRESENTER_RETAINED" +private const val TAG_UI_REMEMBERED = "TAG_UI_REMEMBERED" +private const val TAG_PRESENTER_REMEMBERED = "TAG_PRESENTER_REMEMBERED" private const val TAG_STATE = "TAG_STATE" /** @@ -62,33 +62,33 @@ class KeyedCircuitContentTests { val navigator = setUpTestOneContent() // Initial onNodeWithTag(TAG_STATE).assertTextEquals("1") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("1") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("1") // Push a new ScreenA navigator.goTo(ScreenA(2)) onNodeWithTag(TAG_STATE).assertTextEquals("4") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("4") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("4") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("4") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("4") // Push a new ScreenA navigator.goTo(ScreenA(3)) onNodeWithTag(TAG_STATE).assertTextEquals("9") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("9") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("9") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("9") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("9") // Push a new ScreenB navigator.goTo(ScreenB("abc")) onNodeWithTag(TAG_STATE).assertTextEquals("cba") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("cba") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("cba") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("cba") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("cba") // Back one navigator.pop() onNodeWithTag(TAG_STATE).assertTextEquals("9") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("9") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("9") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("9") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("9") // Back two navigator.pop() onNodeWithTag(TAG_STATE).assertTextEquals("4") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("4") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("4") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("4") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("4") } } @@ -98,33 +98,33 @@ class KeyedCircuitContentTests { var screenState by setUpTestTwoAContent() // Initial onNodeWithTag(TAG_STATE).assertTextEquals("1") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("1") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("1") // Set a new ScreenA screenState = ScreenA(2) onNodeWithTag(TAG_STATE).assertTextEquals("4") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("4") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("4") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("4") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("4") // Set a new ScreenA screenState = ScreenA(3) onNodeWithTag(TAG_STATE).assertTextEquals("9") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("9") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("9") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("9") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("9") // Set a new ScreenB screenState = ScreenB("abc") onNodeWithTag(TAG_STATE).assertTextEquals("cba") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("cba") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("cba") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("cba") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("cba") // Back to a ScreenA screenState = ScreenA(3) onNodeWithTag(TAG_STATE).assertTextEquals("9") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("9") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("9") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("9") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("9") // Back to another ScreenA screenState = ScreenA(2) onNodeWithTag(TAG_STATE).assertTextEquals("4") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("4") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("4") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("4") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("4") } } @@ -134,33 +134,33 @@ class KeyedCircuitContentTests { var screenState by setUpTestTwoBContent() // Initial onNodeWithTag(TAG_STATE).assertTextEquals("1") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("1") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("1") // Set a new ScreenA screenState = ScreenA(2) onNodeWithTag(TAG_STATE).assertTextEquals("4") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("1") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("1") // Set a new ScreenA screenState = ScreenA(3) onNodeWithTag(TAG_STATE).assertTextEquals("9") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("1") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("1") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("1") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("1") // Set a new ScreenB screenState = ScreenB("abc") onNodeWithTag(TAG_STATE).assertTextEquals("cba") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("cba") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("cba") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("cba") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("cba") // Back to a ScreenA screenState = ScreenA(3) onNodeWithTag(TAG_STATE).assertTextEquals("9") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("9") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("9") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("9") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("9") // Back to another ScreenA screenState = ScreenA(2) onNodeWithTag(TAG_STATE).assertTextEquals("4") - onNodeWithTag(TAG_UI_RETAINED).assertTextEquals("9") - onNodeWithTag(TAG_PRESENTER_RETAINED).assertTextEquals("9") + onNodeWithTag(TAG_UI_REMEMBERED).assertTextEquals("9") + onNodeWithTag(TAG_PRESENTER_REMEMBERED).assertTextEquals("9") } } @@ -198,13 +198,13 @@ class KeyedCircuitContentTests { } private class ScreenA(val num: Int) : Screen { - class State(val numSquare: Int, val retainedNumSquare: Int) : CircuitUiState + data class State(val numSquare: Int, val rememberedNumSquare: Int) : CircuitUiState } private class ScreenAPresenter(val screen: ScreenA) : Presenter { @Composable override fun present(): ScreenA.State { - val square = rememberRetained { screen.num * screen.num } + val square = remember { screen.num * screen.num } return ScreenA.State(screen.num * screen.num, square) } } @@ -212,22 +212,25 @@ private class ScreenAPresenter(val screen: ScreenA) : Presenter { @Composable private fun ScreenAUi(state: ScreenA.State, modifier: Modifier = Modifier) { Column(modifier) { - val retained = rememberRetained { state.numSquare } - Text(text = "$retained", modifier = Modifier.testTag(TAG_UI_RETAINED)) + val remembered = remember { state.numSquare } + Text(text = "$remembered", modifier = Modifier.testTag(TAG_UI_REMEMBERED)) Text(text = "${state.numSquare}", modifier = Modifier.testTag(TAG_STATE)) - Text(text = "${state.retainedNumSquare}", modifier = Modifier.testTag(TAG_PRESENTER_RETAINED)) + Text( + text = "${state.rememberedNumSquare}", + modifier = Modifier.testTag(TAG_PRESENTER_REMEMBERED), + ) } } private class ScreenB(val text: String) : Screen { - class State(val textReverse: String, val retainedTextReverse: String) : CircuitUiState + data class State(val textReverse: String, val rememberedTextReverse: String) : CircuitUiState } private class ScreenBPresenter(val screen: ScreenB) : Presenter { @Composable override fun present(): ScreenB.State { - val textReverse = rememberRetained { screen.text.reversed() } + val textReverse = remember { screen.text.reversed() } return ScreenB.State(screen.text.reversed(), textReverse) } } @@ -235,9 +238,9 @@ private class ScreenBPresenter(val screen: ScreenB) : Presenter { @Composable private fun ScreenBUi(state: ScreenB.State, modifier: Modifier = Modifier) { Column(modifier) { - val retained = rememberRetained { state.textReverse } - Text(text = retained, modifier = Modifier.testTag(TAG_UI_RETAINED)) + val remembered = remember { state.textReverse } + Text(text = remembered, modifier = Modifier.testTag(TAG_UI_REMEMBERED)) Text(text = state.textReverse, modifier = Modifier.testTag(TAG_STATE)) - Text(text = state.retainedTextReverse, modifier = Modifier.testTag(TAG_PRESENTER_RETAINED)) + Text(text = state.rememberedTextReverse, modifier = Modifier.testTag(TAG_PRESENTER_REMEMBERED)) } } diff --git a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt similarity index 95% rename from circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt rename to circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt index 01f543b8e..b6953d915 100644 --- a/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationDecoration.kt +++ b/circuitx/gesture-navigation/src/androidMain/kotlin/com/slack/circuitx/gesturenavigation/AndroidPredictiveBackNavigationDecoration.kt @@ -43,12 +43,12 @@ public actual fun GestureNavigationDecoration( onBackInvoked: () -> Unit, ): NavDecoration = when { - Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveNavigationDecoration(onBackInvoked) + Build.VERSION.SDK_INT >= 34 -> AndroidPredictiveBackNavigationDecoration(onBackInvoked) else -> fallback } @RequiresApi(34) -private class AndroidPredictiveNavigationDecoration(private val onBackInvoked: () -> Unit) : +public class AndroidPredictiveBackNavigationDecoration(private val onBackInvoked: () -> Unit) : NavDecoration { @Composable override fun DecoratedContent( @@ -70,7 +70,11 @@ private class AndroidPredictiveNavigationDecoration(private val onBackInvoked: ( label = "GestureNavDecoration", ) - if (previous != null && !transition.isStateBeingAnimated { it.record == previous }) { + if ( + previous != null && + !transition.isPending && + !transition.isStateBeingAnimated { it.record == previous } + ) { // We display the 'previous' item in the back stack for when the user performs a gesture // to go back. // We only display it here if the transition is not running. When the transition is diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationSaveableStateTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationSaveableStateTest.kt deleted file mode 100644 index a3594c905..000000000 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationSaveableStateTest.kt +++ /dev/null @@ -1,129 +0,0 @@ -// Copyright (C) 2023 Slack Technologies, LLC -// SPDX-License-Identifier: Apache-2.0 -package com.slack.circuitx.gesturenavigation - -import androidx.activity.ComponentActivity -import androidx.compose.ui.test.assertTextEquals -import androidx.compose.ui.test.junit4.createAndroidComposeRule -import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.slack.circuit.backstack.rememberSaveableBackStack -import com.slack.circuit.foundation.CircuitCompositionLocals -import com.slack.circuit.foundation.NavigableCircuitContent -import com.slack.circuit.foundation.rememberCircuitNavigator -import com.slack.circuit.internal.test.TestContentTags.TAG_COUNT -import com.slack.circuit.internal.test.TestContentTags.TAG_GO_NEXT -import com.slack.circuit.internal.test.TestContentTags.TAG_INCREASE_COUNT -import com.slack.circuit.internal.test.TestContentTags.TAG_LABEL -import com.slack.circuit.internal.test.TestContentTags.TAG_POP -import com.slack.circuit.internal.test.TestCountPresenter.RememberType -import com.slack.circuit.internal.test.TestScreen -import com.slack.circuit.internal.test.createTestCircuit -import org.junit.Rule -import org.junit.Test -import org.junit.runner.RunWith -import org.robolectric.annotation.Config - -@Config(minSdk = 34) -@RunWith(AndroidJUnit4::class) -class GestureNavigationSaveableStateTest { - - @get:Rule val composeTestRule = createAndroidComposeRule() - - @Test - fun saveableStateScopedToBackstackWithKeysAndBackSwipes() { - saveableStateScopedToBackstack(true) { - composeTestRule.activityRule.scenario.performBackSwipeGesture() - } - } - - @Test - fun saveableStateScopedToBackstackWithoutKeysAndBackSwipes() { - saveableStateScopedToBackstack(false) { - composeTestRule.activityRule.scenario.performBackSwipeGesture() - } - } - - @Test - fun saveableStateScopedToBackstackWithKeysAndBackPress() { - saveableStateScopedToBackstack(true) { - composeTestRule.onTopNavigationRecordNodeWithTag(TAG_POP).performClick() - } - } - - @Test - fun saveableStateScopedToBackstackWithoutKeysAndBackPress() { - saveableStateScopedToBackstack(false) { - composeTestRule.onTopNavigationRecordNodeWithTag(TAG_POP).performClick() - } - } - - private fun saveableStateScopedToBackstack(useKeys: Boolean, pop: () -> Unit) { - composeTestRule.run { - val circuit = createTestCircuit(useKeys = useKeys, rememberType = RememberType.Saveable) - - setContent { - CircuitCompositionLocals(circuit) { - val backStack = rememberSaveableBackStack(TestScreen.ScreenA) - val navigator = - rememberCircuitNavigator( - backStack = backStack, - onRootPop = {}, // no-op for tests - ) - NavigableCircuitContent( - navigator = navigator, - backStack = backStack, - decoration = GestureNavigationDecoration(onBackInvoked = navigator::pop), - ) - } - } - - // Current: Screen A. Increase count to 1 - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("A") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") - onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") - - // Navigate to Screen B. Increase count to 1 - onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") - onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") - - // Navigate to Screen C. Increase count to 1 - onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("C") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") - onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") - - // Pop to Screen B. Increase count from 1 to 2. - pop() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") - onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("2") - - // Navigate to Screen C. Assert that it's state was not saved - onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("C") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") - - // Pop to Screen B. Assert that it's state was saved - pop() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("2") - - // Pop to Screen A. Assert that it's state was saved - pop() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("A") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") - - // Navigate to Screen B. Assert that it's state was not saved - onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() - onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") - onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") - } - } -} diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationRetainedStateTest.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt similarity index 64% rename from circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationRetainedStateTest.kt rename to circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt index 765d59d87..ce7c81cea 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationRetainedStateTest.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/GestureNavigationStateTest.kt @@ -3,10 +3,14 @@ package com.slack.circuitx.gesturenavigation import androidx.activity.ComponentActivity +import androidx.compose.material.ExperimentalMaterialApi +import androidx.compose.runtime.remember import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onRoot import androidx.compose.ui.test.performClick -import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.compose.ui.test.performTouchInput +import androidx.compose.ui.test.swipeRight import com.slack.circuit.backstack.rememberSaveableBackStack import com.slack.circuit.foundation.CircuitCompositionLocals import com.slack.circuit.foundation.NavigableCircuitContent @@ -22,45 +26,62 @@ import com.slack.circuit.internal.test.createTestCircuit import org.junit.Rule import org.junit.Test import org.junit.runner.RunWith +import org.robolectric.ParameterizedRobolectricTestRunner import org.robolectric.annotation.Config -@Config(minSdk = 34) -@RunWith(AndroidJUnit4::class) -class GestureNavigationRetainedStateTest { - - @get:Rule val composeTestRule = createAndroidComposeRule() +enum class GestureNavDecorationOption { + AndroidPredictiveBack, + Cupertino, +} - @Test - fun retainedStateScopedToBackstackWithKeysAndBackSwipes() { - retainedStateScopedToBackstack(true) { - composeTestRule.activityRule.scenario.performBackSwipeGesture() - } +@OptIn(ExperimentalMaterialApi::class) +@Config(minSdk = 34) +@RunWith(ParameterizedRobolectricTestRunner::class) +class GestureNavigationStateTest( + private val decorationOption: GestureNavDecorationOption, + private val useKeys: Boolean, + private val useSwipe: Boolean, + private val rememberType: RememberType, +) { + companion object { + @JvmStatic + @ParameterizedRobolectricTestRunner.Parameters( + name = "{0}_useKeys={1}_useSwipe={2}_rememberType={3}" + ) + fun params() = + parameterizedParams() + .combineWithParameters( + GestureNavDecorationOption.Cupertino, + GestureNavDecorationOption.AndroidPredictiveBack, + ) + .combineWithParameters(true, false) // useKeys + .combineWithParameters(true, false) // useSwipe + .combineWithParameters(RememberType.Retained, RememberType.Saveable) } - @Test - fun retainedStateScopedToBackstackWithoutKeysAndBackSwipes() { - retainedStateScopedToBackstack(false) { - composeTestRule.activityRule.scenario.performBackSwipeGesture() - } - } + @get:Rule val composeTestRule = createAndroidComposeRule() - @Test - fun retainedStateScopedToBackstackWithKeysAndBackPress() { - retainedStateScopedToBackstack(true) { + private fun pop() { + if (useSwipe) { + when (decorationOption) { + GestureNavDecorationOption.AndroidPredictiveBack -> { + composeTestRule.activityRule.scenario.performGestureNavigationBackSwipe() + } + GestureNavDecorationOption.Cupertino -> { + composeTestRule.onRoot().performTouchInput { + swipeRight(startX = width * 0.2f, endX = width * 0.8f) + } + } + } + } else { composeTestRule.onTopNavigationRecordNodeWithTag(TAG_POP).performClick() } } @Test - fun retainedStateScopedToBackstackWithoutKeysAndBackPress() { - retainedStateScopedToBackstack(false) { - composeTestRule.onTopNavigationRecordNodeWithTag(TAG_POP).performClick() - } - } - - private fun retainedStateScopedToBackstack(useKeys: Boolean, pop: () -> Unit) { + fun retainedStateScopedToBackstack() { composeTestRule.run { - val circuit = createTestCircuit(useKeys = useKeys, rememberType = RememberType.Retained) + val circuit = createTestCircuit(useKeys = useKeys, rememberType = rememberType) setContent { CircuitCompositionLocals(circuit) { @@ -73,7 +94,17 @@ class GestureNavigationRetainedStateTest { NavigableCircuitContent( navigator = navigator, backStack = backStack, - decoration = GestureNavigationDecoration(onBackInvoked = navigator::pop), + decoration = + remember { + when (decorationOption) { + GestureNavDecorationOption.AndroidPredictiveBack -> { + AndroidPredictiveBackNavigationDecoration(onBackInvoked = navigator::pop) + } + GestureNavDecorationOption.Cupertino -> { + CupertinoGestureNavigationDecoration(onBackInvoked = navigator::pop) + } + } + }, ) } } @@ -81,41 +112,51 @@ class GestureNavigationRetainedStateTest { // Current: Screen A. Increase count to 1 onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("A") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") + println("Increasing A") onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") // Navigate to Screen B. Increase count to 1 + println("Going to B") onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") + println("Increasing B") onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") // Navigate to Screen C. Increase count to 1 + println("Going to C") onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("C") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") + println("Increasing C") onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") // Pop to Screen B. Increase count from 1 to 2. + println("Pop to B") pop() onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") + println("Increasing B") onTopNavigationRecordNodeWithTag(TAG_INCREASE_COUNT).performClick() onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("2") // Navigate to Screen C. Assert that it's state was not retained + println("Go to C") onTopNavigationRecordNodeWithTag(TAG_GO_NEXT).performClick() onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("C") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("0") // Pop to Screen B. Assert that it's state was retained + println("Popping to B") pop() onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("B") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("2") // Pop to Screen A. Assert that it's state was retained + println("Popping to A") pop() onTopNavigationRecordNodeWithTag(TAG_LABEL).assertTextEquals("A") onTopNavigationRecordNodeWithTag(TAG_COUNT).assertTextEquals("1") diff --git a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/Utils.kt b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/Utils.kt index 11013508d..0e292ba64 100644 --- a/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/Utils.kt +++ b/circuitx/gesture-navigation/src/androidUnitTest/kotlin/com/slack/circuitx/gesturenavigation/Utils.kt @@ -27,7 +27,7 @@ internal fun BackEventCompat.copy( ): BackEventCompat = BackEventCompat(touchX = touchX, touchY = touchY, progress = progress, swipeEdge = swipeEdge) -internal fun ActivityScenario.performBackSwipeGesture() { +internal fun ActivityScenario.performGestureNavigationBackSwipe() { onActivity { activity -> val event = BackEventCompat( @@ -50,3 +50,15 @@ internal fun ActivityScenario.performBackSwipeGesture() { } } } + +fun parameterizedParams(): List> = emptyList() + +inline fun List>.combineWithParameters(vararg values: T): List> { + if (isEmpty()) return values.map { arrayOf(it) } + + return fold(emptyList()) { acc, args -> + val result = acc.toMutableList() + values.forEach { result += (args + it) } + result.toList() + } +} diff --git a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt index 7c69886be..39d203b2a 100644 --- a/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt +++ b/circuitx/gesture-navigation/src/commonMain/kotlin/com/slack/circuitx/gesturenavigation/TransitionUtils.kt @@ -9,6 +9,9 @@ internal fun Transition.isStateBeingAnimated(equals: (T) -> Boolean): Boo return isRunning && (equals(currentState) || equals(targetState)) } +internal val Transition<*>.isPending: Boolean + get() = this.currentState != this.targetState + /** * A holder class used by the `AnimatedContent` composables. This enables us to pass through all of * the necessary information as an argument, which is optimal for `AnimatedContent`.