diff --git a/CHANGELOG.md b/CHANGELOG.md index 23ec1740f..e6e888808 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,7 @@ - [#670](https://github.com/bumble-tech/appyx/pull/670) - Fixes ios lifecycle - [#673](https://github.com/bumble-tech/appyx/pull/673) – Fix canHandeBackPress typo - [#671](https://github.com/bumble-tech/appyx/issue/671) – Fix ui state saving issue +- [#694](https://github.com/bumble-tech/appyx/pull/694) – Fix appyxComponent state saving issue ### Enhancement diff --git a/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyComponent.kt b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyComponent.kt new file mode 100644 index 000000000..1cc296fb7 --- /dev/null +++ b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyComponent.kt @@ -0,0 +1,50 @@ +package com.bumble.appyx.helpers + +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.spring +import com.bumble.appyx.helpers.DummyComponentModel.State +import com.bumble.appyx.interactions.gesture.GestureFactory +import com.bumble.appyx.interactions.gesture.GestureSettleConfig +import com.bumble.appyx.interactions.model.BaseAppyxComponent +import com.bumble.appyx.interactions.model.backpresshandlerstrategies.BackPressHandlerStrategy +import com.bumble.appyx.interactions.model.backpresshandlerstrategies.DontHandleBackPress +import com.bumble.appyx.interactions.state.MutableSavedStateMap +import com.bumble.appyx.interactions.ui.Visualisation +import com.bumble.appyx.interactions.ui.context.TransitionBounds +import com.bumble.appyx.interactions.ui.context.UiContext +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob + +class DummyComponent( + scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.Main), + val model: DummyComponentModel, + visualisation: (UiContext) -> Visualisation>, + animationSpec: AnimationSpec = spring(), + gestureFactory: (TransitionBounds) -> GestureFactory> = { + GestureFactory.Noop() + }, + gestureSettleConfig: GestureSettleConfig = GestureSettleConfig(), + backPressStrategy: BackPressHandlerStrategy> = DontHandleBackPress(), + disableAnimations: Boolean = false, +) : BaseAppyxComponent>( + scope = scope, + model = model, + visualisation = visualisation, + gestureFactory = gestureFactory, + gestureSettleConfig = gestureSettleConfig, + backPressStrategy = backPressStrategy, + defaultAnimationSpec = animationSpec, + disableAnimations = disableAnimations +) { + var saveInstanceStateInvoked: Int = 0 + + fun resetSaveInstanceState() { + saveInstanceStateInvoked = 0 + } + + override fun saveInstanceState(state: MutableSavedStateMap) { + super.saveInstanceState(state) + saveInstanceStateInvoked += 1 + } +} diff --git a/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyComponentModel.kt b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyComponentModel.kt new file mode 100644 index 000000000..100126a61 --- /dev/null +++ b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyComponentModel.kt @@ -0,0 +1,32 @@ +package com.bumble.appyx.helpers + +import com.bumble.appyx.helpers.DummyComponentModel.State +import com.bumble.appyx.interactions.model.Element +import com.bumble.appyx.interactions.model.asElement +import com.bumble.appyx.interactions.model.transition.BaseTransitionModel +import com.bumble.appyx.utils.multiplatform.Parcelable +import com.bumble.appyx.utils.multiplatform.Parcelize +import com.bumble.appyx.utils.multiplatform.SavedStateMap + +class DummyComponentModel( + initialTarget: NavTarget, + savedStateMap: SavedStateMap?, +) : BaseTransitionModel>( + savedStateMap = savedStateMap, +) { + @Parcelize + data class State( + val target: Element + ) : Parcelable + + override val initialState: State = State(initialTarget.asElement()) + + override fun State.availableElements(): Set> = setOf(target) + + override fun State.removeDestroyedElement(element: Element): State = + this + + override fun State.removeDestroyedElements(): State = this + + override fun State.destroyedElements(): Set> = emptySet() +} diff --git a/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyVisualisation.kt b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyVisualisation.kt new file mode 100644 index 000000000..c3bdb829a --- /dev/null +++ b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/DummyVisualisation.kt @@ -0,0 +1,32 @@ +package com.bumble.appyx.helpers + +import androidx.compose.animation.core.SpringSpec +import com.bumble.appyx.helpers.DummyComponentModel.State +import com.bumble.appyx.interactions.ui.DefaultAnimationSpec +import com.bumble.appyx.interactions.ui.context.UiContext +import com.bumble.appyx.interactions.ui.state.MatchedTargetUiState +import com.bumble.appyx.transitionmodel.BaseVisualisation + +class DummyVisualisation( + uiContext: UiContext, + defaultAnimationSpec: SpringSpec = DefaultAnimationSpec +) : BaseVisualisation, TargetUiState, MutableUiState>( + uiContext = uiContext, + defaultAnimationSpec = defaultAnimationSpec, +) { + override fun State.toUiTargets(): List> = + listOf( + MatchedTargetUiState( + element = target, + targetUiState = TargetUiState(0) + ) + ) + + override fun mutableUiStateFor( + uiContext: UiContext, + targetUiState: TargetUiState + ): MutableUiState = + targetUiState.toMutableUiState(uiContext) + + +} diff --git a/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/RootNode.kt b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/RootNode.kt new file mode 100644 index 000000000..d54b32c0a --- /dev/null +++ b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/RootNode.kt @@ -0,0 +1,33 @@ +package com.bumble.appyx.helpers + +import androidx.compose.material.Text +import com.bumble.appyx.helpers.RootNode.NavTarget +import com.bumble.appyx.navigation.modality.NodeContext +import com.bumble.appyx.navigation.node.Node +import com.bumble.appyx.navigation.node.node + +class RootNode( + nodeContext: NodeContext, + appyxComponent: DummyComponent = DummyComponent( + model = DummyComponentModel( + initialTarget = NavTarget.Child1, + savedStateMap = nodeContext.savedStateMap + ), + visualisation = { DummyVisualisation(it) } + ) +) : Node( + nodeContext = nodeContext, + appyxComponent = appyxComponent, +) { + sealed interface NavTarget { + data object Child1 : NavTarget + data object Child2 : NavTarget + } + + override fun buildChildNode(navTarget: NavTarget, nodeContext: NodeContext): Node<*> = + when (navTarget) { + is NavTarget.Child1 -> node(nodeContext) { Text("Child 1") } + is NavTarget.Child2 -> node(nodeContext) { Text("Child 2") } + } + +} diff --git a/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/TargetUiState.kt b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/TargetUiState.kt new file mode 100644 index 000000000..5d3dbf5e2 --- /dev/null +++ b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/helpers/TargetUiState.kt @@ -0,0 +1,41 @@ +package com.bumble.appyx.helpers + +import androidx.compose.animation.core.SpringSpec +import androidx.compose.ui.Modifier +import com.bumble.appyx.interactions.ui.context.UiContext +import com.bumble.appyx.interactions.ui.state.BaseMutableUiState +import kotlinx.coroutines.CoroutineScope + +class TargetUiState( + val id: Int, +) + +class MutableUiState( + uiContext: UiContext, + val id: Int, +) : BaseMutableUiState( + uiContext, emptyList() +) { + override val combinedMotionPropertyModifier: Modifier = Modifier + + override suspend fun snapTo(target: TargetUiState) = Unit + + override fun lerpTo( + scope: CoroutineScope, + start: TargetUiState, + end: TargetUiState, + fraction: Float + ) = Unit + + override suspend fun animateTo( + scope: CoroutineScope, + target: TargetUiState, + springSpec: SpringSpec + ) = Unit +} + +fun TargetUiState.toMutableUiState(uiContext: UiContext): MutableUiState = + MutableUiState( + uiContext = uiContext, + id = id, + ) diff --git a/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/navigation/node/NodeTest.kt b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/navigation/node/NodeTest.kt new file mode 100644 index 000000000..974e71c47 --- /dev/null +++ b/appyx-navigation/android/src/androidTest/kotlin/com/bumble/appyx/navigation/node/NodeTest.kt @@ -0,0 +1,50 @@ +package com.bumble.appyx.navigation.node + +import com.bumble.appyx.helpers.DummyComponent +import com.bumble.appyx.helpers.DummyComponentModel +import com.bumble.appyx.helpers.DummyVisualisation +import com.bumble.appyx.helpers.RootNode +import com.bumble.appyx.navigation.AppyxTestScenario +import com.bumble.appyx.navigation.modality.NodeContext +import org.junit.Assert.assertNotEquals +import org.junit.Rule +import org.junit.Test + +class NodeTest { + private var appyxComponent: DummyComponent? = null + + private val nodeFactory: (nodeContext: NodeContext) -> Node<*> = { nodeContext -> + appyxComponent = DummyComponent( + model = DummyComponentModel( + initialTarget = RootNode.NavTarget.Child1, + savedStateMap = nodeContext.savedStateMap + ), + visualisation = { DummyVisualisation(it) } + ) + RootNode(nodeContext = nodeContext, appyxComponent = appyxComponent!!) + } + + @get:Rule + val rule = AppyxTestScenario { nodeContext -> + nodeFactory(nodeContext) + } + + @Test + fun WHEN_node_is_create_THEN_plugins_are_setup_as_expected() { + rule.start() + assertNotEquals("Node should have some predefined plugins", 0, rule.node.plugins.size) + } + + @Test + fun WHEN_node_is_create_THEN_appyx_component_state_is_saved_during_recreation() { + rule.start() + appyxComponent!!.resetSaveInstanceState() + rule.activityScenario.recreate() + assertNotEquals( + "AppyxComponent state should be saved", + 0, + appyxComponent!!.saveInstanceStateInvoked + ) + } + +} diff --git a/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/Node.kt b/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/Node.kt index 5d1c605ae..b71daa307 100644 --- a/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/Node.kt +++ b/appyx-navigation/common/src/commonMain/kotlin/com/bumble/appyx/navigation/node/Node.kt @@ -91,7 +91,7 @@ abstract class Node( val id: String get() = nodeContext.identifier - val plugins: List = plugins + listOfNotNull(this as? Plugin) + val plugins: List = plugins + appyxComponent + childAware + listOfNotNull(this as? Plugin) val ancestryInfo: AncestryInfo = nodeContext.ancestryInfo