Repository: bumble-tech/appyx
Issue: MutableUiState visibility is incorrectly resolved in some cases

If `MutableUiState` has `Scale` or `Rotation` motion properties that modify the bounds of UiElement then visibility will be incorrectly resolved as it doesn't take these properties into account.

New approach should take into account all `MotionProperties` which can modify element bounds, transform bounds based of these values and calculate if the element is visible on the screen.

Example:

The initial bounds are `0, 0` and `x, y`.  Element has `scale = x2`, `rotationY = 90`, `offsetX = -30`, the transformed bounds should be  `-30, 0` and `2y - 30, 2x`.

Final bounds should be matched to transition bounds to calculate if the element is visible or not



Changed files:
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt
appyx-components/stable/spotlight/android/src/androidTest/kotlin/com/bumble/appyx/components/spotlight/android/SpotlightTest.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Scale.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/transitionmodel/BaseMotionController.kt
appyx-components/stable/backstack/android/src/androidTest/kotlin/com/bumble/appyx/components/backstack/android/BackStackTest.kt
appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/composable/Children.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/state/BaseMutableUiState.kt
demos/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/MotionProperty.kt
appyx-components/stable/backstack/common/src/commonMain/kotlin/com/bumble/appyx/components/backstack/ui/slider/BackStackSlider.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/sample/Children.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Shadow.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Position.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/ColorOverlay.kt
appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Alpha.kt

diff --git a/appyx-components/stable/backstack/android/src/androidTest/kotlin/com/bumble/appyx/components/backstack/android/BackStackTest.kt b/appyx-components/stable/backstack/android/src/androidTest/kotlin/com/bumble/appyx/components/backstack/android/BackStackTest.kt
index c77cd69b..41826c61 100644
--- a/appyx-components/stable/backstack/android/src/androidTest/kotlin/com/bumble/appyx/components/backstack/android/BackStackTest.kt
+++ b/appyx-components/stable/backstack/android/src/androidTest/kotlin/com/bumble/appyx/components/backstack/android/BackStackTest.kt
@@ -56,8 +56,8 @@ class BackStackTest(private val testParam: TestParam) {
         backStack.push(interactionTarget = InteractionTarget.Child4)
         backStack.pop(animationSpec = tweenTwoSec)
 
-        // all operations finished. advanced time > 2000 (last operation animation spec)
-        composeTestRule.mainClock.advanceTimeBy(2100)
+        // all operations finished
+        composeTestRule.waitForIdle()
 
         Assert.assertEquals(3, backStack.elements.value.all.size)
     }
diff --git a/appyx-components/stable/backstack/common/src/commonMain/kotlin/com/bumble/appyx/components/backstack/ui/slider/BackStackSlider.kt b/appyx-components/stable/backstack/common/src/commonMain/kotlin/com/bumble/appyx/components/backstack/ui/slider/BackStackSlider.kt
index c7dcf4da..f80dc4f4 100644
--- a/appyx-components/stable/backstack/common/src/commonMain/kotlin/com/bumble/appyx/components/backstack/ui/slider/BackStackSlider.kt
+++ b/appyx-components/stable/backstack/common/src/commonMain/kotlin/com/bumble/appyx/components/backstack/ui/slider/BackStackSlider.kt
@@ -33,7 +33,7 @@ class BackStackSlider<InteractionTarget : Any>(
             stashed.mapIndexed { index, element ->
                 MatchedTargetUiState(
                     element,
-                    visible.toOutsideLeft(index + 1, -width)
+                    visible.toOutsideLeft(index + 1, width)
                 )
             } +
             destroyed.mapIndexed { index, element ->
diff --git a/appyx-components/stable/spotlight/android/src/androidTest/kotlin/com/bumble/appyx/components/spotlight/android/SpotlightTest.kt b/appyx-components/stable/spotlight/android/src/androidTest/kotlin/com/bumble/appyx/components/spotlight/android/SpotlightTest.kt
index 11c13eb9..a12cc30e 100644
--- a/appyx-components/stable/spotlight/android/src/androidTest/kotlin/com/bumble/appyx/components/spotlight/android/SpotlightTest.kt
+++ b/appyx-components/stable/spotlight/android/src/androidTest/kotlin/com/bumble/appyx/components/spotlight/android/SpotlightTest.kt
@@ -56,6 +56,8 @@ class SpotlightTest(private val testParam: TestParam) {
             spotlight.waitUntilAnimationEnded(composeTestRule)
         }
 
+        composeTestRule.waitForIdle()
+
         checkInteractionTargetsOnScreen(setOf(InteractionTarget.Child5, InteractionTarget.Child4))
     }
 
@@ -73,6 +75,8 @@ class SpotlightTest(private val testParam: TestParam) {
             spotlight.waitUntilAnimationEnded(composeTestRule)
         }
 
+        composeTestRule.waitForIdle()
+
         checkInteractionTargetsOnScreen(setOf(InteractionTarget.Child5))
     }
 
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt
index 6dc0c459..cf4372d9 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/DraggableChildren.kt
@@ -88,7 +88,7 @@ fun <InteractionTarget : Any, ModelState : Any> DraggableChildren(
         ) {
             elementUiModels.value.forEach { elementUiModel ->
                 key(elementUiModel.element.id) {
-                    elementUiModel.animationContainer()
+                    elementUiModel.persistentContainer()
                     val isVisible by elementUiModel.visibleState.collectAsState()
                     if (isVisible) {
                         element.invoke(
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt
index 560fee06..59e47dac 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/output/ElementUiModel.kt
@@ -9,7 +9,14 @@ import kotlinx.coroutines.flow.StateFlow
 data class ElementUiModel<InteractionTarget>(
     val element: Element<InteractionTarget>,
     val visibleState: StateFlow<Boolean>,
-    val animationContainer: @Composable () -> Unit,
+    /**
+     * The purpose of this container is to be present in composition as long as corresponding
+     * Element exists. With its help we calculate visibility of the element and animate MotionProperties
+     * even if the element is invisible. This help to break circular dependency when element is
+     * not present in the composition and therefore we can't update MotionProperties, and because
+     * MotionProperties are not updated element can never enter composition even when it should.
+     */
+    val persistentContainer: @Composable () -> Unit,
     val modifier: Modifier,
     val progress: Flow<Float>
 )
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/MotionProperty.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/MotionProperty.kt
index a4271f8b..cabb4875 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/MotionProperty.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/MotionProperty.kt
@@ -15,7 +15,6 @@ import androidx.compose.ui.Modifier
 import com.bumble.appyx.interactions.AppyxLogger
 import com.bumble.appyx.interactions.SystemClock
 import com.bumble.appyx.interactions.core.ui.context.UiContext
-import com.bumble.appyx.mapState
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.SharingStarted
 import kotlinx.coroutines.flow.StateFlow
@@ -57,12 +56,6 @@ abstract class MotionProperty<T, V : AnimationVector>(
     private var previousVelocity = animatable.velocity
     private var lastTime = 0L
 
-    /**
-     * Mapper which resolves the visibility of the property. If property has no influence on the
-     * visibility, it should always return true.
-     */
-    open val visibilityMapper: ((T) -> Boolean) = { true }
-
     private val internalValueFlow = MutableStateFlow(animatable.value)
 
 
@@ -110,11 +103,14 @@ abstract class MotionProperty<T, V : AnimationVector>(
     val isAnimating: StateFlow<Boolean>
         get() = _isAnimatingFlow
 
-    val isVisibleFlow: StateFlow<Boolean> by lazy {
-        renderValueFlow.mapState(uiContext.coroutineScope) { value ->
-            visibilityMapper.invoke(value)
-        }
-    }
+
+    /**
+     * Visibility of the element is calculated based on the element bounds relative to the container
+     * where transition happen in case of clipToBounds = true or rootBounds in case of clipToBounds = true.
+     * This property should be overridden if the value of MotionProperty affect visibility. For instance,
+     * Alpha motion property - regardless of element bounds the element is invisible if alpha = 0f.
+     */
+    open val isVisibleFlow: StateFlow<Boolean>? = null
 
     private fun onValueChanged() {
         internalValueFlow.update { internalValue }
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Alpha.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Alpha.kt
index 93084937..0cb5cc92 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Alpha.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Alpha.kt
@@ -11,6 +11,7 @@ import com.bumble.appyx.interactions.core.ui.context.UiContext
 import com.bumble.appyx.interactions.core.ui.math.lerpFloat
 import com.bumble.appyx.interactions.core.ui.property.Interpolatable
 import com.bumble.appyx.interactions.core.ui.property.MotionProperty
+import com.bumble.appyx.mapState
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 
@@ -32,13 +33,14 @@ class Alpha(
         val easing: Easing? = null,
     ) : MotionProperty.Target
 
+    override val isVisibleFlow: StateFlow<Boolean> =
+        renderValueFlow.mapState(uiContext.coroutineScope) {
+            it > 0f
+        }
+
     override fun calculateRenderValue(base: Float, displacement: Float): Float =
         base - displacement
 
-    override val visibilityMapper: ((Float) -> Boolean) = { alpha ->
-        alpha > 0.0f
-    }
-
     override val modifier: Modifier
         get() = Modifier.composed {
             this.alpha(renderValueFlow.collectAsState().value)
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/ColorOverlay.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/ColorOverlay.kt
index 8b888cb7..64a47b37 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/ColorOverlay.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/ColorOverlay.kt
@@ -37,8 +37,6 @@ class ColorOverlay(
     override fun calculateRenderValue(base: Float, displacement: Float): Float =
         base - displacement
 
-    override val visibilityMapper: ((Float) -> Boolean) = { true }
-
     override val modifier: Modifier
         get() = Modifier.composed {
             val alpha = renderValueFlow.collectAsState().value
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Position.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Position.kt
index 8f1d8ecb..a54f1e80 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Position.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Position.kt
@@ -10,12 +10,10 @@ import androidx.compose.ui.Modifier
 import androidx.compose.ui.composed
 import androidx.compose.ui.unit.DpOffset
 import androidx.compose.ui.unit.dp
-import com.bumble.appyx.interactions.core.ui.context.TransitionBounds
 import com.bumble.appyx.interactions.core.ui.context.UiContext
 import com.bumble.appyx.interactions.core.ui.math.lerpDpOffset
 import com.bumble.appyx.interactions.core.ui.property.Interpolatable
 import com.bumble.appyx.interactions.core.ui.property.MotionProperty
-import com.bumble.appyx.interactions.core.ui.toPx
 import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 
@@ -40,60 +38,6 @@ class Position(
     override fun calculateRenderValue(base: DpOffset, displacement: DpOffset): DpOffset =
         base - displacement
 
-    override val visibilityMapper: (DpOffset) -> Boolean = { displacedValue ->
-        val bounds = uiContext.transitionBounds
-        val clipToBounds = uiContext.clipToBounds
-
-        val itemOffsetRangeX = calculateItemOffsetRangeX(displacedValue, bounds)
-        val itemOffsetRangeY = calculateItemOffsetRangeY(displacedValue, bounds)
-
-        val visibleOffsetRangeX = calculateVisibleOffsetRangeX(bounds, clipToBounds)
-        val visibleOffsetRangeY = calculateVisibleOffsetRangeY(bounds, clipToBounds)
-
-        val isVisible = itemOffsetRangeX.hasIntersection(visibleOffsetRangeX)
-            && itemOffsetRangeY.hasIntersection(visibleOffsetRangeY)
-        isVisible
-    }
-
-    private fun calculateItemOffsetRangeX(
-        displacedValue: DpOffset,
-        bounds: TransitionBounds
-    ): ClosedRange<Int> {
-        val displacedValueXPx = displacedValue.x.toPx(bounds.density)
-        return (displacedValueXPx..(displacedValueXPx + bounds.widthPx))
-    }
-
-    private fun calculateItemOffsetRangeY(
-        displacedValue: DpOffset,
-        bounds: TransitionBounds
-    ): ClosedRange<Int> {
-        val displacedValueYPx = displacedValue.y.toPx(bounds.density)
-        return (displacedValueYPx..(displacedValueYPx + bounds.heightPx))
-    }
-
-    private fun calculateVisibleOffsetRangeX(bounds: TransitionBounds, clipToBounds: Boolean) =
-        if (clipToBounds) {
-            (0)..(bounds.widthPx)
-        } else {
-            (-bounds.containerOffsetXPx)..(bounds.screenWidthPx - bounds.containerOffsetXPx)
-        }
-
-    private fun calculateVisibleOffsetRangeY(bounds: TransitionBounds, clipToBounds: Boolean) =
-        if (clipToBounds) {
-            (0)..(bounds.heightPx)
-        } else {
-            (-bounds.containerOffsetYPx)..(bounds.screenHeightPx - bounds.containerOffsetYPx)
-        }
-
-
-    // If one range ends where another starts we consider them as non-intersected
-    private fun <T : Comparable<T>> ClosedRange<T>.hasIntersection(another: ClosedRange<T>): Boolean =
-        when {
-            isEmpty() || another.isEmpty() -> false
-            endInclusive <= another.start || start >= another.endInclusive -> false
-            else -> true
-        }
-
     override val modifier: Modifier
         get() = Modifier.composed {
             val value = renderValueFlow.collectAsState()
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Scale.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Scale.kt
index 38e241cb..51fa7458 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Scale.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Scale.kt
@@ -37,10 +37,6 @@ class Scale(
     override fun calculateRenderValue(base: Float, displacement: Float): Float =
         base - displacement
 
-    override val visibilityMapper: ((Float) -> Boolean) = { scale ->
-        scale > 0.0f
-    }
-
     override val modifier: Modifier
         get() = Modifier.composed {
             this.scale(renderValueFlow.collectAsState().value)
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Shadow.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Shadow.kt
index d89c250d..feb602f3 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Shadow.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/property/impl/Shadow.kt
@@ -36,8 +36,6 @@ class Shadow(
     override fun calculateRenderValue(base: Float, displacement: Float): Float =
         base - displacement
 
-    override val visibilityMapper: ((Float) -> Boolean) = { true }
-
     override val modifier: Modifier
         get() = Modifier.composed {
             this.shadow(elevation = renderValueFlow.collectAsState().value.dp, clip = false)
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/state/BaseMutableUiState.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/state/BaseMutableUiState.kt
index f5cae3f8..af58dc05 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/state/BaseMutableUiState.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/core/ui/state/BaseMutableUiState.kt
@@ -1,34 +1,86 @@
 package com.bumble.appyx.interactions.core.ui.state
 
 import androidx.compose.animation.core.SpringSpec
+import androidx.compose.foundation.layout.fillMaxSize
 import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
+import androidx.compose.ui.geometry.Rect
+import androidx.compose.ui.geometry.Size
+import androidx.compose.ui.graphics.graphicsLayer
+import androidx.compose.ui.layout.boundsInParent
+import androidx.compose.ui.layout.boundsInRoot
+import androidx.compose.ui.layout.onPlaced
 import com.bumble.appyx.combineState
 import com.bumble.appyx.interactions.core.ui.context.UiContext
 import com.bumble.appyx.interactions.core.ui.property.MotionProperty
 import kotlinx.coroutines.CoroutineScope
 import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
 import kotlinx.coroutines.flow.StateFlow
 import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.update
 
 abstract class BaseMutableUiState<MutableUiState, TargetUiState>(
     val uiContext: UiContext,
     val motionProperties: List<MotionProperty<*, *>>
 ) {
+
+    private val containerRect = Rect(
+        offset = Offset.Zero,
+        size = Size(
+            width = uiContext.transitionBounds.widthPx.toFloat(),
+            height = uiContext.transitionBounds.heightPx.toFloat()
+        )
+    )
+
     abstract val modifier: Modifier
 
-    val isAnimating: Flow<Boolean>
-        get() = combine(motionProperties.map { it.isAnimating }) { booleanArray ->
-            booleanArray.any { it }
-        }
+    private val _isBoundsVisible = MutableStateFlow(false)
+    private val visibilitySources: Iterable<StateFlow<Boolean>> =
+        motionProperties.mapNotNull { it.isVisibleFlow } + _isBoundsVisible
 
     val isVisible: StateFlow<Boolean>
         get() = combineState(
-            motionProperties.map { it.isVisibleFlow },
+            visibilitySources,
             uiContext.coroutineScope
         ) { booleanArray ->
             booleanArray.all { it }
         }
 
+    /**
+     * This modifier duplicates original modifier, and will be placed on the dummy compose view
+     * to calculate bounds relative to parent and eventually update bounds visibility relative
+     * to parent's bounds. Because it's responsible only for calculating element's bounds it ensures
+     * that it's invisible by setting alpha as 0f. Additionally, it makes sure that it occupies all
+     * available space by applying fillMaxSize().
+     */
+    val visibilityModifier: Modifier
+        get() = Modifier
+            .graphicsLayer {
+                // Making sure that this modifier is invisible
+                alpha = 0f
+            }
+            .then(modifier)
+            .fillMaxSize()
+            .onPlaced { coordinates ->
+                if (uiContext.clipToBounds) {
+                    val boundsInParent = coordinates.boundsInParent()
+                    val overlaps = boundsInParent.overlaps(containerRect)
+                    _isBoundsVisible.update { overlaps }
+                } else {
+                    // if element is invisible boundsInRoot will have width == 0 or height == 0 or both
+                    val boundsInRoot = coordinates.boundsInRoot()
+                    _isBoundsVisible.update {
+                        (boundsInRoot.width > 0f && boundsInRoot.height > 0f)
+                    }
+                }
+            }
+
+    val isAnimating: Flow<Boolean>
+        get() = combine(motionProperties.map { it.isAnimating }) { booleanArray ->
+            booleanArray.any { it }
+        }
+
     abstract suspend fun snapTo(
         target: TargetUiState
     )
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/sample/Children.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/sample/Children.kt
index f26f325a..02971438 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/sample/Children.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/interactions/sample/Children.kt
@@ -74,7 +74,7 @@ fun <InteractionTarget : Any, ModelState : Any> Children(
     ) {
         elementUiModels.value.forEach { elementUiModel ->
             key(elementUiModel.element.id) {
-                elementUiModel.animationContainer()
+                elementUiModel.persistentContainer()
                 val isVisible by elementUiModel.visibleState.collectAsState()
                 if (isVisible) {
                     element.invoke(elementUiModel)
diff --git a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/transitionmodel/BaseMotionController.kt b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/transitionmodel/BaseMotionController.kt
index 5d775b45..91ac26bc 100644
--- a/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/transitionmodel/BaseMotionController.kt
+++ b/appyx-interactions/common/src/commonMain/kotlin/com/bumble/appyx/transitionmodel/BaseMotionController.kt
@@ -3,6 +3,7 @@ package com.bumble.appyx.transitionmodel
 import DefaultAnimationSpec
 import androidx.compose.animation.core.SpringSpec
 import androidx.compose.animation.core.spring
+import androidx.compose.foundation.layout.Box
 import androidx.compose.runtime.Composable
 import androidx.compose.runtime.LaunchedEffect
 import androidx.compose.runtime.collectAsState
@@ -91,7 +92,8 @@ abstract class BaseMotionController<InteractionTarget : Any, ModelState, Mutable
             ElementUiModel(
                 element = t1.element,
                 visibleState = mutableUiState.isVisible,
-                animationContainer = @Composable {
+                persistentContainer = @Composable {
+                    Box(modifier = mutableUiState.visibilityModifier)
                     observeElementAnimationChanges(mutableUiState, t1)
                     manageAnimations(mutableUiState, t1, update)
                 },
@@ -199,7 +201,8 @@ abstract class BaseMotionController<InteractionTarget : Any, ModelState, Mutable
             ElementUiModel(
                 element = t1.element,
                 visibleState = mutableUiState.isVisible,
-                animationContainer = @Composable {
+                persistentContainer = @Composable {
+                    Box(modifier = mutableUiState.visibilityModifier)
                     interpolateUiState(segmentProgress, mutableUiState, t0, t1, initialProgress)
                 },
                 modifier = mutableUiState.modifier,
diff --git a/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/composable/Children.kt b/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/composable/Children.kt
index 8055ce44..5945d733 100644
--- a/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/composable/Children.kt
+++ b/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/composable/Children.kt
@@ -112,7 +112,7 @@ class ChildrenTransitionScope<InteractionTarget : Any, NavState : Any>(
         visibleFrames.value
             .forEach { uiModel ->
                 key(uiModel.element.id) {
-                    uiModel.animationContainer()
+                    uiModel.persistentContainer()
                     val isVisible by uiModel.visibleState.collectAsState()
                     if (isVisible) {
                         Child(
diff --git a/demos/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt b/demos/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt
index e1def6f9..44aef335 100644
--- a/demos/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt
+++ b/demos/appyx-navigation/src/main/kotlin/com/bumble/appyx/navigation/node/backstack/BackStackNode.kt
@@ -102,6 +102,7 @@ class BackStackNode(
                 .background(appyx_dark)
         ) {
             Children(
+                clipToBounds = true,
                 interactionModel = backStack,
                 modifier = Modifier
                     .weight(0.9f)