From 9184c211175db6b22b357bdf917c2149e836ee3f Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Fri, 26 Apr 2024 10:05:37 +0100 Subject: [PATCH 01/26] Shared element transition integration --- libraries/core/build.gradle.kts | 3 +- libraries/core/detekt-baseline.xml | 9 +- .../bumble/appyx/core/composable/Children.kt | 96 +++++++++++++------ .../appyx/core/navigation/BaseNavModel.kt | 1 + .../appyx/core/navigation/NavModelAdapter.kt | 1 + .../navigation/transition/SharedElement.kt | 62 ++++++++++++ .../com/bumble/appyx/core/node/LocalNode.kt | 13 +++ .../sandbox/client/container/ContainerNode.kt | 7 ++ .../client/sharedelement/FullScreenNode.kt | 57 +++++++++++ .../ProfileHorizontalListNode.kt | 74 ++++++++++++++ .../sharedelement/ProfileVerticalListNode.kt | 73 ++++++++++++++ .../sharedelement/SharedElementFaderNode.kt | 78 +++++++++++++++ 12 files changed, 440 insertions(+), 34 deletions(-) create mode 100644 libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/SharedElement.kt create mode 100644 samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/FullScreenNode.kt create mode 100644 samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileHorizontalListNode.kt create mode 100644 samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileVerticalListNode.kt create mode 100644 samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index 663f56662..aa7a54d2a 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -47,7 +47,8 @@ dependencies { api(composeBom) api(project(":libraries:customisations")) api(libs.androidx.lifecycle.common) - api(libs.compose.animation.core) + api("androidx.compose.animation:animation:1.7.0-alpha07") + api("androidx.compose.ui:ui:1.7.0-alpha07") 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..2777e90c9 100644 --- a/libraries/core/detekt-baseline.xml +++ b/libraries/core/detekt-baseline.xml @@ -1,5 +1,8 @@ - + - - + + + CompositionLocalAllowlist:LocalNode.kt$LocalNodeTargetVisibility + CompositionLocalAllowlist:LocalNode.kt$LocalSharedElementScope + 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..4a74e1b64 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,10 @@ package com.bumble.appyx.core.composable -import androidx.compose.foundation.layout.Box +import android.annotation.SuppressLint +import androidx.compose.animation.ExperimentalSharedTransitionApi +import androidx.compose.animation.SharedTransitionLayout import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf @@ -23,15 +26,19 @@ 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.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(), + withSharedElementTransition: Boolean = false, noinline block: @Composable ChildrenTransitionScope.() -> Unit = { children { child -> child() @@ -50,18 +57,42 @@ inline fun ParentNode.Children( ) } } - Box(modifier = modifier - .onSizeChanged { - transitionBounds = it + if (withSharedElementTransition) { + SharedTransitionLayout(modifier = modifier + .onSizeChanged { + transitionBounds = it + } + ) { + CompositionLocalProvider( + LocalSharedElementScope provides this + ) { + block( + ChildrenTransitionScope( + transitionHandler = transitionHandler, + transitionParams = transitionParams, + navModel = navModel + ) + ) + } + } + } else { + SharedTransitionLayout(modifier = modifier + .onSizeChanged { + transitionBounds = it + } + ) { + CompositionLocalProvider( + LocalSharedElementScope provides null + ) { + block( + ChildrenTransitionScope( + transitionHandler = transitionHandler, + transitionParams = transitionParams, + navModel = navModel + ) + ) + } } - ) { - block( - ChildrenTransitionScope( - transitionHandler = transitionHandler, - transitionParams = transitionParams, - navModel = navModel - ) - ) } } @@ -89,6 +120,7 @@ class ChildrenTransitionScope( } @Composable + @SuppressLint("ComposableNaming") fun ParentNode.children( clazz: KClass, block: @Composable ChildTransitionScope.(child: ChildRenderer) -> Unit, @@ -101,6 +133,7 @@ class ChildrenTransitionScope( } @Composable + @SuppressLint("ComposableNaming") fun ParentNode.children( clazz: KClass, block: @Composable ChildTransitionScope.( @@ -116,6 +149,7 @@ class ChildrenTransitionScope( } } + @SuppressLint("ComposableNaming") @Composable private fun ParentNode._children( clazz: KClass, @@ -140,30 +174,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.targetStateVisible.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..90d503757 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( + targetStateVisible = 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..b95ebeb9d 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,7 @@ interface NavModelAdapter { val screenState: StateFlow> data class ScreenState( + val targetStateVisible: NavElements = emptyList(), val onScreen: NavElements = emptyList(), val offScreen: NavElements = emptyList(), ) 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..e5a460ed5 --- /dev/null +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/SharedElement.kt @@ -0,0 +1,62 @@ +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.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: SharedTransitionScope.PlaceHolderSize = SharedTransitionScope.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..caf890fd4 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,18 @@ 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 } 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..84d858f17 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,7 @@ 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.SharedElementFaderExample 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 +48,7 @@ import com.bumble.appyx.sandbox.client.list.LazyListContainerNode import com.bumble.appyx.sandbox.client.mvicoreexample.MviCoreExampleBuilder import com.bumble.appyx.sandbox.client.mvicoreexample.leaf.MviCoreLeafBuilder import com.bumble.appyx.sandbox.client.navmodels.NavModelExamplesNode +import com.bumble.appyx.sandbox.client.sharedelement.SharedElementFaderNode import com.bumble.appyx.utils.customisations.NodeCustomisation import kotlinx.parcelize.Parcelize @@ -72,6 +74,9 @@ class ContainerNode internal constructor( @Parcelize object LazyExamples : NavTarget() + @Parcelize + object SharedElementFaderExample : NavTarget() + @Parcelize object IntegrationPointExample : NavTarget() @@ -96,6 +101,7 @@ class ContainerNode internal constructor( when (navTarget) { is Picker -> node(buildContext) { modifier -> ExamplesList(modifier) } is NavModelExamples -> NavModelExamplesNode(buildContext) + is SharedElementFaderExample -> SharedElementFaderNode(buildContext) is LazyExamples -> LazyListContainerNode(buildContext) is IntegrationPointExample -> IntegrationPointExampleNode(buildContext) is BlockerExample -> BlockerExampleNode(buildContext) @@ -137,6 +143,7 @@ class ContainerNode internal constructor( label?.let { Text(it, textAlign = TextAlign.Center) } + TextButton("Shared Element Fader") { backStack.push(SharedElementFaderExample) } 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..6890b1135 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/FullScreenNode.kt @@ -0,0 +1,57 @@ +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 profileId: Int, + buildContext: BuildContext +) : Node(buildContext) { + + @OptIn(ExperimentalSharedTransitionApi::class) + @Composable + override fun View(modifier: Modifier) { + Box( + modifier = modifier + .fillMaxSize() + .clickable { + onClick(profileId) + } + ) { + val profile = Profile.allProfiles[profileId] + ProfileImage( + profile.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) + ) + } + } +} 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..443e0f10a --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileHorizontalListNode.kt @@ -0,0 +1,74 @@ +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, + buildContext: BuildContext +) : Node(buildContext) { + + @OptIn(ExperimentalSharedTransitionApi::class) + @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) { pageId -> + item(key = pageId) { + Box( + modifier = Modifier + .requiredSize(200.dp) + .clickable { + onProfileClick(pageId) + } + ) { + val profile = Profile.allProfiles[pageId] + ProfileImage( + profile.drawableRes, modifier = Modifier + .fillMaxSize() + .sharedElement(key = "$pageId image") + ) + Text( + text = "${profile.name}, ${profile.age}", + color = Color.White, + fontSize = 16.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$pageId 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..8b3d466a2 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/ProfileVerticalListNode.kt @@ -0,0 +1,73 @@ +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.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.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +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 ProfileVerticalListNode( + private val profileId: Int, + private val onProfileClick: (Int) -> Unit, + buildContext: BuildContext +) : Node(buildContext) { + + @OptIn(ExperimentalSharedTransitionApi::class) + @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) { pageId -> + item(key = pageId) { + Row( + modifier = Modifier + .clickable { + onProfileClick(pageId) + } + ) { + val profile = Profile.allProfiles[pageId] + ProfileImage( + profile.drawableRes, modifier = Modifier + .requiredSize(64.dp) + .sharedElement(key = "$pageId image") + .clip(CircleShape) + ) + Text( + text = "${profile.name}, ${profile.age}", + color = Color.Black, + fontSize = 32.sp, + modifier = Modifier + .fillMaxWidth() + .sharedElement(key = "$pageId text") + .padding(8.dp) + ) + } + } + } + } + } +} diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt new file mode 100644 index 000000000..d9e4c4bd5 --- /dev/null +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt @@ -0,0 +1,78 @@ +package com.bumble.appyx.sandbox.client.sharedelement + +import android.annotation.SuppressLint +import android.os.Parcelable +import androidx.compose.animation.core.tween +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import com.bumble.appyx.core.composable.Children +import com.bumble.appyx.core.modality.BuildContext +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.operation.replace +import com.bumble.appyx.navmodel.backstack.transitionhandler.rememberBackstackFader +import kotlinx.parcelize.Parcelize + +class SharedElementFaderNode( + buildContext: BuildContext, + 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 + ) + + is NavTarget.VerticalList -> ProfileVerticalListNode( + onProfileClick = { id -> + backStack.replace(NavTarget.HorizontalList(id)) + }, + profileId = navTarget.profileId, + buildContext = buildContext + ) + + is NavTarget.FullScreen -> FullScreenNode( + onClick = { id -> + backStack.push(NavTarget.VerticalList(id)) + }, + profileId = navTarget.profileId, + buildContext = buildContext + ) + } + } + + @SuppressLint("UnusedContentLambdaTargetStateParameter") + @Composable + override fun View(modifier: Modifier) { + Children( + navModel = backStack, + withSharedElementTransition = true, + transitionHandler = rememberBackstackFader(transitionSpec = { tween(300) }) + ) + } +} From e2b687ab6676c80059b445384606b073d50d375d Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Fri, 26 Apr 2024 10:13:01 +0100 Subject: [PATCH 02/26] Update CHANGELOG.md --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 08d337d79..cf1c18a37 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 support + --- ## 1.5.0 From 984a51203eb877743e06626d64aed3ad2fbe9d56 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Fri, 26 Apr 2024 10:25:36 +0100 Subject: [PATCH 03/26] Replace SharedTransitionLayout with Box where no sharedElement is supported --- .../main/kotlin/com/bumble/appyx/core/composable/Children.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 4a74e1b64..4f920b87f 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 @@ -3,6 +3,7 @@ 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.LaunchedEffect @@ -76,7 +77,7 @@ inline fun ParentNode.Children( } } } else { - SharedTransitionLayout(modifier = modifier + Box(modifier = modifier .onSizeChanged { transitionBounds = it } From c1c570d23ca126c607b964831808bb8df88d6f5e Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Fri, 26 Apr 2024 10:51:33 +0100 Subject: [PATCH 04/26] Update crashing lint --- gradle.properties | 2 +- .../transitionhandler/SpotlightAdvancedSlider.kt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/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, From 06a138cdcb87ce591267e32777118311caa5bdab Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Mon, 29 Apr 2024 11:39:31 +0100 Subject: [PATCH 05/26] Movable content support and test for targetVisibilityState --- .../node/BackStackTargetVisibilityTest.kt | 89 +++++++++++++++ .../node/SpotlightTargetVisibilityTest.kt | 104 ++++++++++++++++++ .../bumble/appyx/core/composable/Children.kt | 5 +- .../appyx/core/navigation/BaseNavModel.kt | 2 +- .../appyx/core/navigation/NavModelAdapter.kt | 6 +- .../navigation/transition/MovableContent.kt | 36 ++++++ .../navigation/transition/SharedElement.kt | 3 +- .../com/bumble/appyx/core/node/LocalNode.kt | 4 + .../kotlin/com/bumble/appyx/core/node/Node.kt | 11 +- .../com/bumble/appyx/core/node/ParentNode.kt | 12 +- .../sandbox/client/container/ContainerNode.kt | 4 +- .../client/sharedelement/FullScreenNode.kt | 11 +- .../ProfileHorizontalListNode.kt | 21 ++-- .../sharedelement/ProfileVerticalListNode.kt | 10 +- ...edElementWithMovableContentExampleNode.kt} | 47 +++++++- 15 files changed, 333 insertions(+), 32 deletions(-) create mode 100644 libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/BackStackTargetVisibilityTest.kt create mode 100644 libraries/core/src/androidTest/kotlin/com/bumble/appyx/core/node/SpotlightTargetVisibilityTest.kt create mode 100644 libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/MovableContent.kt rename samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/{SharedElementFaderNode.kt => SharedElementWithMovableContentExampleNode.kt} (61%) 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 4f920b87f..f772f8aa3 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 @@ -65,6 +65,7 @@ inline fun ParentNode.Children( } ) { CompositionLocalProvider( + /** LocalSharedElementScope will be consumed by children UI to apply shareElement modifier */ LocalSharedElementScope provides this ) { block( @@ -83,6 +84,8 @@ inline fun ParentNode.Children( } ) { CompositionLocalProvider( + /** If sharedElement is not supported for this Node - provide null otherwise children + * can consume ascendant's LocalSharedElementScope */ LocalSharedElementScope provides null ) { block( @@ -190,7 +193,7 @@ class ChildrenTransitionScope( key(navElement.key.id) { CompositionLocalProvider( LocalNodeTargetVisibility provides - children.targetStateVisible.contains(navElement) + children.onScreenWithVisibleTargetState.contains(navElement) ) { Child( navElement, 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 90d503757..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,7 +69,7 @@ abstract class BaseNavModel( state .mapState(scope) { elements -> NavModelAdapter.ScreenState( - targetStateVisible = elements.filter { screenResolver.isOnScreen(it.targetState) }, + onScreenWithVisibleTargetState = elements.filter { screenResolver.isOnScreen(it.targetState) }, onScreen = elements.filter { screenResolver.isOnScreen(it) }, offScreen = elements.filterNot { screenResolver.isOnScreen(it) }, ) 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 b95ebeb9d..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,7 +9,11 @@ interface NavModelAdapter { val screenState: StateFlow> data class ScreenState( - val targetStateVisible: NavElements = emptyList(), + /** onScreenWithVisibleTargetState represents the list of NavElements that have a target state + * as visible. For instance if the NavModel is a BackStack it will represent the element that + * is transitioning to ACTIVE state. + */ + val onScreenWithVisibleTargetState: NavElements = 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..fa7ee35aa --- /dev/null +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/navigation/transition/MovableContent.kt @@ -0,0 +1,36 @@ +package com.bumble.appyx.core.navigation.transition + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.movableContentOf +import com.bumble.appyx.core.node.LocalNodeTargetVisibility +import com.bumble.appyx.core.node.LocalParentNodeMovableContent + + +/** + * Returns movable content for the given key. To reuse composable across Nodes movable content + * must to be invoked only once at any time, therefore we should return null for if the Node is + * transitioning to an invisible target and return movable content only if the targetState is visible. + * + * Example: ParentNode (P) has BackStack with one Active Node (A). We push Node (B) to the BackStack, + * and we want to move content from Node (A) to Node (B). Node (A) is transitioning from Active to + * Stashed (invisible) state and Node (B) is transitioning from Created to Active (visible) state. + * When this transition starts this function will return null for Node (A) and movable content for + * Node (B)so that this content will be moved from Node (A) to Node (B). + * + * If you have a custom NavModel keep in mind that you can only move content from a visible Node + * that becomes invisible to a Node that is becoming visible. + */ +@Composable +fun localMovableContentWithTargetVisibility( + key: Any, + defaultValue: @Composable () -> Unit +): (@Composable () -> Unit)? { + if (!LocalNodeTargetVisibility.current) return null + val movableContentMap = LocalParentNodeMovableContent.current + return movableContentMap.getOrPut(key) { + movableContentOf { + defaultValue() + } + } +} + 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 index e5a460ed5..f5eb21c8d 100644 --- 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 @@ -3,6 +3,7 @@ package com.bumble.appyx.core.navigation.transition import androidx.compose.animation.BoundsTransform import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.animation.SharedTransitionScope.PlaceHolderSize import androidx.compose.animation.core.Spring import androidx.compose.animation.core.VisibilityThreshold import androidx.compose.animation.core.spring @@ -19,7 +20,7 @@ import com.bumble.appyx.core.node.LocalSharedElementScope fun Modifier.sharedElement( key: Any, boundsTransform: BoundsTransform = DefaultBoundsTransform, - placeHolderSize: SharedTransitionScope.PlaceHolderSize = SharedTransitionScope.PlaceHolderSize.contentSize, + placeHolderSize: PlaceHolderSize = PlaceHolderSize.contentSize, renderInOverlayDuringTransition: Boolean = true, zIndexInOverlay: Float = 0f, clipInOverlayDuringTransition: SharedTransitionScope.OverlayClip = ParentClip 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 caf890fd4..2d9c5cf8e 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 @@ -2,6 +2,7 @@ package com.bumble.appyx.core.node import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope +import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf val LocalNode = compositionLocalOf { null } @@ -16,3 +17,6 @@ val LocalSharedElementScope = compositionLocalOf { null * return false. */ val LocalNodeTargetVisibility = compositionLocalOf { false } + +val LocalParentNodeMovableContent = + compositionLocalOf Unit>> { mutableMapOf() } diff --git a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt index 2ffcceef7..d9974b293 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt @@ -123,11 +123,18 @@ open class Node @VisibleForTesting internal constructor( LocalNode provides this, LocalLifecycleOwner provides this, ) { - HandleBackPress() - View(modifier) + DerivedSetup { + HandleBackPress() + View(modifier) + } } } + @Composable + protected open fun DerivedSetup(innerContent: @Composable () -> Unit) { + innerContent() + } + @Composable private fun HandleBackPress() { // can't use BackHandler Composable because plugins provide OnBackPressedCallback which is not observable 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..61f33f571 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 @@ -2,6 +2,8 @@ package com.bumble.appyx.core.node import androidx.annotation.CallSuper import androidx.annotation.VisibleForTesting +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -74,6 +76,15 @@ abstract class ParentNode( manageTransitions() } + @Composable + final override fun DerivedSetup(innerContent: @Composable () -> Unit) { + CompositionLocalProvider( + LocalParentNodeMovableContent provides mutableMapOf() + ) { + innerContent() + } + } + fun childOrCreate(navKey: NavKey): ChildEntry.Initialized = childNodeCreationManager.childOrCreate(navKey) @@ -219,5 +230,4 @@ abstract class ParentNode( } - } 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 84d858f17..2f825caa1 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 @@ -48,7 +48,7 @@ import com.bumble.appyx.sandbox.client.list.LazyListContainerNode import com.bumble.appyx.sandbox.client.mvicoreexample.MviCoreExampleBuilder import com.bumble.appyx.sandbox.client.mvicoreexample.leaf.MviCoreLeafBuilder import com.bumble.appyx.sandbox.client.navmodels.NavModelExamplesNode -import com.bumble.appyx.sandbox.client.sharedelement.SharedElementFaderNode +import com.bumble.appyx.sandbox.client.sharedelement.SharedElementWithMovableContentExampleNode import com.bumble.appyx.utils.customisations.NodeCustomisation import kotlinx.parcelize.Parcelize @@ -101,7 +101,7 @@ class ContainerNode internal constructor( when (navTarget) { is Picker -> node(buildContext) { modifier -> ExamplesList(modifier) } is NavModelExamples -> NavModelExamplesNode(buildContext) - is SharedElementFaderExample -> SharedElementFaderNode(buildContext) + is SharedElementFaderExample -> SharedElementWithMovableContentExampleNode(buildContext) is LazyExamples -> LazyListContainerNode(buildContext) is IntegrationPointExample -> IntegrationPointExampleNode(buildContext) is BlockerExample -> BlockerExampleNode(buildContext) 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 index 6890b1135..5506d4e37 100644 --- 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 @@ -17,7 +17,6 @@ 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, @@ -36,11 +35,15 @@ class FullScreenNode( } ) { val profile = Profile.allProfiles[profileId] - ProfileImage( - profile.drawableRes, modifier = Modifier + + + Box( + modifier = Modifier .fillMaxSize() .sharedElement(key = "$profileId image") - ) + ) { + ProfileImageWithCounterMovableContent(profileId) + } Text( text = "${profile.name}, ${profile.age}", 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 index 443e0f10a..ddca47c88 100644 --- 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 @@ -22,7 +22,6 @@ 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, @@ -41,28 +40,30 @@ class ProfileHorizontalListNode( horizontalArrangement = Arrangement.spacedBy(32.dp), verticalAlignment = Alignment.CenterVertically ) { - repeat(10) { pageId -> - item(key = pageId) { + repeat(10) { profileId -> + item(key = profileId) { Box( modifier = Modifier .requiredSize(200.dp) .clickable { - onProfileClick(pageId) + onProfileClick(profileId) } ) { - val profile = Profile.allProfiles[pageId] - ProfileImage( - profile.drawableRes, modifier = Modifier + val profile = Profile.allProfiles[profileId] + Box( + modifier = Modifier .fillMaxSize() - .sharedElement(key = "$pageId image") - ) + .sharedElement(key = "$profileId image") + ) { + ProfileImageWithCounterMovableContent(profileId) + } Text( text = "${profile.name}, ${profile.age}", color = Color.White, fontSize = 16.sp, modifier = Modifier .fillMaxWidth() - .sharedElement(key = "$pageId text") + .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 index 8b3d466a2..4e7b29d39 100644 --- 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 @@ -3,6 +3,7 @@ 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 @@ -23,7 +24,6 @@ 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, @@ -50,12 +50,14 @@ class ProfileVerticalListNode( } ) { val profile = Profile.allProfiles[pageId] - ProfileImage( - profile.drawableRes, modifier = Modifier + Box( + modifier = Modifier .requiredSize(64.dp) .sharedElement(key = "$pageId image") .clip(CircleShape) - ) + ) { + ProfileImageWithCounterMovableContent(pageId) + } Text( text = "${profile.name}, ${profile.age}", color = Color.Black, diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt similarity index 61% rename from samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt rename to samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt index d9e4c4bd5..3a6c19cee 100644 --- a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementFaderNode.kt +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt @@ -3,30 +3,42 @@ 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.operation.replace 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 SharedElementFaderNode( +class SharedElementWithMovableContentExampleNode( buildContext: BuildContext, private val backStack: BackStack = BackStack( savedStateMap = buildContext.savedStateMap, initialElement = NavTarget.HorizontalList(0) ) -) : ParentNode( +) : ParentNode( navModel = backStack, buildContext = buildContext, ) { - sealed class NavTarget : Parcelable { @Parcelize data class FullScreen(val profileId: Int) : NavTarget() @@ -50,7 +62,7 @@ class SharedElementFaderNode( is NavTarget.VerticalList -> ProfileVerticalListNode( onProfileClick = { id -> - backStack.replace(NavTarget.HorizontalList(id)) + backStack.push(NavTarget.HorizontalList(id)) }, profileId = navTarget.profileId, buildContext = buildContext @@ -76,3 +88,28 @@ class SharedElementFaderNode( ) } } + +@Composable +fun ProfileImageWithCounterMovableContent(pageId: Int) { + 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() +} From 18ef892ebcc59cb1522aca828a6b16ed62acf254 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Mon, 29 Apr 2024 23:02:54 +0100 Subject: [PATCH 06/26] Update detekt-baseline.xml --- libraries/core/detekt-baseline.xml | 1 + .../SharedElementWithMovableContentExampleNode.kt | 4 ++-- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/libraries/core/detekt-baseline.xml b/libraries/core/detekt-baseline.xml index 2777e90c9..f3cb1f93a 100644 --- a/libraries/core/detekt-baseline.xml +++ b/libraries/core/detekt-baseline.xml @@ -3,6 +3,7 @@ CompositionLocalAllowlist:LocalNode.kt$LocalNodeTargetVisibility + CompositionLocalAllowlist:LocalNode.kt$LocalParentNodeMovableContent CompositionLocalAllowlist:LocalNode.kt$LocalSharedElementScope diff --git a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt index 3a6c19cee..58baac8a9 100644 --- a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt @@ -90,7 +90,7 @@ class SharedElementWithMovableContentExampleNode( } @Composable -fun ProfileImageWithCounterMovableContent(pageId: Int) { +fun ProfileImageWithCounterMovableContent(pageId: Int, modifier: Modifier = Modifier) { localMovableContentWithTargetVisibility(key = pageId) { var counter by remember(pageId) { mutableIntStateOf(Random.nextInt(0, 100)) } @@ -100,7 +100,7 @@ fun ProfileImageWithCounterMovableContent(pageId: Int) { counter++ } } - Box(modifier = Modifier) { + Box(modifier = modifier) { ProfileImage( allProfiles[pageId].drawableRes, modifier = Modifier ) From bec8f87cd3579bcb557d0293ceac8eb6ffb1a848 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Tue, 30 Apr 2024 08:27:05 +0100 Subject: [PATCH 07/26] Rename button to Shared element with movable content --- .../com/bumble/appyx/sandbox/client/container/ContainerNode.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 2f825caa1..b46a90de0 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 @@ -143,7 +143,7 @@ class ContainerNode internal constructor( label?.let { Text(it, textAlign = TextAlign.Center) } - TextButton("Shared Element Fader") { backStack.push(SharedElementFaderExample) } + TextButton("Shared element with movable content") { backStack.push(SharedElementFaderExample) } TextButton("NavModel Examples") { backStack.push(NavModelExamples) } TextButton("Customisations Example") { backStack.push(Customisations) } TextButton("Explicit navigation example") { From 378564ae66cdd01953b0b730832b03366a70f264 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Tue, 30 Apr 2024 08:36:25 +0100 Subject: [PATCH 08/26] Update emulator API --- .github/workflows/build.yml | 2 +- documentation/ui/transitions.md | 3 +-- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5057431c9..394bd2166 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 diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index 1c782f896..f2bdfad7a 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -12,8 +12,7 @@ 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. ## Jetpack Compose default animations From 53bed477384762d338fe065d7dd19ff23fca915b Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Tue, 30 Apr 2024 16:09:15 +0100 Subject: [PATCH 09/26] Add documentation for movable content and shared element transition --- documentation/ui/transitions.md | 123 ++++++++++++++++++ .../bumble/appyx/core/composable/Children.kt | 4 +- .../sharedelement/ProfileVerticalListNode.kt | 4 +- 3 files changed, 128 insertions(+), 3 deletions(-) diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index f2bdfad7a..bfcb93e6e 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -14,6 +14,129 @@ Below you can find the different options how to visualise `NavModel` state chang 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 sharedElementModifier 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 + .fillMaxSize() + .sharedElement(key = "sharedContainer") + ) { /** ... */ } + } +} +class NodeOne( + buildContext: BuildContext +) : Node( + buildContext = buildContext +) { + + @Composable + override fun View(modifier: Modifier) { + Box( + modifier = 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() + ) + } +} + +``` + +## 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 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") +} + +``` + ## 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/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 f772f8aa3..58e672da4 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 @@ -6,6 +6,7 @@ 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 @@ -38,7 +39,7 @@ import kotlin.reflect.KClass inline fun ParentNode.Children( navModel: NavModel, modifier: Modifier = Modifier, - transitionHandler: TransitionHandler = JumpToEndTransitionHandler(), + transitionHandler: TransitionHandler = remember { JumpToEndTransitionHandler() }, withSharedElementTransition: Boolean = false, noinline block: @Composable ChildrenTransitionScope.() -> Unit = { children { child -> @@ -100,6 +101,7 @@ inline fun ParentNode.Children( } } +@Immutable class ChildrenTransitionScope( private val transitionHandler: TransitionHandler, private val transitionParams: TransitionParams, 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 index 4e7b29d39..39f9196e6 100644 --- 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 @@ -13,11 +13,11 @@ 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.graphics.Color import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import com.bumble.appyx.core.modality.BuildContext @@ -60,7 +60,7 @@ class ProfileVerticalListNode( } Text( text = "${profile.name}, ${profile.age}", - color = Color.Black, + color = LocalContentColor.current, fontSize = 32.sp, modifier = Modifier .fillMaxWidth() From 734b49f83523751931fc3ec4c9639a5bd6d2f024 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Tue, 30 Apr 2024 16:29:33 +0100 Subject: [PATCH 10/26] Update CHANGELOG.md --- CHANGELOG.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index cf1c18a37..10fe35849 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,7 +2,7 @@ ## Pending changes -- [#701](https://github.com/bumble-tech/appyx/pull/701) – **Added**: Shared element transition support +- [#701](https://github.com/bumble-tech/appyx/pull/701) – **Added**: Shared element transition and movable content support --- From 960e194fb99c056ce45e2676b1958c60abc77b9e Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Tue, 30 Apr 2024 17:12:31 +0100 Subject: [PATCH 11/26] Add docs and uninstall test app --- .github/workflows/build.yml | 1 + documentation/ui/transitions.md | 2 ++ 2 files changed, 3 insertions(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 394bd2166..8b37558da 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -119,6 +119,7 @@ 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 logcat > logcat.out & ./gradlew connectedCheck - name: Upload failed instrumentation artifacts diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index bfcb93e6e..9845e251a 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -39,6 +39,7 @@ class NodeOne( override fun View(modifier: Modifier) { Box( modifier = Modifier + // make sure you specify the size before using sharedElement modifier .fillMaxSize() .sharedElement(key = "sharedContainer") ) { /** ... */ } @@ -54,6 +55,7 @@ class NodeOne( override fun View(modifier: Modifier) { Box( modifier = Modifier + // make sure you specify the size before using sharedElement modifier .requiredSize(64.dp) .sharedElement(key = "sharedContainer") ) { /** ... */ } From 3d45d4862e2c5d14c68e4083bdc27627d4c6cd84 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 1 May 2024 10:39:28 +0100 Subject: [PATCH 12/26] Uninstall RIBs test apk --- .github/workflows/build.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 8b37558da..6a7083a8f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -120,6 +120,7 @@ jobs: 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 From 7dfbbfb1bdb12b42caa52f472eadc9f18559fe7b Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 1 May 2024 10:57:16 +0100 Subject: [PATCH 13/26] Rename package and uninstall navigation compose test apk --- samples/navigation-compose/build.gradle.kts | 2 +- .../appyx/sample/navigtion/compose/ComposeNavigationRootTest.kt | 1 + .../compose/ComposeNavigationContainerNode.kt | 2 +- .../{navigtion => navigation}/compose/ComposeNavigationRoot.kt | 2 +- samples/sandbox/build.gradle.kts | 1 + 5 files changed, 5 insertions(+), 3 deletions(-) rename samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/{navigtion => navigation}/compose/ComposeNavigationContainerNode.kt (97%) rename samples/navigation-compose/src/main/kotlin/com/bumble/appyx/sample/{navigtion => navigation}/compose/ComposeNavigationRoot.kt (98%) 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/navigtion/compose/ComposeNavigationRootTest.kt index 15e2d8593..760a82417 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/navigtion/compose/ComposeNavigationRootTest.kt @@ -6,6 +6,7 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.bumble.appyx.core.integrationpoint.LocalIntegrationPoint +import com.bumble.appyx.sample.navigation.compose.ComposeNavigationRoot import com.bumble.appyx.testing.ui.rules.AppyxTestActivity import org.junit.Rule import org.junit.Test 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/sandbox/build.gradle.kts b/samples/sandbox/build.gradle.kts index cff15557b..3beae0ada 100644 --- a/samples/sandbox/build.gradle.kts +++ b/samples/sandbox/build.gradle.kts @@ -28,6 +28,7 @@ android { release { isMinifyEnabled = false proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + signingConfig = signingConfigs.getByName("debug") } } buildFeatures { From 592133a5941c37368bd92d24449d1a97195d7bf7 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 1 May 2024 11:04:50 +0100 Subject: [PATCH 14/26] Fix compilation error --- .../bumble/appyx/app/node/samples/SamplesContainerNode.kt | 6 +++--- .../bumble/appyx/app/node/samples/SamplesSelectorNode.kt | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) 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( From 155d01e86c56c7f8d089e8341ca07e317b666133 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 1 May 2024 11:04:50 +0100 Subject: [PATCH 15/26] Fix compilation error --- .github/workflows/build.yml | 1 + .../bumble/appyx/app/node/samples/SamplesContainerNode.kt | 6 +++--- .../bumble/appyx/app/node/samples/SamplesSelectorNode.kt | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 6a7083a8f..5ddf640d2 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,6 +121,7 @@ jobs: script: | adb uninstall "com.bumble.appyx.core.test" adb uninstall "com.bumble.appyx.interop.ribs.test" + adb uninstall "com.bumble.appyx.sample.navigation.compose.test" adb logcat > logcat.out & ./gradlew connectedCheck - name: Upload failed instrumentation artifacts 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( From c3290b33baea4a8982e9d96825d01979034c8805 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 1 May 2024 11:47:06 +0100 Subject: [PATCH 16/26] Add proguard rules release to sandbox app --- samples/sandbox/build.gradle.kts | 2 +- samples/sandbox/proguard-rules.pro | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/samples/sandbox/build.gradle.kts b/samples/sandbox/build.gradle.kts index 3beae0ada..c78fe3c6d 100644 --- a/samples/sandbox/build.gradle.kts +++ b/samples/sandbox/build.gradle.kts @@ -26,7 +26,7 @@ android { } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") signingConfig = signingConfigs.getByName("debug") } 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.** From def253f14ae948dcddaec614d14bb280fb36f9ab Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 1 May 2024 13:45:17 +0100 Subject: [PATCH 17/26] Fix testing package name --- .../compose/ComposeNavigationRootTest.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) rename samples/navigation-compose/src/androidTest/kotlin/com/bumble/appyx/sample/{navigtion => navigation}/compose/ComposeNavigationRootTest.kt (95%) 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 95% 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 760a82417..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 @@ -6,7 +6,6 @@ import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.performClick import com.bumble.appyx.core.integrationpoint.LocalIntegrationPoint -import com.bumble.appyx.sample.navigation.compose.ComposeNavigationRoot import com.bumble.appyx.testing.ui.rules.AppyxTestActivity import org.junit.Rule import org.junit.Test From a6d22a11f47447b13dde912208349bb279fc326a Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Thu, 2 May 2024 10:36:55 +0100 Subject: [PATCH 18/26] Update compose to alpha08 --- gradle/libs.versions.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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" From ade37a3e17b841bc6ca77d3c7125d526d150c8f7 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Thu, 2 May 2024 10:37:36 +0100 Subject: [PATCH 19/26] Temporary remove uninstalling com.bumble.appyx.sample.navigation.compose.test --- .github/workflows/build.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 5ddf640d2..6a7083a8f 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -121,7 +121,6 @@ jobs: script: | adb uninstall "com.bumble.appyx.core.test" adb uninstall "com.bumble.appyx.interop.ribs.test" - adb uninstall "com.bumble.appyx.sample.navigation.compose.test" adb logcat > logcat.out & ./gradlew connectedCheck - name: Upload failed instrumentation artifacts From 5131031557fdf77213eda1310e682f5bc01f6d4f Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Thu, 2 May 2024 11:38:30 +0100 Subject: [PATCH 20/26] Update to alpha08 --- libraries/core/build.gradle.kts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/libraries/core/build.gradle.kts b/libraries/core/build.gradle.kts index aa7a54d2a..a64acba6f 100644 --- a/libraries/core/build.gradle.kts +++ b/libraries/core/build.gradle.kts @@ -45,10 +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("androidx.compose.animation:animation:1.7.0-alpha07") - api("androidx.compose.ui:ui:1.7.0-alpha07") api(libs.compose.runtime) api(libs.androidx.appcompat) api(libs.kotlin.coroutines.android) From e9e5b4f91b0a7c806ecd8f4ac5b746c91f2ea67f Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Tue, 7 May 2024 08:08:03 +0100 Subject: [PATCH 21/26] Update documentation/ui/transitions.md Co-authored-by: Manuel Vivo --- documentation/ui/transitions.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index 9845e251a..135422834 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -19,7 +19,7 @@ Using the provided [Child-related composables](children-view.md) you'll see no t To support shared element transition between two Child Nodes you need: -1. Use sharedElementModifier with the same key on the composable you want to connect. +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. From 8a1c9d1feb0d20503cb0b7d65fe93e5831fa8218 Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Tue, 7 May 2024 08:08:14 +0100 Subject: [PATCH 22/26] Update documentation/ui/transitions.md Co-authored-by: Manuel Vivo --- documentation/ui/transitions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index 135422834..24908b21e 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -45,7 +45,8 @@ class NodeOne( ) { /** ... */ } } } -class NodeOne( + +class NodeTwo( buildContext: BuildContext ) : Node( buildContext = buildContext From aeb4eb52dd36823e6d8b57f2d48b4aebd2219f96 Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Tue, 7 May 2024 08:08:20 +0100 Subject: [PATCH 23/26] Update libraries/core/src/main/kotlin/com/bumble/appyx/core/composable/Children.kt Co-authored-by: Manuel Vivo --- .../main/kotlin/com/bumble/appyx/core/composable/Children.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 58e672da4..6071ad806 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 @@ -86,7 +86,7 @@ inline fun ParentNode.Children( ) { CompositionLocalProvider( /** If sharedElement is not supported for this Node - provide null otherwise children - * can consume ascendant's LocalSharedElementScope */ + * can consume ascendant's LocalSharedElementScope */ LocalSharedElementScope provides null ) { block( From 695b88cdab46f127db1cd4c0a2a51827774367a6 Mon Sep 17 00:00:00 2001 From: Andrey Kovalev Date: Tue, 7 May 2024 08:08:26 +0100 Subject: [PATCH 24/26] Update documentation/ui/transitions.md Co-authored-by: Manuel Vivo --- documentation/ui/transitions.md | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index 24908b21e..92d16ba63 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -87,7 +87,8 @@ class ParentNode( 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() + transitionHandler = rememberBackStackFader(), + withSharedElementTransition = true ) } } From 76d6c1fcc33c560ffaaca8d80bd31b28dbfbcd34 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Tue, 7 May 2024 17:25:22 +0100 Subject: [PATCH 25/26] - Added shared element example without movable content - Moved LocalMovableContentMap to Children composable and added a parameter to it. - Added support for more movableContent API variants --- libraries/core/detekt-baseline.xml | 2 +- .../bumble/appyx/core/composable/Children.kt | 8 +- .../navigation/transition/MovableContent.kt | 162 +++++++++++++++++- .../com/bumble/appyx/core/node/LocalNode.kt | 7 +- .../kotlin/com/bumble/appyx/core/node/Node.kt | 11 +- .../com/bumble/appyx/core/node/ParentNode.kt | 11 -- .../sandbox/client/container/ContainerNode.kt | 30 +++- .../client/sharedelement/FullScreenNode.kt | 48 +++++- .../ProfileHorizontalListNode.kt | 99 ++++++++--- .../sharedelement/ProfileVerticalListNode.kt | 101 ++++++++--- ...pleNode.kt => SharedElementExampleNode.kt} | 15 +- 11 files changed, 397 insertions(+), 97 deletions(-) rename samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/{SharedElementWithMovableContentExampleNode.kt => SharedElementExampleNode.kt} (88%) diff --git a/libraries/core/detekt-baseline.xml b/libraries/core/detekt-baseline.xml index f3cb1f93a..689191e73 100644 --- a/libraries/core/detekt-baseline.xml +++ b/libraries/core/detekt-baseline.xml @@ -2,8 +2,8 @@ + CompositionLocalAllowlist:LocalNode.kt$LocalMovableContentMap CompositionLocalAllowlist:LocalNode.kt$LocalNodeTargetVisibility - CompositionLocalAllowlist:LocalNode.kt$LocalParentNodeMovableContent CompositionLocalAllowlist:LocalNode.kt$LocalSharedElementScope 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 6071ad806..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 @@ -28,6 +28,7 @@ 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 @@ -41,6 +42,7 @@ inline fun ParentNode.Children( modifier: Modifier = Modifier, transitionHandler: TransitionHandler = remember { JumpToEndTransitionHandler() }, withSharedElementTransition: Boolean = false, + withMovableContent: Boolean = false, noinline block: @Composable ChildrenTransitionScope.() -> Unit = { children { child -> child() @@ -67,7 +69,8 @@ inline fun ParentNode.Children( ) { CompositionLocalProvider( /** LocalSharedElementScope will be consumed by children UI to apply shareElement modifier */ - LocalSharedElementScope provides this + LocalSharedElementScope provides this, + LocalMovableContentMap provides if (withMovableContent) mutableMapOf() else null ) { block( ChildrenTransitionScope( @@ -87,7 +90,8 @@ inline fun ParentNode.Children( CompositionLocalProvider( /** If sharedElement is not supported for this Node - provide null otherwise children * can consume ascendant's LocalSharedElementScope */ - LocalSharedElementScope provides null + LocalSharedElementScope provides null, + LocalMovableContentMap provides if (withMovableContent) mutableMapOf() else null ) { block( ChildrenTransitionScope( 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 index fa7ee35aa..8786797f5 100644 --- 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 @@ -2,14 +2,16 @@ 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 -import com.bumble.appyx.core.node.LocalParentNodeMovableContent /** * Returns movable content for the given key. To reuse composable across Nodes movable content - * must to be invoked only once at any time, therefore we should return null for if the Node is - * transitioning to an invisible target and return movable content only if the targetState is visible. + * 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 @@ -23,14 +25,162 @@ import com.bumble.appyx.core.node.LocalParentNodeMovableContent @Composable fun localMovableContentWithTargetVisibility( key: Any, - defaultValue: @Composable () -> Unit + content: @Composable () -> Unit ): (@Composable () -> Unit)? { if (!LocalNodeTargetVisibility.current) return null - val movableContentMap = LocalParentNodeMovableContent.current + val movableContentMap = retrieveMovableContentMap() return movableContentMap.getOrPut(key) { movableContentOf { - defaultValue() + 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/node/LocalNode.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/LocalNode.kt index 2d9c5cf8e..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 @@ -2,7 +2,6 @@ package com.bumble.appyx.core.node import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.SharedTransitionScope -import androidx.compose.runtime.Composable import androidx.compose.runtime.compositionLocalOf val LocalNode = compositionLocalOf { null } @@ -18,5 +17,7 @@ val LocalSharedElementScope = compositionLocalOf { null */ val LocalNodeTargetVisibility = compositionLocalOf { false } -val LocalParentNodeMovableContent = - compositionLocalOf Unit>> { mutableMapOf() } +/** + * 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/Node.kt b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt index d9974b293..2ffcceef7 100644 --- a/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt +++ b/libraries/core/src/main/kotlin/com/bumble/appyx/core/node/Node.kt @@ -123,18 +123,11 @@ open class Node @VisibleForTesting internal constructor( LocalNode provides this, LocalLifecycleOwner provides this, ) { - DerivedSetup { - HandleBackPress() - View(modifier) - } + HandleBackPress() + View(modifier) } } - @Composable - protected open fun DerivedSetup(innerContent: @Composable () -> Unit) { - innerContent() - } - @Composable private fun HandleBackPress() { // can't use BackHandler Composable because plugins provide OnBackPressedCallback which is not observable 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 61f33f571..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 @@ -2,8 +2,6 @@ package com.bumble.appyx.core.node import androidx.annotation.CallSuper import androidx.annotation.VisibleForTesting -import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.Stable import androidx.lifecycle.DefaultLifecycleObserver import androidx.lifecycle.Lifecycle @@ -76,15 +74,6 @@ abstract class ParentNode( manageTransitions() } - @Composable - final override fun DerivedSetup(innerContent: @Composable () -> Unit) { - CompositionLocalProvider( - LocalParentNodeMovableContent provides mutableMapOf() - ) { - innerContent() - } - } - fun childOrCreate(navKey: NavKey): ChildEntry.Initialized = childNodeCreationManager.childOrCreate(navKey) 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 b46a90de0..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,7 +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.SharedElementFaderExample +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 @@ -48,7 +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.SharedElementWithMovableContentExampleNode +import com.bumble.appyx.sandbox.client.sharedelement.SharedElementExampleNode import com.bumble.appyx.utils.customisations.NodeCustomisation import kotlinx.parcelize.Parcelize @@ -75,7 +76,10 @@ class ContainerNode internal constructor( object LazyExamples : NavTarget() @Parcelize - object SharedElementFaderExample : NavTarget() + object SharedElementExample : NavTarget() + + @Parcelize + object SharedElementWithMovableContentExample : NavTarget() @Parcelize object IntegrationPointExample : NavTarget() @@ -101,12 +105,21 @@ class ContainerNode internal constructor( when (navTarget) { is Picker -> node(buildContext) { modifier -> ExamplesList(modifier) } is NavModelExamples -> NavModelExamplesNode(buildContext) - is SharedElementFaderExample -> SharedElementWithMovableContentExampleNode(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" @@ -143,7 +156,12 @@ class ContainerNode internal constructor( label?.let { Text(it, textAlign = TextAlign.Center) } - TextButton("Shared element with movable content") { backStack.push(SharedElementFaderExample) } + 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 index 5506d4e37..959331ff6 100644 --- 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 @@ -17,16 +17,29 @@ 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) { - @OptIn(ExperimentalSharedTransitionApi::class) @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() @@ -36,6 +49,38 @@ class FullScreenNode( ) { 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 @@ -58,3 +103,4 @@ class FullScreenNode( } } } + 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 index ddca47c88..dd9b79741 100644 --- 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 @@ -22,14 +22,15 @@ 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) { - @OptIn(ExperimentalSharedTransitionApi::class) @Composable override fun View(modifier: Modifier) { val state = rememberLazyListState(initialFirstVisibleItemIndex = selectedId) @@ -42,34 +43,80 @@ class ProfileHorizontalListNode( ) { repeat(10) { profileId -> item(key = profileId) { - 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) - ) + 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 index 39f9196e6..b735c215a 100644 --- 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 @@ -24,14 +24,15 @@ 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) { - @OptIn(ExperimentalSharedTransitionApi::class) @Composable override fun View(modifier: Modifier) { val state = rememberLazyListState(initialFirstVisibleItemIndex = profileId) @@ -41,35 +42,81 @@ class ProfileVerticalListNode( contentPadding = PaddingValues(horizontal = 16.dp), verticalArrangement = Arrangement.spacedBy(32.dp), ) { - repeat(10) { pageId -> - item(key = pageId) { - Row( - modifier = Modifier - .clickable { - onProfileClick(pageId) - } - ) { - val profile = Profile.allProfiles[pageId] - Box( - modifier = Modifier - .requiredSize(64.dp) - .sharedElement(key = "$pageId image") - .clip(CircleShape) - ) { - ProfileImageWithCounterMovableContent(pageId) - } - Text( - text = "${profile.name}, ${profile.age}", - color = LocalContentColor.current, - fontSize = 32.sp, - modifier = Modifier - .fillMaxWidth() - .sharedElement(key = "$pageId text") - .padding(8.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/SharedElementWithMovableContentExampleNode.kt b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementExampleNode.kt similarity index 88% rename from samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt rename to samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementExampleNode.kt index 58baac8a9..d9e750179 100644 --- a/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementWithMovableContentExampleNode.kt +++ b/samples/sandbox/src/main/kotlin/com/bumble/appyx/sandbox/client/sharedelement/SharedElementExampleNode.kt @@ -28,13 +28,14 @@ import kotlinx.coroutines.delay import kotlinx.parcelize.Parcelize import kotlin.random.Random -class SharedElementWithMovableContentExampleNode( +class SharedElementExampleNode( buildContext: BuildContext, + private val hasMovableContent : Boolean = false, private val backStack: BackStack = BackStack( savedStateMap = buildContext.savedStateMap, initialElement = NavTarget.HorizontalList(0) ) -) : ParentNode( +) : ParentNode( navModel = backStack, buildContext = buildContext, ) { @@ -57,7 +58,8 @@ class SharedElementWithMovableContentExampleNode( backStack.push(NavTarget.FullScreen(id)) }, selectedId = navTarget.profileId, - buildContext = buildContext + buildContext = buildContext, + hasMovableContent = hasMovableContent ) is NavTarget.VerticalList -> ProfileVerticalListNode( @@ -65,7 +67,8 @@ class SharedElementWithMovableContentExampleNode( backStack.push(NavTarget.HorizontalList(id)) }, profileId = navTarget.profileId, - buildContext = buildContext + buildContext = buildContext, + hasMovableContent = hasMovableContent ) is NavTarget.FullScreen -> FullScreenNode( @@ -73,7 +76,8 @@ class SharedElementWithMovableContentExampleNode( backStack.push(NavTarget.VerticalList(id)) }, profileId = navTarget.profileId, - buildContext = buildContext + buildContext = buildContext, + hasMovableContent = hasMovableContent ) } } @@ -84,6 +88,7 @@ class SharedElementWithMovableContentExampleNode( Children( navModel = backStack, withSharedElementTransition = true, + withMovableContent = hasMovableContent, transitionHandler = rememberBackstackFader(transitionSpec = { tween(300) }) ) } From 9909b89e5cd52da3b7c3fec030f4d368ba31ce33 Mon Sep 17 00:00:00 2001 From: andreykovalev Date: Wed, 8 May 2024 10:03:58 +0100 Subject: [PATCH 26/26] Add withMovableContent param to docs --- documentation/ui/transitions.md | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/documentation/ui/transitions.md b/documentation/ui/transitions.md index 92d16ba63..09c32a186 100644 --- a/documentation/ui/transitions.md +++ b/documentation/ui/transitions.md @@ -104,7 +104,8 @@ 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. +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 @@ -139,6 +140,15 @@ override fun View(modifier: Modifier) { CustomMovableContent("movableContentKey") } +// ParentNode +@Composable +override fun View(modifier: Modifier) { + Children( + navModel = backStack, + withMovableContent = true, + ) +} + ``` ## Jetpack Compose default animations