voyager
voyager copied to clipboard
Support ios-like back gesture navigation
Some references to other implementations:
https://github.com/adrielcafe/voyager/assets/3532155/449ef882-41dc-4e2d-bbfd-115f911044ed
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
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 :)
Would like to know if there is anyone who has a workaround for this in the mean time?
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
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()
}
}
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.
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?
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.
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.
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
Hi folks! I was trying to implement it but i have some glitches when coming back to the previous screen, like fast white screen
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.
Hey! Do we have a solution for this "Support iOS-like back gesture navigation"?
Yes, the comment above yours has a implementation you can use.
Thanks!! i just tested and worked!!
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
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 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.
Thanks for the solutions. Will there still be official support in the Voyager Library? And if so, is it foreseeable when this will happen?
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 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 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 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 Thank you, I find the reason. It was my fault. Thank you for your answer
@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.
@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 Thanks, removing the derivedStateOf
was the missing part. Really hope there will be some official solution soon.
I amended @kevinvanmierlo / @rewhex's solutions with two tweaks; dropping them here in case anyone finds them useful. Beware: they're pretty hacky.
-
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 invisibleBox
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 😆
-
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) }