adrielcafe / voyager

🛸 A pragmatic navigation library for Jetpack Compose
https://voyager.adriel.cafe
MIT License
2.47k stars 124 forks source link

Support ios-like back gesture navigation #144

Open terrakok opened 1 year ago

terrakok commented 1 year ago

Some references to other implementations: 1) compose-look-and-feel

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

2) Arkadii Ivanov PoC

alexzhirkevich commented 1 year ago

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

codlab commented 11 months ago

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 :)

rjmangubat23 commented 10 months ago

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

rjmangubat23 commented 10 months ago

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

DevSrSouza commented 10 months ago

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 commented 10 months ago

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.

vk-login-auth commented 10 months ago

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?

aicameron10 commented 9 months ago

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.

brendanw commented 8 months ago

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.

liufeng382641424 commented 6 months ago

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

planasmultimedia commented 6 months ago

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

kevinvanmierlo commented 6 months ago

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.

eduruesta commented 5 months ago

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

Syer10 commented 5 months ago

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

eduruesta commented 5 months ago

Thanks!! i just tested and worked!!

hoanghai9650 commented 5 months ago

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

rewhex commented 3 months ago

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.

kevinvanmierlo commented 3 months ago

@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.

harry248 commented 3 months ago

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

liufeng382641424 commented 2 months ago

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

kevinvanmierlo commented 1 month ago

@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.

briandr97 commented 1 month ago

@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)
kevinvanmierlo commented 1 month ago

@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.

briandr97 commented 1 month ago

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

harry248 commented 1 month ago

@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.

omaeewa commented 1 month ago

@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()
    )
}
harry248 commented 1 month ago

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

MeLlamoPablo commented 3 weeks ago

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)
    }