diff --git a/CHANGELOG.md b/CHANGELOG.md index fe729588f..6854f15ab 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -3,6 +3,7 @@ ## Pending changes - [#719](https://github.com/bumble-tech/appyx/pull/719) – **Updated**: Jetpack Compose to 1.7.6 +- [#720](https://github.com/bumble-tech/appyx/pull/720) – **Added**: Shared element transition and movable content support ## 1.5.1 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dee03fe7c..e80d9526f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -35,6 +35,7 @@ coil-compose = { module = "io.coil-kt:coil-compose", version.ref = "coil" } compose-bom = { group = "androidx.compose", name = "compose-bom", version.ref = "composeBom" } # compose versions are resolved by BOM compose-animation-core = { module = "androidx.compose.animation:animation-core" } +compose-animation-android = { group = "androidx.compose.animation", name = "animation-android" } compose-foundation-layout = { module = "androidx.compose.foundation:foundation-layout" } compose-foundation = { module = "androidx.compose.foundation:foundation" } compose-material = { module = "androidx.compose.material3:material3" } diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index c03ab1b3d..d99a92288 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -46,6 +46,7 @@ dependencies { api(project(":libraries:customisations")) api(libs.androidx.lifecycle.common) api(libs.compose.animation.core) + api(libs.compose.animation.android) api(libs.compose.runtime) api(libs.androidx.appcompat) api(libs.kotlin.coroutines.android) diff --git a/libraries/core/detekt-baseline.xml b/libraries/core/detekt-baseline.xml index a9c84a3ab..689191e73 100644 --- a/libraries/core/detekt-baseline.xml +++ b/libraries/core/detekt-baseline.xml @@ -1,5 +1,9 @@ - + - - + + + CompositionLocalAllowlist:LocalNode.kt$LocalMovableContentMap + CompositionLocalAllowlist:LocalNode.kt$LocalNodeTargetVisibility + CompositionLocalAllowlist:LocalNode.kt$LocalSharedElementScope + diff --git a/libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/BackStackTargetVisibilityTest.kt b/libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/BackStackTargetVisibilityTest.kt new file mode 100644 index 000000000..626175e00 --- /dev/null +++ b/libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/BackStackTargetVisibilityTest.kt @@ -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( + 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, + ) : ParentNode( + 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) + } + } + +} diff --git a/libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/SpotlightTargetVisibilityTest.kt b/libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/SpotlightTargetVisibilityTest.kt new file mode 100644 index 000000000..ac71699ed --- /dev/null +++ b/libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/SpotlightTargetVisibilityTest.kt @@ -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 + + 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, + ) : ParentNode( + 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) + } + } + +} diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/composable/Children.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/composable/Children.kt index 7bafa664c..447dfdd71 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/composable/Children.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/composable/Children.kt @@ -1,7 +1,12 @@ package com.bumble.appyx.core.composable +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.Immutable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -23,15 +28,21 @@ import com.bumble.appyx.core.navigation.transition.TransitionBounds import com.bumble.appyx.core.navigation.transition.TransitionDescriptor import com.bumble.appyx.core.navigation.transition.TransitionHandler import com.bumble.appyx.core.navigation.transition.TransitionParams +import com.bumble.appyx.core.node.LocalMovableContentMap +import com.bumble.appyx.core.node.LocalNodeTargetVisibility +import com.bumble.appyx.core.node.LocalSharedElementScope import com.bumble.appyx.core.node.ParentNode import kotlinx.coroutines.flow.map import kotlin.reflect.KClass +@OptIn(ExperimentalSharedTransitionApi::class) @Composable inline fun ParentNode.Children( navModel: NavModel, modifier: Modifier = Modifier, - transitionHandler: TransitionHandler = JumpToEndTransitionHandler(), + transitionHandler: TransitionHandler = remember { JumpToEndTransitionHandler() }, + withSharedElementTransition: Boolean = false, + withMovableContent: Boolean = false, noinline block: @Composable ChildrenTransitionScope.() -> Unit = { children { child -> child() @@ -50,21 +61,51 @@ inline fun ParentNode.Children( ) } } - Box(modifier = modifier - .onSizeChanged { - transitionBounds = it + if (withSharedElementTransition) { + SharedTransitionLayout(modifier = modifier + .onSizeChanged { + transitionBounds = it + } + ) { + CompositionLocalProvider( + /** LocalSharedElementScope will be consumed by children UI to apply shareElement modifier */ + LocalSharedElementScope provides this, + LocalMovableContentMap provides if (withMovableContent) mutableMapOf() else null + ) { + block( + ChildrenTransitionScope( + transitionHandler = transitionHandler, + transitionParams = transitionParams, + navModel = navModel + ) + ) + } + } + } else { + Box(modifier = modifier + .onSizeChanged { + transitionBounds = it + } + ) { + CompositionLocalProvider( + /** If sharedElement is not supported for this Node - provide null otherwise children + * can consume ascendant's LocalSharedElementScope */ + LocalSharedElementScope provides null, + LocalMovableContentMap provides if (withMovableContent) mutableMapOf() else null + ) { + block( + ChildrenTransitionScope( + transitionHandler = transitionHandler, + transitionParams = transitionParams, + navModel = navModel + ) + ) + } } - ) { - block( - ChildrenTransitionScope( - transitionHandler = transitionHandler, - transitionParams = transitionParams, - navModel = navModel - ) - ) } } +@Immutable class ChildrenTransitionScope( private val transitionHandler: TransitionHandler, private val transitionParams: TransitionParams, @@ -89,6 +130,7 @@ class ChildrenTransitionScope( } @Composable + @SuppressLint("ComposableNaming") fun ParentNode.children( clazz: KClass, block: @Composable ChildTransitionScope.(ChildRenderer) -> Unit, @@ -99,6 +141,7 @@ class ChildrenTransitionScope( } @Composable + @SuppressLint("ComposableNaming") fun ParentNode.children( clazz: KClass, block: @Composable ChildTransitionScope.( @@ -114,6 +157,7 @@ class ChildrenTransitionScope( } } + @SuppressLint("ComposableNaming") @Composable private fun ParentNode._children( clazz: KClass, @@ -138,30 +182,32 @@ class ChildrenTransitionScope( } } - val visibleElementsFlow = remember { + val screenStateFlow = remember { this@ChildrenTransitionScope .navModel .screenState - .map { list -> - list - .onScreen - .filter { clazz.isInstance(it.key.navTarget) } - } } - val children by visibleElementsFlow.collectAsState(emptyList()) + val children by screenStateFlow.collectAsState() - children.forEach { navElement -> - key(navElement.key.id) { - Child( - navElement, - saveableStateHolder, - transitionParams, - transitionHandler, - block - ) + children + .onScreen + .filter { clazz.isInstance(it.key.navTarget) } + .forEach { navElement -> + key(navElement.key.id) { + CompositionLocalProvider( + LocalNodeTargetVisibility provides + children.onScreenWithVisibleTargetState.contains(navElement) + ) { + Child( + navElement, + saveableStateHolder, + transitionParams, + transitionHandler, + block + ) + } + } } - - } } } diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/BaseNavModel.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/BaseNavModel.kt index 82546b455..e14d3b460 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/BaseNavModel.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/BaseNavModel.kt @@ -69,6 +69,7 @@ abstract class BaseNavModel( state .mapState(scope) { elements -> NavModelAdapter.ScreenState( + onScreenWithVisibleTargetState = elements.filter { screenResolver.isOnScreen(it.targetState) }, onScreen = elements.filter { screenResolver.isOnScreen(it) }, offScreen = elements.filterNot { screenResolver.isOnScreen(it) }, ) diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/NavModelAdapter.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/NavModelAdapter.kt index ca524a2cb..49f0d3498 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/NavModelAdapter.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/NavModelAdapter.kt @@ -9,6 +9,11 @@ interface NavModelAdapter { val screenState: StateFlow> data class ScreenState( + /** 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 = emptyList(), val onScreen: NavElements = emptyList(), val offScreen: NavElements = emptyList(), ) diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/MovableContent.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/MovableContent.kt new file mode 100644 index 000000000..8786797f5 --- /dev/null +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/MovableContent.kt @@ -0,0 +1,186 @@ +package com.bumble.appyx.core.navigation.transition + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.movableContentWithReceiverOf +import com.bumble.appyx.core.node.LocalMovableContentMap +import com.bumble.appyx.core.node.LocalNodeTargetVisibility + + +/** + * 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 the case when 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, + content: @Composable () -> Unit +): (@Composable () -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentOf { + content() + } + } as? @Composable () -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun

localMovableContentWithTargetVisibility( + key: Any, + content: @Composable (P) -> Unit +): (@Composable (P) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentOf

{ + content(it) + } + } as? @Composable (P) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + + +@Composable +fun localMovableContentWithTargetVisibility( + key: Any, + content: @Composable (P1, P2) -> Unit +): (@Composable (P1, P2) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentOf { p1, p2 -> + content(p1, p2) + } + } as? @Composable (P1, P2) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun localMovableContentWithTargetVisibility( + key: Any, + content: @Composable (P1, P2, P3) -> Unit +): (@Composable (P1, P2, P3) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentOf { p1, p2, p3 -> + content(p1, p2, p3) + } + } as? @Composable (P1, P2, P3) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun localMovableContentWithTargetVisibility( + key: Any, + content: @Composable (P1, P2, P3, P4) -> Unit +): (@Composable (P1, P2, P3, P4) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentOf { p1, p2, p3, p4 -> + content(p1, p2, p3, p4) + } + } as? @Composable (P1, P2, P3, P4) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun localMovableContentWithReceiverAndTargetVisibility( + key: Any, + content: @Composable R.() -> Unit +): (@Composable R.() -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentWithReceiverOf { + this.content() + } + } as? @Composable R.() -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun localMovableContentWithReceiverAndTargetVisibility( + key: Any, + content: @Composable R.(P) -> Unit +): (@Composable R.(P) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentWithReceiverOf { p -> + this.content(p) + } + } as? @Composable R.(P) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun localMovableContentWithReceiverAndTargetVisibility( + key: Any, + content: @Composable R.(P1, P2) -> Unit +): (@Composable R.(P1, P2) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentWithReceiverOf { p1, p2 -> + this.content(p1, p2) + } + } as? @Composable R.(P1, P2) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +fun localMovableContentWithReceiverAndTargetVisibility( + key: Any, + content: @Composable R.(P1, P2, P3) -> Unit +): (@Composable R.(P1, P2, P3) -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = retrieveMovableContentMap() + return movableContentMap.getOrPut(key) { + movableContentWithReceiverOf { p1, p2, p3 -> + this.content(p1, p2, p3) + } + } as? @Composable R.(P1, P2, P3) -> Unit ?: throw IllegalStateException( + "Movable content for key $key is not of the expected type." + + " The same $key has been used for different types of content." + ) +} + +@Composable +private fun retrieveMovableContentMap(): MutableMap { + return requireNotNull(LocalMovableContentMap.current) { + "LocalMovableContentMap not found in the composition hierarchy." + + " Please use withMovableContent = true on Children composable." + } +} + diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/SharedElement.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/SharedElement.kt new file mode 100644 index 000000000..f5eb21c8d --- /dev/null +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/SharedElement.kt @@ -0,0 +1,63 @@ +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 +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.graphics.Path +import androidx.compose.ui.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import com.bumble.appyx.core.node.LocalNodeTargetVisibility +import com.bumble.appyx.core.node.LocalSharedElementScope + +@OptIn(ExperimentalSharedTransitionApi::class) +fun Modifier.sharedElement( + key: Any, + boundsTransform: BoundsTransform = DefaultBoundsTransform, + placeHolderSize: PlaceHolderSize = PlaceHolderSize.contentSize, + renderInOverlayDuringTransition: Boolean = true, + zIndexInOverlay: Float = 0f, + clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip = ParentClip +) = composed { + val scope = requireNotNull(LocalSharedElementScope.current) { + "LocalSharedElementScope is not provided. Please set withSharedElementTransition = true for Children composable" + } + scope.run { + this@composed.sharedElementWithCallerManagedVisibility( + boundsTransform = boundsTransform, + placeHolderSize = placeHolderSize, + renderInOverlayDuringTransition = renderInOverlayDuringTransition, + zIndexInOverlay = zIndexInOverlay, + clipInOverlayDuringTransition = clipInOverlayDuringTransition, + sharedContentState = rememberSharedContentState(key = key), + visible = LocalNodeTargetVisibility.current + ) + } +} + +@ExperimentalSharedTransitionApi +private val ParentClip: SharedTransitionScope.OverlayClip = + object : SharedTransitionScope.OverlayClip { + override fun getClipPath( + state: SharedTransitionScope.SharedContentState, + bounds: Rect, + layoutDirection: LayoutDirection, + density: Density + ): Path? { + return state.parentSharedContentState?.clipPathInOverlay + } + } + +@ExperimentalSharedTransitionApi +private val DefaultBoundsTransform = BoundsTransform { _, _ -> + spring( + stiffness = Spring.StiffnessMediumLow, + visibilityThreshold = Rect.VisibilityThreshold + ) +} diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/LocalNode.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/LocalNode.kt index b0a8fcd96..ec283e0c2 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/LocalNode.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/LocalNode.kt @@ -1,5 +1,23 @@ package com.bumble.appyx.core.node +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionScope import androidx.compose.runtime.compositionLocalOf val LocalNode = compositionLocalOf { null } + +@OptIn(ExperimentalSharedTransitionApi::class) +val LocalSharedElementScope = compositionLocalOf { null } + +/** + * Represents the target visibility of this Node. For instance, if the underlying + * NavModel is a BackStack and the NavKey's target state is BackStack.ACTIVE this will return true + * as they ACTIVE state is visible on the screen. In the target state is STASHED or DESTROYED it will + * return false. + */ +val LocalNodeTargetVisibility = compositionLocalOf { false } + +/** + * Represents the map from which movable content can be retrieved. + */ +val LocalMovableContentMap = compositionLocalOf?> { null } diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt index 866cc4a5a..0537e9904 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/ParentNode.kt @@ -219,5 +219,4 @@ abstract class ParentNode( } - } diff --git a/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesContainerNode.kt b/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesContainerNode.kt index 7be55abe3..471256b1a 100644 --- a/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesContainerNode.kt +++ b/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesContainerNode.kt @@ -6,10 +6,10 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton import androidx.compose.material.icons.Icons import androidx.compose.material.icons.filled.ArrowBack +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState @@ -33,7 +33,7 @@ import com.bumble.appyx.navmodel.backstack.operation.newRoot import com.bumble.appyx.navmodel.backstack.operation.pop import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackSlider -import com.bumble.appyx.sample.navigtion.compose.ComposeNavigationRoot +import com.bumble.appyx.sample.navigation.compose.ComposeNavigationRoot import kotlinx.parcelize.Parcelize class SamplesContainerNode( diff --git a/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesSelectorNode.kt b/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesSelectorNode.kt index 89b4f50ad..281594bc1 100644 --- a/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesSelectorNode.kt +++ b/samples/app/src/main/kotlin/com/bumble/appyx/app/node/samples/SamplesSelectorNode.kt @@ -26,7 +26,7 @@ import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.ParentNode import com.bumble.appyx.core.node.node -import com.bumble.appyx.sample.navigtion.compose.ComposeNavigationRoot +import com.bumble.appyx.sample.navigation.compose.ComposeNavigationRoot import kotlinx.parcelize.Parcelize class SamplesSelectorNode( diff --git a/samples/navigation-compose/build.gradle.kts b/samples/navigation-compose/build.gradle.kts index fc2a079f2..3858c7078 100644 --- a/samples/navigation-compose/build.gradle.kts +++ b/samples/navigation-compose/build.gradle.kts @@ -10,7 +10,7 @@ plugins { } android { - namespace = "com.bumble.appyx.sample.navigtion.compose" + namespace = "com.bumble.appyx.sample.navigation.compose" compileSdk = libs.versions.androidCompileSdk.get().toInt() defaultConfig { diff --git a/samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationRootTest.kt b/samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationRootTest.kt similarity index 98% rename from samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationRootTest.kt rename to samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationRootTest.kt index 15e2d8593..7f7b47a64 100644 --- a/samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationRootTest.kt +++ b/samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationRootTest.kt @@ -1,4 +1,4 @@ -package com.bumble.appyx.sample.navigtion.compose +package com.bumble.appyx.sample.navigation.compose import androidx.annotation.CheckResult import androidx.compose.runtime.CompositionLocalProvider diff --git a/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationContainerNode.kt b/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationContainerNode.kt similarity index 97% rename from samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationContainerNode.kt rename to samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationContainerNode.kt index e0dfdbd64..7c580475e 100644 --- a/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationContainerNode.kt +++ b/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationContainerNode.kt @@ -1,4 +1,4 @@ -package com.bumble.appyx.sample.navigtion.compose +package com.bumble.appyx.sample.navigation.compose import android.os.Parcelable import androidx.compose.foundation.layout.Column diff --git a/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationRoot.kt b/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationRoot.kt similarity index 98% rename from samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationRoot.kt rename to samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationRoot.kt index 0f4998f7a..3dcfb8f27 100644 --- a/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigtion/compose/ComposeNavigationRoot.kt +++ b/samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/navigation/compose/ComposeNavigationRoot.kt @@ -1,4 +1,4 @@ -package com.bumble.appyx.sample.navigtion.compose +package com.bumble.appyx.sample.navigation.compose import androidx.compose.foundation.background import androidx.compose.foundation.layout.Arrangement diff --git a/samples/navmodel-samples/src/main/kotlin/com/bumble/appyx/navmodel/spotlightadvanced/transitionhandler/SpotlightAdvancedSlider.kt b/samples/navmodel-samples/src/main/kotlin/com/bumble/appyx/navmodel/spotlightadvanced/transitionhandler/SpotlightAdvancedSlider.kt index 11d6c76cc..9edb7ed28 100644 --- a/samples/navmodel-samples/src/main/kotlin/com/bumble/appyx/navmodel/spotlightadvanced/transitionhandler/SpotlightAdvancedSlider.kt +++ b/samples/navmodel-samples/src/main/kotlin/com/bumble/appyx/navmodel/spotlightadvanced/transitionhandler/SpotlightAdvancedSlider.kt @@ -39,7 +39,7 @@ class SpotlightAdvancedSlider( } ) : ModifierTransitionHandler() { - @Suppress("ModifierFactoryExtensionFunction", "MagicNumber") + @Suppress("ModifierFactoryExtensionFunction", "MagicNumber", "SuspiciousModifierThen") override fun createModifier( modifier: Modifier, transition: Transition, diff --git a/samples/sandbox/build.gradle.kts b/samples/sandbox/build.gradle.kts index b83eddb24..c6be113bd 100644 --- a/samples/sandbox/build.gradle.kts +++ b/samples/sandbox/build.gradle.kts @@ -27,8 +27,9 @@ android { } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("debug") } } buildFeatures { diff --git a/samples/sandbox/proguard-rules.pro b/samples/sandbox/proguard-rules.pro index e69de29bb..efb628c5d 100644 --- a/samples/sandbox/proguard-rules.pro +++ b/samples/sandbox/proguard-rules.pro @@ -0,0 +1,4 @@ +-dontwarn okhttp3.internal.platform.** +-dontwarn org.conscrypt.** +-dontwarn org.bouncycastle.** +-dontwarn org.openjsse.** diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/container/ContainerNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/container/ContainerNode.kt index 6ededd3a0..a32b701fa 100644 --- a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/container/ContainerNode.kt +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/container/ContainerNode.kt @@ -39,6 +39,9 @@ import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.MviCore import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.MviCoreLeafExample import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.NavModelExamples import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.Picker +import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.SharedElementExample +import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.SharedElementPager +import com.bumble.appyx.sandbox.client.container.ContainerNode.NavTarget.SharedElementWithMovableContentExample import com.bumble.appyx.sandbox.client.customisations.CustomisationsNode import com.bumble.appyx.sandbox.client.explicitnavigation.ExplicitNavigationExampleActivity import com.bumble.appyx.sandbox.client.integrationpoint.IntegrationPointExampleNode @@ -47,6 +50,8 @@ 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.SharedElementExampleNode +import com.bumble.appyx.sandbox.client.sharedelement.with_pager.SharedElementPagerParentNode import com.bumble.appyx.utils.customisations.NodeCustomisation import kotlinx.parcelize.Parcelize @@ -72,6 +77,12 @@ class ContainerNode internal constructor( @Parcelize object LazyExamples : NavTarget() + @Parcelize + object SharedElementExample : NavTarget() + + @Parcelize + object SharedElementWithMovableContentExample : NavTarget() + @Parcelize object IntegrationPointExample : NavTarget() @@ -89,6 +100,9 @@ class ContainerNode internal constructor( @Parcelize object MviCoreLeafExample : NavTarget() + + @Parcelize + object SharedElementPager : NavTarget() } @Suppress("ComplexMethod") @@ -96,15 +110,27 @@ class ContainerNode internal constructor( when (navTarget) { is Picker -> node(buildContext) { modifier -> ExamplesList(modifier) } is NavModelExamples -> NavModelExamplesNode(buildContext) + is SharedElementExample -> SharedElementExampleNode(buildContext) + is SharedElementWithMovableContentExample -> SharedElementExampleNode( + buildContext, + hasMovableContent = true + ) + is LazyExamples -> LazyListContainerNode(buildContext) is IntegrationPointExample -> IntegrationPointExampleNode(buildContext) is BlockerExample -> BlockerExampleNode(buildContext) is Customisations -> CustomisationsNode(buildContext) - is MviCoreExample -> MviCoreExampleBuilder().build(buildContext, "MVICore initial state") + is MviCoreExample -> MviCoreExampleBuilder().build( + buildContext, + "MVICore initial state" + ) + is MviCoreLeafExample -> MviCoreLeafBuilder().build( buildContext, "MVICore leaf initial state" ) + + is SharedElementPager -> SharedElementPagerParentNode(buildContext) } @Composable @@ -137,6 +163,13 @@ class ContainerNode internal constructor( label?.let { Text(it, textAlign = TextAlign.Center) } + TextButton("Shared element Pager") { backStack.push(SharedElementPager) } + TextButton("Shared element ") { backStack.push(SharedElementExample) } + TextButton("Shared element with movable content") { + backStack.push( + SharedElementWithMovableContentExample + ) + } TextButton("NavModel Examples") { backStack.push(NavModelExamples) } TextButton("Customisations Example") { backStack.push(Customisations) } TextButton("Explicit navigation example") { diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/FullScreenNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/FullScreenNode.kt new file mode 100644 index 000000000..959331ff6 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/FullScreenNode.kt @@ -0,0 +1,106 @@ +package com.bumble.appyx.sandbox.client.sharedelement + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.sharedElement +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.samples.common.profile.Profile +import com.bumble.appyx.samples.common.profile.ProfileImage + +class FullScreenNode( + private val onClick: (Int) -> Unit, + private val hasMovableContent: Boolean, + private val profileId: Int, + buildContext: BuildContext +) : Node(buildContext) { + + @Composable + override fun View(modifier: Modifier) { + if (hasMovableContent) { + SharedElementWithMovableContentContent(modifier) + } else { + SharedElementContent(modifier) + } + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + private fun SharedElementContent( + modifier: Modifier = Modifier + ) { + Box( + modifier = modifier + .fillMaxSize() + .clickable { + onClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + + ProfileImage( + Profile.allProfiles[profileId].drawableRes, modifier = Modifier + .fillMaxSize() + .sharedElement(key = "$profileId image") + ) + + Text( + text = "${profile.name}, ${profile.age}", + color = Color.White, + fontSize = 30.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$profileId text") + .align(Alignment.BottomStart) + .padding(16.dp) + ) + } + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + private fun SharedElementWithMovableContentContent( + modifier: Modifier = Modifier + ) { + Box( + modifier = modifier + .fillMaxSize() + .clickable { + onClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + + Box( + modifier = Modifier + .fillMaxSize() + .sharedElement(key = "$profileId image") + ) { + ProfileImageWithCounterMovableContent(profileId) + } + + Text( + text = "${profile.name}, ${profile.age}", + color = Color.White, + fontSize = 30.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$profileId text") + .align(Alignment.BottomStart) + .padding(16.dp) + ) + } + } +} + diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileHorizontalListNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileHorizontalListNode.kt new file mode 100644 index 000000000..dd9b79741 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileHorizontalListNode.kt @@ -0,0 +1,122 @@ +package com.bumble.appyx.sandbox.client.sharedelement + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.sharedElement +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.samples.common.profile.Profile +import com.bumble.appyx.samples.common.profile.ProfileImage + +class ProfileHorizontalListNode( + private val selectedId: Int, + private val onProfileClick: (Int) -> Unit, + private val hasMovableContent: Boolean, + buildContext: BuildContext +) : Node(buildContext) { + + @Composable + override fun View(modifier: Modifier) { + val state = rememberLazyListState(initialFirstVisibleItemIndex = selectedId) + LazyRow( + state = state, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + horizontalArrangement = Arrangement.spacedBy(32.dp), + verticalAlignment = Alignment.CenterVertically + ) { + repeat(10) { profileId -> + item(key = profileId) { + if (hasMovableContent) { + SharedElementWithMovableContentContent(profileId) + } else { + SharedElementContent(profileId) + } + } + } + } + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + private fun SharedElementContent( + profileId: Int, + modifier: Modifier = Modifier + ) { + Box( + modifier = modifier + .requiredSize(200.dp) + .clickable { + onProfileClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + + ProfileImage( + Profile.allProfiles[profileId].drawableRes, modifier = Modifier + .fillMaxSize() + .sharedElement(key = "$profileId image") + ) + Text( + text = "${profile.name}, ${profile.age}", + color = Color.White, + fontSize = 16.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$profileId text") + .align(Alignment.BottomStart) + .padding(8.dp) + ) + } + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + private fun SharedElementWithMovableContentContent( + profileId: Int, + modifier: Modifier = Modifier + ) { + Box( + modifier = modifier + .requiredSize(200.dp) + .clickable { + onProfileClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + Box( + modifier = Modifier + .fillMaxSize() + .sharedElement(key = "$profileId image") + ) { + ProfileImageWithCounterMovableContent(profileId) + } + Text( + text = "${profile.name}, ${profile.age}", + color = Color.White, + fontSize = 16.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$profileId text") + .align(Alignment.BottomStart) + .padding(8.dp) + ) + } + } +} diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileVerticalListNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileVerticalListNode.kt new file mode 100644 index 000000000..b735c215a --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileVerticalListNode.kt @@ -0,0 +1,122 @@ +package com.bumble.appyx.sandbox.client.sharedelement + +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.requiredSize +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.sharedElement +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.samples.common.profile.Profile +import com.bumble.appyx.samples.common.profile.ProfileImage + +class ProfileVerticalListNode( + private val profileId: Int, + private val onProfileClick: (Int) -> Unit, + private val hasMovableContent: Boolean, + buildContext: BuildContext +) : Node(buildContext) { + + @Composable + override fun View(modifier: Modifier) { + val state = rememberLazyListState(initialFirstVisibleItemIndex = profileId) + LazyColumn( + state = state, + modifier = modifier.fillMaxSize(), + contentPadding = PaddingValues(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(32.dp), + ) { + repeat(10) { profileId -> + item(key = profileId) { + if (hasMovableContent) { + SharedElementWithMovableContentContent(profileId) + } else { + SharedElementContent(profileId) + } + } + } + } + } + + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + private fun SharedElementContent( + profileId: Int, + modifier: Modifier = Modifier + ) { + Row( + modifier = modifier + .clickable { + onProfileClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + + ProfileImage( + Profile.allProfiles[profileId].drawableRes, modifier = Modifier + .requiredSize(64.dp) + .sharedElement(key = "$profileId image") + .clip(CircleShape) + ) + Text( + text = "${profile.name}, ${profile.age}", + color = LocalContentColor.current, + fontSize = 32.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$profileId text") + .padding(8.dp) + ) + } + } + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + private fun SharedElementWithMovableContentContent( + profileId: Int, + modifier: Modifier = Modifier + ) { + Row( + modifier = modifier + .clickable { + onProfileClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + Box( + modifier = Modifier + .requiredSize(64.dp) + .sharedElement(key = "$profileId image") + .clip(CircleShape) + ) { + ProfileImageWithCounterMovableContent(profileId) + } + Text( + text = "${profile.name}, ${profile.age}", + color = LocalContentColor.current, + fontSize = 32.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$profileId text") + .padding(8.dp) + ) + } + } +} diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementExampleNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementExampleNode.kt new file mode 100644 index 000000000..d9e750179 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementExampleNode.kt @@ -0,0 +1,120 @@ +package com.bumble.appyx.sandbox.client.sharedelement + +import android.annotation.SuppressLint +import android.os.Parcelable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.layout.Box +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.localMovableContentWithTargetVisibility +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.samples.common.profile.Profile.Companion.allProfiles +import com.bumble.appyx.samples.common.profile.ProfileImage +import kotlinx.coroutines.delay +import kotlinx.parcelize.Parcelize +import kotlin.random.Random + +class SharedElementExampleNode( + buildContext: BuildContext, + private val hasMovableContent : Boolean = false, + private val backStack: BackStack = BackStack( + savedStateMap = buildContext.savedStateMap, + initialElement = NavTarget.HorizontalList(0) + ) +) : ParentNode( + navModel = backStack, + buildContext = buildContext, +) { + + sealed class NavTarget : Parcelable { + @Parcelize + data class FullScreen(val profileId: Int) : NavTarget() + + @Parcelize + data class HorizontalList(val profileId: Int = 0) : NavTarget() + + @Parcelize + data class VerticalList(val profileId: Int = 0) : NavTarget() + } + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.HorizontalList -> ProfileHorizontalListNode( + onProfileClick = { id -> + backStack.push(NavTarget.FullScreen(id)) + }, + selectedId = navTarget.profileId, + buildContext = buildContext, + hasMovableContent = hasMovableContent + ) + + is NavTarget.VerticalList -> ProfileVerticalListNode( + onProfileClick = { id -> + backStack.push(NavTarget.HorizontalList(id)) + }, + profileId = navTarget.profileId, + buildContext = buildContext, + hasMovableContent = hasMovableContent + ) + + is NavTarget.FullScreen -> FullScreenNode( + onClick = { id -> + backStack.push(NavTarget.VerticalList(id)) + }, + profileId = navTarget.profileId, + buildContext = buildContext, + hasMovableContent = hasMovableContent + ) + } + } + + @SuppressLint("UnusedContentLambdaTargetStateParameter") + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backStack, + withSharedElementTransition = true, + withMovableContent = hasMovableContent, + transitionHandler = rememberBackstackFader(transitionSpec = { tween(300) }) + ) + } +} + +@Composable +fun ProfileImageWithCounterMovableContent(pageId: Int, modifier: Modifier = Modifier) { + localMovableContentWithTargetVisibility(key = pageId) { + var counter by remember(pageId) { mutableIntStateOf(Random.nextInt(0, 100)) } + + LaunchedEffect(Unit) { + while (true) { + delay(1000) + counter++ + } + } + Box(modifier = modifier) { + ProfileImage( + allProfiles[pageId].drawableRes, modifier = Modifier + ) + Text( + text = "$counter", + modifier = Modifier.align(Alignment.Center), + color = Color.White + ) + + } + }?.invoke() +} diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/ShareElementDetailsNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/ShareElementDetailsNode.kt new file mode 100644 index 000000000..99d6811b3 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/ShareElementDetailsNode.kt @@ -0,0 +1,4 @@ +package com.bumble.appyx.sandbox.client.sharedelement.with_pager + +class ShareElementDetailsNode { +} \ No newline at end of file diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/SharedElementPagerNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/SharedElementPagerNode.kt new file mode 100644 index 000000000..883125fdd --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/SharedElementPagerNode.kt @@ -0,0 +1,4 @@ +package com.bumble.appyx.sandbox.client.sharedelement.with_pager + +class SharedElementPagerNode { +} \ No newline at end of file diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/SharedElementPagerParentNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/SharedElementPagerParentNode.kt new file mode 100644 index 000000000..da4699c89 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/with_pager/SharedElementPagerParentNode.kt @@ -0,0 +1,146 @@ +package com.bumble.appyx.sandbox.client.sharedelement.with_pager + +import android.os.Parcelable +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring +import androidx.compose.animation.core.tween +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.pager.HorizontalPager +import androidx.compose.foundation.pager.PageSize +import androidx.compose.foundation.pager.PagerDefaults +import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +import com.bumble.appyx.core.navigation.transition.sharedElement +import com.bumble.appyx.core.node.Node +import com.bumble.appyx.core.node.ParentNode +import com.bumble.appyx.core.node.node +import com.bumble.appyx.navmodel.backstack.BackStack +import com.bumble.appyx.navmodel.backstack.operation.push +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import com.bumble.appyx.samples.common.profile.Profile +import com.bumble.appyx.samples.common.profile.ProfileImage +import kotlinx.parcelize.Parcelize + +class SharedElementPagerParentNode( + buildContext: BuildContext, + private val backStack: BackStack = BackStack( + savedStateMap = buildContext.savedStateMap, + initialElement = NavTarget.Pager + ) +) : ParentNode( + navModel = backStack, + buildContext = buildContext +) { + + sealed class NavTarget : Parcelable { + @Parcelize + data object Pager : NavTarget() + + @Parcelize + data class Detail(val index: Int) : NavTarget() + } + + @OptIn(ExperimentalSharedTransitionApi::class) + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { + return when (navTarget) { + is NavTarget.Pager -> node(buildContext) { + Box { + val pagerState = rememberPagerState { Profile.allProfiles.size } + + HorizontalPager( + state = pagerState, + pageSize = PageSize.Fill, + contentPadding = PaddingValues(horizontal = 10.dp), + flingBehavior = PagerDefaults.flingBehavior( + state = pagerState, + snapAnimationSpec = spring(dampingRatio = Spring.DampingRatioHighBouncy) + ) + ) { pageIndex -> + Column( + modifier = Modifier + .fillMaxWidth() + .clickable { + backStack.push(NavTarget.Detail(pageIndex)) + } + ) { + val profile = Profile.allProfiles[pageIndex] + + ProfileImage( + profile.drawableRes, modifier = Modifier + .fillMaxWidth() + .height(200.dp) + .sharedElement(key = "$pageIndex image") + ) + + Text( + text = "${profile.name}, ${profile.age}", + color = Color.Black, + fontSize = 30.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$pageIndex text") + .padding(16.dp) + ) + } + + } + + } + } + + is NavTarget.Detail -> node(buildContext) { + val pageIndex = navTarget.index + Box( + modifier = Modifier + .fillMaxSize() + ) { + val profile = Profile.allProfiles[pageIndex] + + ProfileImage( + profile.drawableRes, modifier = Modifier + .fillMaxSize() + .sharedElement(key = "$pageIndex image") + ) + + Text( + text = "${profile.name}, ${profile.age}", + color = Color.White, + fontSize = 30.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$pageIndex text") + .align(Alignment.BottomStart) + .padding(16.dp) + ) + } + + } + } + } + + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backStack, + withSharedElementTransition = true, + transitionHandler = rememberBackstackFader(transitionSpec = { tween(300) }) + ) + } +} \ No newline at end of file