diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5057431c9..6a7083a8f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: instrumentation-tests: name: Instrumentation tests - runs-on: macOS-latest + runs-on: ubuntu-latest timeout-minutes: 30 steps: - uses: actions/checkout@v4 @@ -119,6 +119,8 @@ jobs: emulator-options: -no-snapshot-save -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none disable-animations: true script: | + adb uninstall "com.bumble.appyx.core.test" + adb uninstall "com.bumble.appyx.interop.ribs.test" adb logcat > logcat.out & ./gradlew connectedCheck - name: Upload failed instrumentation artifacts diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d337d79..10fe35849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,8 @@ ## Pending changes +- [#701](https://github.com/bumble-tech/appyx/pull/701) – **Added**: Shared element transition and movable content support + --- ## 1.5.0 diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index 1c782f896..09c32a186 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -12,9 +12,145 @@ Below you can find the different options how to visualise `NavModel` state chang ## No transitions -Using the provided [Child-related composables](children-view.md) you'll see no transitions as a default – UI changes resulting from the NavModel's state update will be rendered instantly. +Using the provided [Child-related composables](children-view.md) you'll see no transitions as a default – UI changes resulting from the NavModel's state update will be rendered instantly. +## Shared element transitions + +To support shared element transition between two Child Nodes you need: + +1. Use the `sharedElement` Modifier with the same key on the composable you want to connect. +2. On the `Children` composable, set `withSharedElementTransition` to true and use either fader or + no transition handler at all. Using a slider will make the shared element slide away with the + rest of of the content. +3. When operation is performed on the NavModel, the shared element will be animated between the two + Child Nodes. For instance, in the example below backStack currently has NavTarget.Child1 as the + active element. Performing a push operation with NavTarget.Child2 will animate the shared element + between NodeOne and NodeTwo. Popping back to NavTarget.Child1 will animate the shared element back. + +```kotlin +class NodeOne( + buildContext: BuildContext +) : Node( + buildContext = buildContext +) { + + @Composable + override fun View(modifier: Modifier) { + Box( + modifier = Modifier + // make sure you specify the size before using sharedElement modifier + .fillMaxSize() + .sharedElement(key = "sharedContainer") + ) { /** ... */ } + } +} + +class NodeTwo( + buildContext: BuildContext +) : Node( + buildContext = buildContext +) { + + @Composable + override fun View(modifier: Modifier) { + Box( + modifier = Modifier + // make sure you specify the size before using sharedElement modifier + .requiredSize(64.dp) + .sharedElement(key = "sharedContainer") + ) { /** ... */ } + } +} + +class ParentNode( + buildContext: BuildContext, + backStack: BackStack = BackStack( + initialElement = NavTarget.Child1, + savedStateMap = buildContext.savedStateMap + ) +) : ParentNode( + buildContext = buildContext, + navModel = backStack, +) { + + override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node = + when (navTarget) { + NavTarget.Child1 -> NodeOne(buildContext) + NavTarget.Child2 -> NodeTwo(buildContext) + } + + @Composable + override fun View(modifier: Modifier) { + Children( + // or any other NavModel + navModel = backStack, + // or no transitionHandler at all. Using a slider will make the shared element slide away + // with the rest of of the content. + transitionHandler = rememberBackStackFader(), + withSharedElementTransition = true + ) + } +} + +``` + +## Transitions with movable content + +You can move composable content between two Child Nodes without losing its state. You can only move +content from a Node that is currently visible and transitioning to invisible state to a Node that +is currently invisible and transitioning to visible state as movable content is intended to be +composed once design and is moved from one part of the composition to another. + +To move content between two Child Nodes you need to use `localMovableContentWithTargetVisibility` +composable function with the correct key to retrieve existing content if it exists or put content +for this key if it doesn't exist. In addition to that, on Parent's `Children` composable you need to +set `withMovableContent` to true. + +In the example below when a NodeOne is being replaced with NodeTwo in a BackStack or Spotlight NavModel +`CustomMovableContent("movableContentKey")` will be moved from NodeOne to NodeTwo without losing its +state. + + +```kotlin +@Composable +fun CustomMovableContent(key: Any, modifier: Modifier = Modifier) { + localMovableContentWithTargetVisibility(key = key) { + // implement movable content here + var counter by remember(pageId) { mutableIntStateOf(0) } + LaunchedEffect(Unit) { + while (true) { + delay(1000) + counter++ + } + } + Text(text = "$counter") + }?.invoke() +} + +// NodeOne +@Composable +override fun View(modifier: Modifier) { + CustomMovableContent("movableContentKey") +} + +// NodeTwo +@Composable +override fun View(modifier: Modifier) { + CustomMovableContent("movableContentKey") +} + +// ParentNode +@Composable +override fun View(modifier: Modifier) { + Children( + navModel = backStack, + withMovableContent = true, + ) +} + +``` + ## Jetpack Compose default animations You can use [standard Compose animations](https://developer.android.com/jetpack/compose/animation) for embedded child `Nodes` in the view, e.g. `AnimatedVisibility`: diff --git a/gradle.properties b/gradle.properties index dac2ecdb1..55a95dc9e 100644 --- a/gradle.properties +++ b/gradle.properties @@ -4,4 +4,4 @@ org.gradle.parallel=true android.useAndroidX=true kotlin.code.style=official library.version=1.5.0 -android.experimental.lint.version=8.3.0 +android.experimental.lint.version=8.4.0-rc02 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b08607b23..a733f1f2a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -8,7 +8,7 @@ accompanist = "0.28.0" androidx-lifecycle = "2.6.1" androidx-navigation-compose = "2.5.1" coil = "2.2.1" -composeBom = "2024.04.00" +composeBom = "2024.05.00" composeCompiler = "1.5.11" ribs = "0.39.0" mvicore = "1.2.6" diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index 663f56662..a64acba6f 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -45,9 +45,9 @@ dependencies { val composeBom = platform(libs.compose.bom) api(composeBom) + api("androidx.compose.animation:animation:1.7.0-alpha08") api(project(":libraries:customisations")) api(libs.androidx.lifecycle.common) - api(libs.compose.animation.core) 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 9b27ff37e..9d4d62205 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.(child: ChildRenderer) -> Unit, @@ -101,6 +143,7 @@ class ChildrenTransitionScope( } @Composable + @SuppressLint("ComposableNaming") fun ParentNode.children( clazz: KClass, block: @Composable ChildTransitionScope.( @@ -116,6 +159,7 @@ class ChildrenTransitionScope( } } + @SuppressLint("ComposableNaming") @Composable private fun ParentNode._children( clazz: KClass, @@ -140,30 +184,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 e056d1a2f..41d6ea4e3 100644 --- a/samples/navigation-compose/build.gradle.kts +++ b/samples/navigation-compose/build.gradle.kts @@ -9,7 +9,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 cff15557b..c78fe3c6d 100644 --- a/samples/sandbox/build.gradle.kts +++ b/samples/sandbox/build.gradle.kts @@ -26,8 +26,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..612e74137 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,8 @@ 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.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 +49,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.SharedElementExampleNode import com.bumble.appyx.utils.customisations.NodeCustomisation import kotlinx.parcelize.Parcelize @@ -72,6 +75,12 @@ class ContainerNode internal constructor( @Parcelize object LazyExamples : NavTarget() + @Parcelize + object SharedElementExample : NavTarget() + + @Parcelize + object SharedElementWithMovableContentExample : NavTarget() + @Parcelize object IntegrationPointExample : NavTarget() @@ -96,11 +105,21 @@ 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" @@ -137,6 +156,12 @@ class ContainerNode internal constructor( label?.let { Text(it, textAlign = TextAlign.Center) } + 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() +}