11package org.monogram.app.components
22
33import androidx.compose.animation.core.animate
4+ import androidx.compose.animation.core.animateFloatAsState
45import androidx.compose.animation.core.spring
56import androidx.compose.animation.core.tween
67import androidx.compose.foundation.background
@@ -28,14 +29,13 @@ import androidx.compose.ui.input.pointer.pointerInput
2829import androidx.compose.ui.input.pointer.util.VelocityTracker
2930import androidx.compose.ui.layout.onSizeChanged
3031import androidx.compose.ui.util.fastFirstOrNull
32+ import androidx.compose.ui.zIndex
33+ import com.arkivanov.decompose.Child
3134import com.arkivanov.decompose.ExperimentalDecomposeApi
3235import com.arkivanov.decompose.extensions.compose.stack.Children
33- import com.arkivanov.decompose.extensions.compose.stack.animation.fade
34- import com.arkivanov.decompose.extensions.compose.stack.animation.plus
35- import com.arkivanov.decompose.extensions.compose.stack.animation.predictiveback.predictiveBackAnimation
36- import com.arkivanov.decompose.extensions.compose.stack.animation.slide
37- import com.arkivanov.decompose.extensions.compose.stack.animation.stackAnimation
36+ import com.arkivanov.decompose.extensions.compose.stack.animation.StackAnimation
3837import com.arkivanov.decompose.extensions.compose.subscribeAsState
38+ import com.arkivanov.decompose.router.stack.ChildStack
3939import kotlinx.coroutines.launch
4040import org.monogram.presentation.root.RootComponent
4141import kotlin.math.abs
@@ -46,23 +46,42 @@ fun MobileLayout(root: RootComponent) {
4646 val stack by root.childStack.subscribeAsState()
4747 val isDragToBackEnabled by root.appPreferences.isDragToBackEnabled.collectAsState()
4848 val coroutineScope = rememberCoroutineScope()
49- val previous = stack.items.dropLast(1 ).lastOrNull()?.instance
49+ val activeEntry = stack.active
50+ val previousEntry = stack.items.dropLast(1 ).lastOrNull()
51+ val stackKeysAreUnique = stack.items.map { it.key }.toSet().size == stack.items.size
52+ val canRenderSwipePreview =
53+ stackKeysAreUnique &&
54+ previousEntry != null &&
55+ previousEntry.key != activeEntry.key &&
56+ previousEntry.instance != = activeEntry.instance
5057 var dragOffsetX by remember { mutableFloatStateOf(0f ) }
5158 var isCompletingSwipeBack by remember { mutableStateOf(false ) }
5259 var widthPx by remember { mutableFloatStateOf(0f ) }
5360 var isSwipeBackBlocked by remember { mutableStateOf(false ) }
5461 val canUseDragToBack =
55- isDragToBackEnabled && isSwipeBackSupported(stack.active.instance) && ! isSwipeBackBlocked
62+ isDragToBackEnabled &&
63+ previousEntry != null &&
64+ stackKeysAreUnique &&
65+ isSwipeBackSupported(activeEntry.instance) &&
66+ ! isSwipeBackBlocked
5667 val dragProgress = if (widthPx > 0f ) (dragOffsetX / widthPx).coerceIn(0f , 1f ) else 0f
5768
69+ LaunchedEffect (activeEntry.key, stackKeysAreUnique) {
70+ isSwipeBackBlocked = false
71+ if (! stackKeysAreUnique) {
72+ dragOffsetX = 0f
73+ isCompletingSwipeBack = false
74+ }
75+ }
76+
5877 LaunchedEffect (canUseDragToBack) {
5978 if (! canUseDragToBack && dragOffsetX > 0f ) {
6079 dragOffsetX = 0f
6180 isCompletingSwipeBack = false
6281 }
6382 }
6483
65- if (dragOffsetX > 0f && previous != null ) {
84+ if (dragOffsetX > 0f && canRenderSwipePreview ) {
6685 Box (modifier = Modifier .fillMaxSize()) {
6786 Box (
6887 modifier = Modifier
@@ -71,7 +90,12 @@ fun MobileLayout(root: RootComponent) {
7190 translationX = ((dragProgress - 1f ) * widthPx * 0.08f )
7291 },
7392 ) {
74- RenderChild (previous)
93+ key(" swipe-preview:${previousEntry.key} " ) {
94+ RenderChild (
95+ child = previousEntry.instance,
96+ isOverlay = true ,
97+ )
98+ }
7599 }
76100 Box (
77101 modifier = Modifier
@@ -93,7 +117,7 @@ fun MobileLayout(root: RootComponent) {
93117 }
94118 .then(
95119 if (canUseDragToBack) {
96- Modifier .pointerInput(canUseDragToBack) {
120+ Modifier .pointerInput(canUseDragToBack, activeEntry.key ) {
97121 awaitEachGesture {
98122 if (size.width == 0 ) return @awaitEachGesture
99123
@@ -170,6 +194,11 @@ fun MobileLayout(root: RootComponent) {
170194 continue
171195 }
172196
197+ if (! canRenderSwipePreview) {
198+ dragOffsetX = 0f
199+ break
200+ }
201+
173202 isDragging = true
174203 }
175204
@@ -203,11 +232,9 @@ fun MobileLayout(root: RootComponent) {
203232 },
204233 ) {
205234 Children (
206- stack = root.childStack,
207- animation = predictiveBackAnimation(
208- backHandler = root.backHandler,
209- onBack = root::onBack,
210- fallbackAnimation = if (! isCompletingSwipeBack) stackAnimation(slide() + fade()) else null ,
235+ stack = stack,
236+ animation = safeStackAnimation(
237+ enabled = dragOffsetX == 0f && ! isCompletingSwipeBack,
211238 ),
212239 ) { child ->
213240 key(child.key) {
@@ -254,4 +281,119 @@ private fun isSwipeBackSupported(child: RootComponent.Child): Boolean =
254281 is RootComponent .Child .DebugChild -> true
255282
256283 else -> false
257- }
284+ }
285+
286+ private fun <C : Any , T : Any > activeOnlyStackAnimation (): StackAnimation <C , T > =
287+ StackAnimation { stack, modifier, content ->
288+ Box (modifier = modifier) {
289+ content(stack.active)
290+ }
291+ }
292+
293+ @Composable
294+ private fun <C : Any , T : Any > safeStackAnimation (enabled : Boolean ): StackAnimation <C , T > {
295+ if (! enabled) {
296+ return activeOnlyStackAnimation()
297+ }
298+
299+ return StackAnimation { stack, modifier, content ->
300+ var previousStack by remember { mutableStateOf<ChildStack <C , T >? > (null ) }
301+ var transition by remember { mutableStateOf<StackTransition <C , T >? > (null ) }
302+ val oldStack = previousStack
303+
304+ if (oldStack == null ) {
305+ previousStack = stack
306+ } else if (oldStack.active.key != stack.active.key) {
307+ val oldActive = oldStack.active
308+ val newActive = stack.active
309+ transition =
310+ if (oldActive.key != newActive.key) {
311+ StackTransition (
312+ oldActive = oldActive,
313+ newActive = newActive,
314+ isPop = stack.items.size < oldStack.items.size,
315+ )
316+ } else {
317+ null
318+ }
319+ previousStack = stack
320+ }
321+
322+ val currentTransition = transition
323+ val animationProgress by animateFloatAsState(
324+ targetValue = if (currentTransition == null ) 1f else 0f ,
325+ animationSpec = tween(durationMillis = 220 ),
326+ label = " MobileStackAnimation" ,
327+ finishedListener = {
328+ transition = null
329+ },
330+ )
331+
332+ Box (modifier = modifier) {
333+ if (
334+ currentTransition != null &&
335+ currentTransition.oldActive.key != currentTransition.newActive.key
336+ ) {
337+ StackAnimatedChild (
338+ child = currentTransition.oldActive,
339+ progress = animationProgress,
340+ isPop = currentTransition.isPop,
341+ isOutgoing = true ,
342+ content = content,
343+ )
344+ StackAnimatedChild (
345+ child = currentTransition.newActive,
346+ progress = animationProgress,
347+ isPop = currentTransition.isPop,
348+ isOutgoing = false ,
349+ content = content,
350+ )
351+ } else {
352+ content(stack.active)
353+ }
354+ }
355+ }
356+ }
357+
358+ @Composable
359+ private fun <C : Any , T : Any > StackAnimatedChild (
360+ child : Child .Created <C , T >,
361+ progress : Float ,
362+ isPop : Boolean ,
363+ isOutgoing : Boolean ,
364+ content : @Composable (child: Child .Created <C , T >) -> Unit ,
365+ ) {
366+ val direction = if (isPop) - 1f else 1f
367+ val translationFactor =
368+ if (isOutgoing) {
369+ - direction * 0.08f * (1f - progress)
370+ } else {
371+ direction * progress
372+ }
373+ val alpha =
374+ if (isOutgoing) {
375+ 1f - 0.18f * (1f - progress)
376+ } else {
377+ 1f
378+ }
379+
380+ key(" stack-animation:${child.key} :${if (isOutgoing) " out" else " in" } " ) {
381+ Box (
382+ modifier = Modifier
383+ .fillMaxSize()
384+ .zIndex(if (isOutgoing) 0f else 1f )
385+ .graphicsLayer {
386+ translationX = size.width * translationFactor
387+ this .alpha = alpha
388+ },
389+ ) {
390+ content(child)
391+ }
392+ }
393+ }
394+
395+ private data class StackTransition <C : Any , T : Any >(
396+ val oldActive : Child .Created <C , T >,
397+ val newActive : Child .Created <C , T >,
398+ val isPop : Boolean ,
399+ )
0 commit comments