Skip to content

Commit

Permalink
Movable content support and test for targetVisibilityState
Browse files Browse the repository at this point in the history
  • Loading branch information
KovalevAndrey committed Apr 29, 2024
1 parent c1c570d commit 06a138c
Show file tree
Hide file tree
Showing 15 changed files with 333 additions and 32 deletions.
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
package com.bumble.appyx.core.node

import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.AppyxTestScenario
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push
import kotlinx.parcelize.Parcelize
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class BackStackTargetVisibilityTest {

private val backStack = BackStack<NavTarget>(
savedStateMap = null,
initialElement = NavTarget.NavTarget1
)

var nodeOneTargetVisibilityState: Boolean = false
var nodeTwoTargetVisibilityState: Boolean = false

var nodeFactory: (buildContext: BuildContext) -> TestParentNode = {
TestParentNode(buildContext = it, backStack = backStack)
}

@get:Rule
val rule = AppyxTestScenario { buildContext ->
nodeFactory(buildContext)
}

@Test
fun `GIVEN_backStack_WHEN_operations_called_THEN_child_nodes_have_correct_targetVisibility_state`() {
rule.start()
assertTrue(nodeOneTargetVisibilityState)

backStack.push(NavTarget.NavTarget2)
rule.waitForIdle()

assertFalse(nodeOneTargetVisibilityState)
assertTrue(nodeTwoTargetVisibilityState)

backStack.pop()
rule.waitForIdle()

assertFalse(nodeTwoTargetVisibilityState)
assertTrue(nodeOneTargetVisibilityState)
}


@Parcelize
sealed class NavTarget : Parcelable {

data object NavTarget1 : NavTarget()

data object NavTarget2 : NavTarget()
}

inner class TestParentNode(
buildContext: BuildContext,
val backStack: BackStack<NavTarget>,
) : ParentNode<NavTarget>(
buildContext = buildContext,
navModel = backStack
) {

override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node =
when (navTarget) {
NavTarget.NavTarget1 -> node(buildContext) {
nodeOneTargetVisibilityState = LocalNodeTargetVisibility.current
}

NavTarget.NavTarget2 -> node(buildContext) {
nodeTwoTargetVisibilityState = LocalNodeTargetVisibility.current
}
}

@Composable
override fun View(modifier: Modifier) {
Children(navModel)
}
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
package com.bumble.appyx.core.node

import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.AppyxTestScenario
import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.navmodel.spotlight.Spotlight
import com.bumble.appyx.navmodel.spotlight.operation.activate
import kotlinx.parcelize.Parcelize
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test

class SpotlightTargetVisibilityTest {

private lateinit var spotlight: Spotlight<NavTarget>

var nodeOneTargetVisibilityState: Boolean = false
var nodeTwoTargetVisibilityState: Boolean = false
var nodeThreeTargetVisibilityState: Boolean = false

var nodeFactory: (buildContext: BuildContext) -> TestParentNode = {
TestParentNode(buildContext = it, spotlight = spotlight)
}

@get:Rule
val rule = AppyxTestScenario { buildContext ->
nodeFactory(buildContext)
}

@Test
fun `GIVEN_spotlight_WHEN_operations_called_THEN_child_nodes_have_correct_targetVisibility_state`() {
val initialActiveIndex = 2
createSpotlight(initialActiveIndex)
rule.start()

assertTrue(nodeThreeTargetVisibilityState)

spotlight.activate(1)
rule.waitForIdle()

assertFalse(nodeOneTargetVisibilityState)
assertTrue(nodeTwoTargetVisibilityState)
assertFalse(nodeThreeTargetVisibilityState)

spotlight.activate(0)
rule.waitForIdle()

assertTrue(nodeOneTargetVisibilityState)
assertFalse(nodeTwoTargetVisibilityState)
assertFalse(nodeThreeTargetVisibilityState)
}


private fun createSpotlight(initialActiveIndex: Int) {
spotlight = Spotlight(
savedStateMap = null,
items = listOf(NavTarget.NavTarget1, NavTarget.NavTarget2, NavTarget.NavTarget3),
initialActiveIndex = initialActiveIndex
)
}

@Parcelize
sealed class NavTarget : Parcelable {

data object NavTarget1 : NavTarget()

data object NavTarget2 : NavTarget()

data object NavTarget3 : NavTarget()
}

inner class TestParentNode(
buildContext: BuildContext,
val spotlight: Spotlight<NavTarget>,
) : ParentNode<NavTarget>(
buildContext = buildContext,
navModel = spotlight
) {

override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node =
when (navTarget) {
NavTarget.NavTarget1 -> node(buildContext) {
nodeOneTargetVisibilityState = LocalNodeTargetVisibility.current
}

NavTarget.NavTarget2 -> node(buildContext) {
nodeTwoTargetVisibilityState = LocalNodeTargetVisibility.current
}
NavTarget.NavTarget3 -> node(buildContext) {
nodeThreeTargetVisibilityState = LocalNodeTargetVisibility.current
}
}

@Composable
override fun View(modifier: Modifier) {
Children(navModel)
}
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,7 @@ inline fun <reified NavTarget : Any, State> ParentNode<NavTarget>.Children(
}
) {
CompositionLocalProvider(
/** LocalSharedElementScope will be consumed by children UI to apply shareElement modifier */
LocalSharedElementScope provides this
) {
block(
Expand All @@ -83,6 +84,8 @@ inline fun <reified NavTarget : Any, State> ParentNode<NavTarget>.Children(
}
) {
CompositionLocalProvider(
/** If sharedElement is not supported for this Node - provide null otherwise children
* can consume ascendant's LocalSharedElementScope */
LocalSharedElementScope provides null
) {
block(
Expand Down Expand Up @@ -190,7 +193,7 @@ class ChildrenTransitionScope<T : Any, S>(
key(navElement.key.id) {
CompositionLocalProvider(
LocalNodeTargetVisibility provides
children.targetStateVisible.contains(navElement)
children.onScreenWithVisibleTargetState.contains(navElement)
) {
Child(
navElement,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ abstract class BaseNavModel<NavTarget, State>(
state
.mapState(scope) { elements ->
NavModelAdapter.ScreenState(
targetStateVisible = elements.filter { screenResolver.isOnScreen(it.targetState) },
onScreenWithVisibleTargetState = elements.filter { screenResolver.isOnScreen(it.targetState) },
onScreen = elements.filter { screenResolver.isOnScreen(it) },
offScreen = elements.filterNot { screenResolver.isOnScreen(it) },
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,11 @@ interface NavModelAdapter<NavTarget, State> {
val screenState: StateFlow<ScreenState<NavTarget, out State>>

data class ScreenState<NavTarget, State>(
val targetStateVisible: NavElements<NavTarget, out State> = emptyList(),
/** onScreenWithVisibleTargetState represents the list of NavElements that have a target state
* as visible. For instance if the NavModel is a BackStack it will represent the element that
* is transitioning to ACTIVE state.
*/
val onScreenWithVisibleTargetState: NavElements<NavTarget, out State> = emptyList(),
val onScreen: NavElements<NavTarget, out State> = emptyList(),
val offScreen: NavElements<NavTarget, out State> = emptyList(),
)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.bumble.appyx.core.navigation.transition

import androidx.compose.runtime.Composable
import androidx.compose.runtime.movableContentOf
import com.bumble.appyx.core.node.LocalNodeTargetVisibility
import com.bumble.appyx.core.node.LocalParentNodeMovableContent


/**
* Returns movable content for the given key. To reuse composable across Nodes movable content
* must to be invoked only once at any time, therefore we should return null for if the Node is
* transitioning to an invisible target and return movable content only if the targetState is visible.
*
* Example: ParentNode (P) has BackStack with one Active Node (A). We push Node (B) to the BackStack,
* and we want to move content from Node (A) to Node (B). Node (A) is transitioning from Active to
* Stashed (invisible) state and Node (B) is transitioning from Created to Active (visible) state.
* When this transition starts this function will return null for Node (A) and movable content for
* Node (B)so that this content will be moved from Node (A) to Node (B).
*
* If you have a custom NavModel keep in mind that you can only move content from a visible Node
* that becomes invisible to a Node that is becoming visible.
*/
@Composable
fun localMovableContentWithTargetVisibility(
key: Any,
defaultValue: @Composable () -> Unit
): (@Composable () -> Unit)? {
if (!LocalNodeTargetVisibility.current) return null
val movableContentMap = LocalParentNodeMovableContent.current
return movableContentMap.getOrPut(key) {
movableContentOf {
defaultValue()
}
}
}

Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.bumble.appyx.core.navigation.transition
import androidx.compose.animation.BoundsTransform
import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
Expand All @@ -19,7 +20,7 @@ import com.bumble.appyx.core.node.LocalSharedElementScope
fun Modifier.sharedElement(
key: Any,
boundsTransform: BoundsTransform = DefaultBoundsTransform,
placeHolderSize: SharedTransitionScope.PlaceHolderSize = SharedTransitionScope.PlaceHolderSize.contentSize,
placeHolderSize: PlaceHolderSize = PlaceHolderSize.contentSize,
renderInOverlayDuringTransition: Boolean = true,
zIndexInOverlay: Float = 0f,
clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip = ParentClip
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package com.bumble.appyx.core.node

import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.SharedTransitionScope
import androidx.compose.runtime.Composable
import androidx.compose.runtime.compositionLocalOf

val LocalNode = compositionLocalOf<Node?> { null }
Expand All @@ -16,3 +17,6 @@ val LocalSharedElementScope = compositionLocalOf<SharedTransitionScope?> { null
* return false.
*/
val LocalNodeTargetVisibility = compositionLocalOf { false }

val LocalParentNodeMovableContent =

Check warning

Code scanning / detekt

CompositionLocals are implicit dependencies and creating new ones should be avoided. See https://mrmans0n.github.io/compose-rules/rules/#compositionlocals for more information. Warning

CompositionLocals are implicit dependencies and creating new ones should be avoided. See https://mrmans0n.github.io/compose-rules/rules/#compositionlocals for more information.
compositionLocalOf<MutableMap<Any, @Composable () -> Unit>> { mutableMapOf() }
Original file line number Diff line number Diff line change
Expand Up @@ -123,11 +123,18 @@ open class Node @VisibleForTesting internal constructor(
LocalNode provides this,
LocalLifecycleOwner provides this,
) {
HandleBackPress()
View(modifier)
DerivedSetup {
HandleBackPress()
View(modifier)
}
}
}

@Composable
protected open fun DerivedSetup(innerContent: @Composable () -> Unit) {
innerContent()
}

@Composable
private fun HandleBackPress() {
// can't use BackHandler Composable because plugins provide OnBackPressedCallback which is not observable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package com.bumble.appyx.core.node

import androidx.annotation.CallSuper
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Stable
import androidx.lifecycle.DefaultLifecycleObserver
import androidx.lifecycle.Lifecycle
Expand Down Expand Up @@ -74,6 +76,15 @@ abstract class ParentNode<NavTarget : Any>(
manageTransitions()
}

@Composable
final override fun DerivedSetup(innerContent: @Composable () -> Unit) {
CompositionLocalProvider(
LocalParentNodeMovableContent provides mutableMapOf()
) {
innerContent()
}
}

fun childOrCreate(navKey: NavKey<NavTarget>): ChildEntry.Initialized<NavTarget> =
childNodeCreationManager.childOrCreate(navKey)

Expand Down Expand Up @@ -219,5 +230,4 @@ abstract class ParentNode<NavTarget : Any>(
}



}
Original file line number Diff line number Diff line change
Expand Up @@ -48,7 +48,7 @@ import com.bumble.appyx.sandbox.client.list.LazyListContainerNode
import com.bumble.appyx.sandbox.client.mvicoreexample.MviCoreExampleBuilder
import com.bumble.appyx.sandbox.client.mvicoreexample.leaf.MviCoreLeafBuilder
import com.bumble.appyx.sandbox.client.navmodels.NavModelExamplesNode
import com.bumble.appyx.sandbox.client.sharedelement.SharedElementFaderNode
import com.bumble.appyx.sandbox.client.sharedelement.SharedElementWithMovableContentExampleNode
import com.bumble.appyx.utils.customisations.NodeCustomisation
import kotlinx.parcelize.Parcelize

Expand Down Expand Up @@ -101,7 +101,7 @@ class ContainerNode internal constructor(
when (navTarget) {
is Picker -> node(buildContext) { modifier -> ExamplesList(modifier) }
is NavModelExamples -> NavModelExamplesNode(buildContext)
is SharedElementFaderExample -> SharedElementFaderNode(buildContext)
is SharedElementFaderExample -> SharedElementWithMovableContentExampleNode(buildContext)
is LazyExamples -> LazyListContainerNode(buildContext)
is IntegrationPointExample -> IntegrationPointExampleNode(buildContext)
is BlockerExample -> BlockerExampleNode(buildContext)
Expand Down
Loading

0 comments on commit 06a138c

Please sign in to comment.