voyager icon indicating copy to clipboard operation
voyager copied to clipboard

Support ios-like back gesture navigation

Open terrakok opened this issue 1 year ago • 28 comments

Some references to other implementations:

  1. compose-look-and-feel

https://github.com/adrielcafe/voyager/assets/3532155/449ef882-41dc-4e2d-bbfd-115f911044ed

  1. Arkadii Ivanov PoC

terrakok avatar May 15 '23 10:05 terrakok

look-and-feel implementation have some limitations. For example swiping entry doesn't share saved states with actual entry if you don't use key in rememberSaveable. And swipe gesture doens't work on top of horizontally scrollable container.

P.S. Implementation from clip is located in this folder. One that located in com.github.alexzhirkevich.navigation is an experimental thing with native iOS UINavigationController

alexzhirkevich avatar May 17 '23 06:05 alexzhirkevich

Bumping this one as having this behaviour would mean a lot. I could start maybe try porting this but it'll take some time due to availability :)

codlab avatar Sep 21 '23 06:09 codlab

Would like to know if there is anyone who has a workaround for this in the mean time?

rjmangubat23 avatar Oct 06 '23 04:10 rjmangubat23

As a workaround, pointed out by Angga Ardinata in the Kotlin slack channels

He used detectHorizontalDragGestures and when onDragEnd, use navigator.pop() to go previous screen. This does not have the peek in back gesture as IOS have but its a great alternative in the mean time as they hopefully develop this feature.

Reference: https://developer.android.com/jetpack/compose/touch-input/pointer-input/understand-gestures

rjmangubat23 avatar Oct 14 '23 00:10 rjmangubat23

Hi folks, sharing here an example using the PreCompose swipe back implementation in Voyager. Hopefully soon I can refine this code and bring it here again, but, for anyone that is being blocked by this, here is the snippet from Voyager Multiplatform sample that I did the implementation.

https://github.com/adrielcafe/voyager/assets/29736164/c04196a1-8958-4844-9598-2bc5a618bcde

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.material.DismissDirection
import androidx.compose.material.DismissState
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FixedThreshold
import androidx.compose.material.ResistanceConfig
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.ThresholdConfig
import androidx.compose.material.rememberDismissState
import androidx.compose.material.swipeable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import kotlin.math.absoluteValue
import kotlin.math.roundToInt

expect val shouldUseSwipeBack: Boolean // on iOS, this would be true and on Android/Desktop, false

/**
 * @param slideInHorizontally a lambda that takes the full width of the
 * content in pixels and returns the initial offset for the slide-in prev entry,
 * by default it returns -fullWidth/4.
 * For the best impression, lambda should return the save value as [NavTransition.pauseTransition]
 * @param spaceToSwipe width of the swipe space from the left side of screen.
 * Can be set to [Int.MAX_VALUE].dp to enable full-scene swipe
 * @param swipeThreshold amount of offset to perform back navigation
 * @param shadowColor color of the shadow. Alpha channel is additionally multiplied
 * by swipe progress. Use [Color.Transparent] to disable shadow
 * */
@OptIn(ExperimentalMaterialApi::class)
class SwipeProperties(
    val slideInHorizontally: (fullWidth: Int) -> Int = { -it / 4 },
    val spaceToSwipe: Dp = 10.dp,
    val swipeThreshold: ThresholdConfig = FixedThreshold(56.dp),
    val shadowColor: Color = Color.Black.copy(alpha = .25f),
    val swipeAnimSpec: AnimationSpec<Float> = tween(),
)


@Composable
public fun SampleApplication() {
    Navigator(
        screen = BasicNavigationScreen(index = 0),
        onBackPressed = { currentScreen ->
            println("Navigator: Pop screen #${(currentScreen as BasicNavigationScreen).index}")
            true
        }
    ) { navigator ->
        val supportSwipeBack = remember { shouldUseSwipeBack }
        
        if(supportSwipeBack) {
            VoyagerSwipeBackContent(navigator)
        } else {
            SlideTransition(navigator)
        }
    }
}

@Composable
@OptIn(ExperimentalMaterialApi::class)
private fun VoyagerSwipeBackContent(navigator: Navigator) {
    val exampleSwipeProperties = remember { SwipeProperties() }

    val currentSceneEntry = navigator.lastItem
    val prevSceneEntry = remember(navigator.items) { navigator.items.getOrNull(navigator.size - 2) }

    var prevWasSwiped by remember {
        mutableStateOf(false)
    }

    LaunchedEffect(currentSceneEntry) {
        prevWasSwiped = false
    }

    val dismissState = key(currentSceneEntry) {
        rememberDismissState()
    }

    LaunchedEffect(dismissState.isDismissed(DismissDirection.StartToEnd)) {
        if (dismissState.isDismissed(DismissDirection.StartToEnd)) {
            prevWasSwiped = true
            navigator.pop()
        }
    }

    val showPrev by remember(dismissState) {
        derivedStateOf {
            dismissState.offset.value > 0f
        }
    }

    val visibleItems = remember(currentSceneEntry, prevSceneEntry, showPrev) {
        if (showPrev) {
            listOfNotNull(currentSceneEntry, prevSceneEntry)
        } else {
            listOfNotNull(currentSceneEntry)
        }
    }

    val canGoBack = remember(navigator.size) { navigator.size > 1 }

    val animationSpec = remember {
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        )
    }

    // display visible items using SwipeItem
    visibleItems.forEachIndexed { index, backStackEntry ->
        val isPrev = remember(index, visibleItems.size) {
            index == 1 && visibleItems.size > 1
        }
        AnimatedContent(
            backStackEntry,
            transitionSpec = {
                if (prevWasSwiped) {
                    EnterTransition.None togetherWith ExitTransition.None
                } else {
                    val (initialOffset, targetOffset) = when (navigator.lastEvent) {
                        StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size })
                        else -> ({ size: Int -> size }) to ({ size: Int -> -size })
                    }

                    slideInHorizontally(animationSpec, initialOffset) togetherWith
                            slideOutHorizontally(animationSpec, targetOffset)
                }
            },
            modifier = Modifier.zIndex(
                if (isPrev) {
                    0f
                } else {
                    1f
                },
            ),
        ) { screen ->
            SwipeItem(
                dismissState = dismissState,
                swipeProperties = exampleSwipeProperties,//actualSwipeProperties,
                isPrev = isPrev,
                isLast = !canGoBack,
            ) {
                navigator.saveableState("swipe", screen) {
                    screen.Content()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SwipeItem(
    dismissState: DismissState,
    swipeProperties: SwipeProperties,
    isPrev: Boolean,
    isLast: Boolean,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    CustomSwipeToDismiss(
        state = if (isPrev) rememberDismissState() else dismissState,
        spaceToSwipe = swipeProperties.spaceToSwipe,
        enabled = !isLast,
        dismissThreshold = swipeProperties.swipeThreshold,
        modifier = modifier,
    ) {
        Box(
            modifier = Modifier
                .takeIf { isPrev }
                ?.graphicsLayer {
                    translationX =
                        swipeProperties.slideInHorizontally(size.width.toInt())
                            .toFloat() -
                                swipeProperties.slideInHorizontally(
                                    dismissState.offset.value.absoluteValue.toInt(),
                                )
                }?.drawWithContent {
                    drawContent()
                    drawRect(
                        swipeProperties.shadowColor,
                        alpha = (1f - dismissState.progress.fraction) *
                                swipeProperties.shadowColor.alpha,
                    )
                }?.pointerInput(0) {
                    // prev entry should not be interactive until fully appeared
                } ?: Modifier,
        ) {
            content.invoke()
        }
    }
}


@Composable
@ExperimentalMaterialApi
private fun CustomSwipeToDismiss(
    state: DismissState,
    enabled: Boolean = true,
    spaceToSwipe: Dp = 10.dp,
    modifier: Modifier = Modifier,
    dismissThreshold: ThresholdConfig,
    dismissContent: @Composable () -> Unit,
) = BoxWithConstraints(modifier) {
    val width = constraints.maxWidth.toFloat()
    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

    val anchors = mutableMapOf(
        0f to DismissValue.Default,
        width to DismissValue.DismissedToEnd,
    )

    val shift = with(LocalDensity.current) {
        remember(this, width, spaceToSwipe) {
            (-width + spaceToSwipe.toPx().coerceIn(0f, width)).roundToInt()
        }
    }
    Box(
        modifier = Modifier
            .offset { IntOffset(x = shift, 0) }
            .swipeable(
                state = state,
                anchors = anchors,
                thresholds = { _, _ -> dismissThreshold },
                orientation = Orientation.Horizontal,
                enabled = enabled && state.currentValue == DismissValue.Default,
                reverseDirection = isRtl,
                resistance = ResistanceConfig(
                    basis = width,
                    factorAtMin = SwipeableDefaults.StiffResistanceFactor,
                    factorAtMax = SwipeableDefaults.StandardResistanceFactor,
                ),
            )
            .offset { IntOffset(x = -shift, 0) }
            .graphicsLayer { translationX = state.offset.value },

        ) {
        dismissContent()
    }
}

DevSrSouza avatar Oct 14 '23 21:10 DevSrSouza

I don't think being Material 3 matter for this component, actually, unless Material (not 3) will be discontinued in the future, but I don't think this is the case.

By looking into the swipeable modifier that is in use, it is the only API that are material that we most use in this could. The swipeable modifier is, currently, a pretty simple implementation on top of draggable modifier, so, we could just copy it directly into voyager if is the case to not use Material or Material3 because currently, there is no Voyager APIs that uses material directly.

DevSrSouza avatar Oct 15 '23 02:10 DevSrSouza

Error: Swipe was used multiple times at androidx.compose.runtime.saveable. When you swipe fast you will get this error how to avoid it? You can catch this bug by setting val spaceToSwipe: Dp = Int.MAX_VALUE.dp and swipe fast to the previous screen. How can I fix it? Can you help me?

vk-login-auth avatar Oct 17 '23 05:10 vk-login-auth

Hi , when there is a bottom bar it disappears and draws the screen over it , any fix i can try ? A navigator nested in a tab navigator.

aicameron10 avatar Nov 17 '23 08:11 aicameron10

for those trying @DevSrSouza solution, I amended CustomSwipeToDismiss to the below:

   Box(modifier = Modifier.graphicsLayer { translationX = state.offset.value }) {
      dismissContent()
   }

   Box(
      modifier = Modifier
         .matchParentSize()
         .offset { IntOffset(x = shift, 0) }
         .swipeable(
            state = state,
            anchors = anchors,
            thresholds = { _, _ -> dismissThreshold },
            orientation = Orientation.Horizontal,
            enabled = enabled && state.currentValue == DismissValue.Default,
            reverseDirection = isRtl,
            resistance = ResistanceConfig(
               basis = width,
               factorAtMin = SwipeableDefaults.StiffResistanceFactor,
               factorAtMax = SwipeableDefaults.StandardResistanceFactor,
            ),
         )
         .offset { IntOffset(x = -shift, 0) },

      )

The offset modifiers were causing some temporary white screen glitchiness when programmatically manipulating the backstack (ie not via gesture).

This code is still problematic if you have nested navigators since the second Box that captures the gestures will always be above nested navigator UX. For that, in my application I've just resorted to not restricting the eligible space for a swipe:

   Box(
      modifier = Modifier
         //.offset { IntOffset(x = shift, 0) }
         .swipeable(
            state = state,
            anchors = anchors,
            thresholds = { _, _ -> dismissThreshold },
            orientation = Orientation.Horizontal,
            enabled = enabled && state.currentValue == DismissValue.Default,
            reverseDirection = isRtl,
            resistance = ResistanceConfig(
               basis = width,
               factorAtMin = SwipeableDefaults.StiffResistanceFactor,
               factorAtMax = SwipeableDefaults.StandardResistanceFactor,
            ),
         )
         //.offset { IntOffset(x = -shift, 0) }
         .graphicsLayer { translationX = state.offset.value }
   ) {
      dismissContent()
   }

Nested navigation is convenient for how I have our code structured (model classes that are reused by 4-5 screens and I want to dispose of when I dispose the navigator), so long-term if I want to restrict swipe space I can just not do nested navigation. I also don't use voyager fwiw, but have adapted @DevSrSouza 's solution for my homespun navigation.

brendanw avatar Dec 18 '23 17:12 brendanw

Hi folks, sharing here an example using the PreCompose swipe back implementation in Voyager. Hopefully soon I can refine this code and bring it here again, but, for anyone that is being blocked by this, here is the snippet from Voyager Multiplatform sample that I did the implementation.

Screen.Recording.2023-10-14.at.18.17.54.mov

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.material.DismissDirection
import androidx.compose.material.DismissState
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FixedThreshold
import androidx.compose.material.ResistanceConfig
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.ThresholdConfig
import androidx.compose.material.rememberDismissState
import androidx.compose.material.swipeable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import kotlin.math.absoluteValue
import kotlin.math.roundToInt

expect val shouldUseSwipeBack: Boolean // on iOS, this would be true and on Android/Desktop, false

/**
 * @param slideInHorizontally a lambda that takes the full width of the
 * content in pixels and returns the initial offset for the slide-in prev entry,
 * by default it returns -fullWidth/4.
 * For the best impression, lambda should return the save value as [NavTransition.pauseTransition]
 * @param spaceToSwipe width of the swipe space from the left side of screen.
 * Can be set to [Int.MAX_VALUE].dp to enable full-scene swipe
 * @param swipeThreshold amount of offset to perform back navigation
 * @param shadowColor color of the shadow. Alpha channel is additionally multiplied
 * by swipe progress. Use [Color.Transparent] to disable shadow
 * */
@OptIn(ExperimentalMaterialApi::class)
class SwipeProperties(
    val slideInHorizontally: (fullWidth: Int) -> Int = { -it / 4 },
    val spaceToSwipe: Dp = 10.dp,
    val swipeThreshold: ThresholdConfig = FixedThreshold(56.dp),
    val shadowColor: Color = Color.Black.copy(alpha = .25f),
    val swipeAnimSpec: AnimationSpec<Float> = tween(),
)


@Composable
public fun SampleApplication() {
    Navigator(
        screen = BasicNavigationScreen(index = 0),
        onBackPressed = { currentScreen ->
            println("Navigator: Pop screen #${(currentScreen as BasicNavigationScreen).index}")
            true
        }
    ) { navigator ->
        val supportSwipeBack = remember { shouldUseSwipeBack }
        
        if(supportSwipeBack) {
            VoyagerSwipeBackContent(navigator)
        } else {
            SlideTransition(navigator)
        }
    }
}

@Composable
@OptIn(ExperimentalMaterialApi::class)
private fun VoyagerSwipeBackContent(navigator: Navigator) {
    val exampleSwipeProperties = remember { SwipeProperties() }

    val currentSceneEntry = navigator.lastItem
    val prevSceneEntry = remember(navigator.items) { navigator.items.getOrNull(navigator.size - 2) }

    var prevWasSwiped by remember {
        mutableStateOf(false)
    }

    LaunchedEffect(currentSceneEntry) {
        prevWasSwiped = false
    }

    val dismissState = key(currentSceneEntry) {
        rememberDismissState()
    }

    LaunchedEffect(dismissState.isDismissed(DismissDirection.StartToEnd)) {
        if (dismissState.isDismissed(DismissDirection.StartToEnd)) {
            prevWasSwiped = true
            navigator.pop()
        }
    }

    val showPrev by remember(dismissState) {
        derivedStateOf {
            dismissState.offset.value > 0f
        }
    }

    val visibleItems = remember(currentSceneEntry, prevSceneEntry, showPrev) {
        if (showPrev) {
            listOfNotNull(currentSceneEntry, prevSceneEntry)
        } else {
            listOfNotNull(currentSceneEntry)
        }
    }

    val canGoBack = remember(navigator.size) { navigator.size > 1 }

    val animationSpec = remember {
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        )
    }

    // display visible items using SwipeItem
    visibleItems.forEachIndexed { index, backStackEntry ->
        val isPrev = remember(index, visibleItems.size) {
            index == 1 && visibleItems.size > 1
        }
        AnimatedContent(
            backStackEntry,
            transitionSpec = {
                if (prevWasSwiped) {
                    EnterTransition.None togetherWith ExitTransition.None
                } else {
                    val (initialOffset, targetOffset) = when (navigator.lastEvent) {
                        StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size })
                        else -> ({ size: Int -> size }) to ({ size: Int -> -size })
                    }

                    slideInHorizontally(animationSpec, initialOffset) togetherWith
                            slideOutHorizontally(animationSpec, targetOffset)
                }
            },
            modifier = Modifier.zIndex(
                if (isPrev) {
                    0f
                } else {
                    1f
                },
            ),
        ) { screen ->
            SwipeItem(
                dismissState = dismissState,
                swipeProperties = exampleSwipeProperties,//actualSwipeProperties,
                isPrev = isPrev,
                isLast = !canGoBack,
            ) {
                navigator.saveableState("swipe", screen) {
                    screen.Content()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SwipeItem(
    dismissState: DismissState,
    swipeProperties: SwipeProperties,
    isPrev: Boolean,
    isLast: Boolean,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    CustomSwipeToDismiss(
        state = if (isPrev) rememberDismissState() else dismissState,
        spaceToSwipe = swipeProperties.spaceToSwipe,
        enabled = !isLast,
        dismissThreshold = swipeProperties.swipeThreshold,
        modifier = modifier,
    ) {
        Box(
            modifier = Modifier
                .takeIf { isPrev }
                ?.graphicsLayer {
                    translationX =
                        swipeProperties.slideInHorizontally(size.width.toInt())
                            .toFloat() -
                                swipeProperties.slideInHorizontally(
                                    dismissState.offset.value.absoluteValue.toInt(),
                                )
                }?.drawWithContent {
                    drawContent()
                    drawRect(
                        swipeProperties.shadowColor,
                        alpha = (1f - dismissState.progress.fraction) *
                                swipeProperties.shadowColor.alpha,
                    )
                }?.pointerInput(0) {
                    // prev entry should not be interactive until fully appeared
                } ?: Modifier,
        ) {
            content.invoke()
        }
    }
}


@Composable
@ExperimentalMaterialApi
private fun CustomSwipeToDismiss(
    state: DismissState,
    enabled: Boolean = true,
    spaceToSwipe: Dp = 10.dp,
    modifier: Modifier = Modifier,
    dismissThreshold: ThresholdConfig,
    dismissContent: @Composable () -> Unit,
) = BoxWithConstraints(modifier) {
    val width = constraints.maxWidth.toFloat()
    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

    val anchors = mutableMapOf(
        0f to DismissValue.Default,
        width to DismissValue.DismissedToEnd,
    )

    val shift = with(LocalDensity.current) {
        remember(this, width, spaceToSwipe) {
            (-width + spaceToSwipe.toPx().coerceIn(0f, width)).roundToInt()
        }
    }
    Box(
        modifier = Modifier
            .offset { IntOffset(x = shift, 0) }
            .swipeable(
                state = state,
                anchors = anchors,
                thresholds = { _, _ -> dismissThreshold },
                orientation = Orientation.Horizontal,
                enabled = enabled && state.currentValue == DismissValue.Default,
                reverseDirection = isRtl,
                resistance = ResistanceConfig(
                    basis = width,
                    factorAtMin = SwipeableDefaults.StiffResistanceFactor,
                    factorAtMax = SwipeableDefaults.StandardResistanceFactor,
                ),
            )
            .offset { IntOffset(x = -shift, 0) }
            .graphicsLayer { translationX = state.offset.value },

        ) {
        dismissContent()
    }
}

Thank you very much, I tried to use it and found that the gesture triggers the previous page's lifecycle as soon as it starts, I hope it can be triggered again at the end of the gesture, hopefully it will be available soon! Thank you. @DevSrSouza

liufeng382641424 avatar Feb 19 '24 03:02 liufeng382641424

Hi folks! I was trying to implement it but i have some glitches when coming back to the previous screen, like fast white screen

planasmultimedia avatar Feb 21 '24 19:02 planasmultimedia

Okay I've been working on this for longer than I care to admit, but I am using this right now in my app (still in testing phase, but almost ready for release). The problem is that the animations in Jetpack compose do not support setting the progress manually (I think it's internal or private). So you'll have to do that all by yourself unfortunately. Also the example code above tries to combine the AnimatedContent with a custom one if it is swiping. While this could work, when the animation is done it would clear the all the remembers (onDispose will be called). In my version I fixed that, but unfortunately like a said before you have to define the animation yourself. Inside the Transition files you can edit the transition to your liking.

The gist is here: https://gist.github.com/kevinvanmierlo/8e051c96c84de9f5c921912d28414038. There a quite a few files since I also added predictive back gesture for Android. If iOS is all you care about you can skip the Android part and just use AnimatedContent there.

Let me know what you think! The code could probably be better at a few places, but it took a while to get this working solid. So feedback is welcome.

kevinvanmierlo avatar Feb 23 '24 13:02 kevinvanmierlo

Hey! Do we have a solution for this "Support iOS-like back gesture navigation"?

eduruesta avatar Mar 06 '24 17:03 eduruesta

Yes, the comment above yours has a implementation you can use.

Syer10 avatar Mar 06 '24 17:03 Syer10

Thanks!! i just tested and worked!!

eduruesta avatar Mar 06 '24 18:03 eduruesta

Hi folks, sharing here an example using the PreCompose swipe back implementation in Voyager. Hopefully soon I can refine this code and bring it here again, but, for anyone that is being blocked by this, here is the snippet from Voyager Multiplatform sample that I did the implementation.

Screen.Recording.2023-10-14.at.18.17.54.mov

import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.EnterTransition
import androidx.compose.animation.ExitTransition
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.Spring
import androidx.compose.animation.core.VisibilityThreshold
import androidx.compose.animation.core.spring
import androidx.compose.animation.core.tween
import androidx.compose.animation.slideInHorizontally
import androidx.compose.animation.slideOutHorizontally
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.offset
import androidx.compose.material.DismissDirection
import androidx.compose.material.DismissState
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FixedThreshold
import androidx.compose.material.ResistanceConfig
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.ThresholdConfig
import androidx.compose.material.rememberDismissState
import androidx.compose.material.swipeable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import kotlin.math.absoluteValue
import kotlin.math.roundToInt

expect val shouldUseSwipeBack: Boolean // on iOS, this would be true and on Android/Desktop, false

/**
 * @param slideInHorizontally a lambda that takes the full width of the
 * content in pixels and returns the initial offset for the slide-in prev entry,
 * by default it returns -fullWidth/4.
 * For the best impression, lambda should return the save value as [NavTransition.pauseTransition]
 * @param spaceToSwipe width of the swipe space from the left side of screen.
 * Can be set to [Int.MAX_VALUE].dp to enable full-scene swipe
 * @param swipeThreshold amount of offset to perform back navigation
 * @param shadowColor color of the shadow. Alpha channel is additionally multiplied
 * by swipe progress. Use [Color.Transparent] to disable shadow
 * */
@OptIn(ExperimentalMaterialApi::class)
class SwipeProperties(
    val slideInHorizontally: (fullWidth: Int) -> Int = { -it / 4 },
    val spaceToSwipe: Dp = 10.dp,
    val swipeThreshold: ThresholdConfig = FixedThreshold(56.dp),
    val shadowColor: Color = Color.Black.copy(alpha = .25f),
    val swipeAnimSpec: AnimationSpec<Float> = tween(),
)


@Composable
public fun SampleApplication() {
    Navigator(
        screen = BasicNavigationScreen(index = 0),
        onBackPressed = { currentScreen ->
            println("Navigator: Pop screen #${(currentScreen as BasicNavigationScreen).index}")
            true
        }
    ) { navigator ->
        val supportSwipeBack = remember { shouldUseSwipeBack }
        
        if(supportSwipeBack) {
            VoyagerSwipeBackContent(navigator)
        } else {
            SlideTransition(navigator)
        }
    }
}

@Composable
@OptIn(ExperimentalMaterialApi::class)
private fun VoyagerSwipeBackContent(navigator: Navigator) {
    val exampleSwipeProperties = remember { SwipeProperties() }

    val currentSceneEntry = navigator.lastItem
    val prevSceneEntry = remember(navigator.items) { navigator.items.getOrNull(navigator.size - 2) }

    var prevWasSwiped by remember {
        mutableStateOf(false)
    }

    LaunchedEffect(currentSceneEntry) {
        prevWasSwiped = false
    }

    val dismissState = key(currentSceneEntry) {
        rememberDismissState()
    }

    LaunchedEffect(dismissState.isDismissed(DismissDirection.StartToEnd)) {
        if (dismissState.isDismissed(DismissDirection.StartToEnd)) {
            prevWasSwiped = true
            navigator.pop()
        }
    }

    val showPrev by remember(dismissState) {
        derivedStateOf {
            dismissState.offset.value > 0f
        }
    }

    val visibleItems = remember(currentSceneEntry, prevSceneEntry, showPrev) {
        if (showPrev) {
            listOfNotNull(currentSceneEntry, prevSceneEntry)
        } else {
            listOfNotNull(currentSceneEntry)
        }
    }

    val canGoBack = remember(navigator.size) { navigator.size > 1 }

    val animationSpec = remember {
        spring(
            stiffness = Spring.StiffnessMediumLow,
            visibilityThreshold = IntOffset.VisibilityThreshold
        )
    }

    // display visible items using SwipeItem
    visibleItems.forEachIndexed { index, backStackEntry ->
        val isPrev = remember(index, visibleItems.size) {
            index == 1 && visibleItems.size > 1
        }
        AnimatedContent(
            backStackEntry,
            transitionSpec = {
                if (prevWasSwiped) {
                    EnterTransition.None togetherWith ExitTransition.None
                } else {
                    val (initialOffset, targetOffset) = when (navigator.lastEvent) {
                        StackEvent.Pop -> ({ size: Int -> -size }) to ({ size: Int -> size })
                        else -> ({ size: Int -> size }) to ({ size: Int -> -size })
                    }

                    slideInHorizontally(animationSpec, initialOffset) togetherWith
                            slideOutHorizontally(animationSpec, targetOffset)
                }
            },
            modifier = Modifier.zIndex(
                if (isPrev) {
                    0f
                } else {
                    1f
                },
            ),
        ) { screen ->
            SwipeItem(
                dismissState = dismissState,
                swipeProperties = exampleSwipeProperties,//actualSwipeProperties,
                isPrev = isPrev,
                isLast = !canGoBack,
            ) {
                navigator.saveableState("swipe", screen) {
                    screen.Content()
                }
            }
        }
    }
}

@OptIn(ExperimentalMaterialApi::class)
@Composable
private fun SwipeItem(
    dismissState: DismissState,
    swipeProperties: SwipeProperties,
    isPrev: Boolean,
    isLast: Boolean,
    modifier: Modifier = Modifier,
    content: @Composable () -> Unit,
) {
    CustomSwipeToDismiss(
        state = if (isPrev) rememberDismissState() else dismissState,
        spaceToSwipe = swipeProperties.spaceToSwipe,
        enabled = !isLast,
        dismissThreshold = swipeProperties.swipeThreshold,
        modifier = modifier,
    ) {
        Box(
            modifier = Modifier
                .takeIf { isPrev }
                ?.graphicsLayer {
                    translationX =
                        swipeProperties.slideInHorizontally(size.width.toInt())
                            .toFloat() -
                                swipeProperties.slideInHorizontally(
                                    dismissState.offset.value.absoluteValue.toInt(),
                                )
                }?.drawWithContent {
                    drawContent()
                    drawRect(
                        swipeProperties.shadowColor,
                        alpha = (1f - dismissState.progress.fraction) *
                                swipeProperties.shadowColor.alpha,
                    )
                }?.pointerInput(0) {
                    // prev entry should not be interactive until fully appeared
                } ?: Modifier,
        ) {
            content.invoke()
        }
    }
}


@Composable
@ExperimentalMaterialApi
private fun CustomSwipeToDismiss(
    state: DismissState,
    enabled: Boolean = true,
    spaceToSwipe: Dp = 10.dp,
    modifier: Modifier = Modifier,
    dismissThreshold: ThresholdConfig,
    dismissContent: @Composable () -> Unit,
) = BoxWithConstraints(modifier) {
    val width = constraints.maxWidth.toFloat()
    val isRtl = LocalLayoutDirection.current == LayoutDirection.Rtl

    val anchors = mutableMapOf(
        0f to DismissValue.Default,
        width to DismissValue.DismissedToEnd,
    )

    val shift = with(LocalDensity.current) {
        remember(this, width, spaceToSwipe) {
            (-width + spaceToSwipe.toPx().coerceIn(0f, width)).roundToInt()
        }
    }
    Box(
        modifier = Modifier
            .offset { IntOffset(x = shift, 0) }
            .swipeable(
                state = state,
                anchors = anchors,
                thresholds = { _, _ -> dismissThreshold },
                orientation = Orientation.Horizontal,
                enabled = enabled && state.currentValue == DismissValue.Default,
                reverseDirection = isRtl,
                resistance = ResistanceConfig(
                    basis = width,
                    factorAtMin = SwipeableDefaults.StiffResistanceFactor,
                    factorAtMax = SwipeableDefaults.StandardResistanceFactor,
                ),
            )
            .offset { IntOffset(x = -shift, 0) }
            .graphicsLayer { translationX = state.offset.value },

        ) {
        dismissContent()
    }
}

Hi, your code is great, but what if I only want next screen that be pushed have slideIn animation. How can I do that? Thanks

hoanghai9650 avatar Mar 20 '24 08:03 hoanghai9650

Big thanks to @kevinvanmierlo, I've changed the code to fix some bugs and crashes, also changed deprecated Swipeable to AnchoredDraggable API: This is the code I use for my app: https://gist.github.com/rewhex/ff9fecb4bdacbd10921f55b580539aa0 Note that this is only for iOS back gesture navigation as I don't want to add predictive back gesture on Android.

rewhex avatar Apr 30 '24 12:04 rewhex

@rewhex Nice! Didn't know the swipeable was already deprecated haha. I'll take a look at the changes you've made later. I also saw in the Android issue someone noticed Compose finally has a SeekableTransitionState so probably the code could be a lot smaller. When I have the time I'll also update the code to include that.

kevinvanmierlo avatar Apr 30 '24 13:04 kevinvanmierlo

Thanks for the solutions. Will there still be official support in the Voyager Library? And if so, is it foreseeable when this will happen?

harry248 avatar May 24 '24 06:05 harry248

Now when I start to swipe, the previous page starts to refresh immediately. This problem bothers me. How can I refresh the previous page after the current page is closed by swiping? Thanks @rewhex

liufeng382641424 avatar Jun 05 '24 10:06 liufeng382641424

@liufeng382641424 You can check if you are on top of the navigator and only start refreshing when that happens. The current screen will not be on top while you are still swiping.

kevinvanmierlo avatar Jul 09 '24 07:07 kevinvanmierlo

@kevinvanmierlo When I call replace to navigate in Android, I got following error. If I push(not replace) error wasn't occurred. How can I fix this error?

java.lang.IllegalArgumentException: Key presentation.MainScreenContainer:transition was used multiple times 
at androidx.compose.runtime.saveable.SaveableStateHolderImpl$SaveableStateProvider$1$1$1.invoke(SaveableStateHolder.kt:89)
at androidx.compose.runtime.saveable.SaveableStateHolderImpl$SaveableStateProvider$1$1$1.invoke(SaveableStateHolder.kt:88)
at androidx.compose.runtime.DisposableEffectImpl.onRemembered(Effects.kt:83)

briandr97 avatar Jul 16 '24 07:07 briandr97

@briandr97 What are you replacing? Usually this happens because the same screen is being used. So usually to fix this you need to create a new screen with a new screen key.

kevinvanmierlo avatar Jul 16 '24 08:07 kevinvanmierlo

@kevinvanmierlo Thank you, I find the reason. It was my fault. Thank you for your answer

briandr97 avatar Jul 17 '24 02:07 briandr97

@rewhex / @kevinvanmierlo Unfortunately, the solution doesn't seem to work anymore with Compose Multiplatform 1.7.0-alpha01. The transition works, but dragging doesn't move the screens while the dragging continues. I have tried to fix the problem but have had no luck so far.

harry248 avatar Jul 19 '24 08:07 harry248

@rewhex / @kevinvanmierlo Unfortunately, the solution doesn't seem to work anymore with Compose Multiplatform 1.7.0-alpha01. The transition works, but dragging doesn't move the screens while the dragging continues. I have tried to fix the problem but have had no luck so far.

I removed derivedStateOf from anchoredDraggableState and it works for me. Additionally, you need to define the variables snapAnimationSpec and decayAnimationSpec in AnchoredDraggableState. Here is the code I am using:

val anchoredDraggableState = remember {
    AnchoredDraggableState(
        initialValue = DismissValue.Default,
        anchors = anchors,
        positionalThreshold = { distance -> distance * 0.2f },
        velocityThreshold = { with(density) { 125.dp.toPx() } },
        snapAnimationSpec = SpringSpec(stiffness = StiffnessLow),
        decayAnimationSpec = exponentialDecay()
    )
}

omaeewa avatar Jul 22 '24 05:07 omaeewa

@omaeewa Thanks, removing the derivedStateOf was the missing part. Really hope there will be some official solution soon.

harry248 avatar Jul 22 '24 13:07 harry248

I amended @kevinvanmierlo / @rewhex's solutions with two tweaks; dropping them here in case anyone finds them useful. Beware: they're pretty hacky.

  1. Their implementation triggers the gesture anywhere on the screen, whereas most iOS apps I've tried trigger them only at the left edge of the screen (an exception to this is Twitter). I'm no iOS expert so I don't know which feels more natural to users, but in my opinion making the entire screen draggable feels a bit brittle so I reduced the handle to just the edge.

    To do so, I moved the anchoredDraggable to a child invisible Box which acts as the handle:

    animatedScreens.fastForEach { screen ->
      key(screen.screen.key) {
        navigator.saveableState("transition", screen.screen) {
          Box(
            Modifier
              .fillMaxSize()
              .animatingModifier(screen),
          ) {
            screen.screen.Content()
            Box(
              Modifier
                .fillMaxHeight()
                .width(16.dp)
                .padding(top = 80.dp)
                .then(if (screen == currentScreen) currentScreenModifier else Modifier),
            )
          }
        }
      }
    }
    

    The downside of this is that you can't interact with anything behind the handle. This is a fair compromise for me, as my app doesn't contain interactable elements that close to the edge of the screen. Also, I've added .padding(top = 80.dp), leaving a 80dp space which in my app is occupied by the back button.

    There's probably a more idiomatic way of achieving this 😆

  2. The default anchorDraggable implementation has a flaw that bothers me: if you drag left-to-right but end your gesture right-to-left, the gesture still completes and goes back to the parent screen. To reproduce, you can try the following:

    Drag from 0% to 80% in 1 second. Hold. Drag from 80% to 60% in half a second. Release.

    In iOS, the above would have aborted the back gesture, because the momentum of the screen was left, which feels natural. However, if we bypass 40%, positionalThreshold triggers which completes the gesture and feels unresponsive and unnatural. The default Voyager BottomSheet also suffers from this.

    To fix it, I pass a confirmValueChange that aborts the gesture if the last movement of the screen was tending left with sufficient velocity:

    val offsetHistory = remember { mutableListOf<Float>() }
    
    val anchoredDraggableState by remember {
      derivedStateOf {
        AnchoredDraggableState(
          initialValue = DismissValue.Default,
          anchors = anchors,
          positionalThreshold = { distance -> distance * 0.4f },
          velocityThreshold = { with(density) { 125.dp.toPx() } },
          animationSpec = SpringSpec(),
          confirmValueChange = {
            if (it == DismissValue.DismissedToEnd) {
              val last = offsetHistory.lastOrNull() ?: return@AnchoredDraggableState true
              val secondToLast = if (offsetHistory.size >= 2) {
                offsetHistory[offsetHistory.size -2]
              } else {
                return@AnchoredDraggableState true
              }
    
              offsetHistory.clear()
              return@AnchoredDraggableState (last + with(density) { 10.dp.toPx() } > secondToLast)
            }
    
            true
          }
        )
      }
    }
    
    // ...
    
    val offset = anchoredDraggableState.offset
    
    LaunchedEffect(offset) {
      offsetHistory.add(offset)
    }
    

MeLlamoPablo avatar Jul 31 '24 22:07 MeLlamoPablo