Skip to content

Instantly share code, notes, and snippets.

Show Gist options
  • Save kevinvanmierlo/8e051c96c84de9f5c921912d28414038 to your computer and use it in GitHub Desktop.
Save kevinvanmierlo/8e051c96c84de9f5c921912d28414038 to your computer and use it in GitHub Desktop.
Voyager Android Predictive Back Gesture And iOS Swipe to go back

This is the gist which explains how to use Android predictive back gesture and iOS swipe to go back when using Voyager in Compose multiplatform.

If you are just using Android, you can merge the commonMain stuff and the androidMain stuff.

// Put this in the commmonMain folder
package nl.kevinvanmierlo.testapp
@Composable
fun App() {
MyApplicationTheme {
KMNavigator(TabBarScreen())
}
}
@OptIn(ExperimentalVoyagerApi::class)
@Composable
public fun KMNavigator(
screen: Screen,
disposeBehavior: NavigatorDisposeBehavior = NavigatorDisposeBehavior(),
onBackPressed: OnBackPressed = { true },
content: NavigatorContent = { KMNavigatorContent(navigator = it) }
) {
Navigator(
screen = screen,
disposeBehavior = disposeBehavior,
onBackPressed = onBackPressed,
content = content
)
}
}
@Composable
public fun KMNavigatorContent(navigator: Navigator) {
PlatformNavigatorContent(navigator)
}
// Put this in the androidMain folder
package nl.kevinvanmierlo.testapp
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.util.fastForEach
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import kotlinx.coroutines.launch
@Composable
actual fun PlatformNavigatorContent(navigator: Navigator) {
val coroutineScope = rememberCoroutineScope()
var currentScreen by remember { mutableStateOf<ScreenHolder?>(null) }
val animatedScreens = remember { mutableStateListOf<ScreenHolder>() }
var peekingScreen by remember { mutableStateOf<ScreenHolder?>(null) }
PredictiveBackHandler(
enabled = navigator.canPop || navigator.parent?.canPop ?: false,
onBackStarted = {
if(navigator.size >= 2) {
currentScreen?.transition = AndroidSlideOverTransition()
currentScreen?.transition?.startPeeking(isPrevScreen = false)
peekingScreen = ScreenHolder(navigator.items[navigator.size - 2])
peekingScreen?.transition = AndroidSlideOverTransition()
peekingScreen?.transition?.startPeeking(isPrevScreen = true)
animatedScreens.add(0, peekingScreen!!)
}
},
onBackProgressed = { event ->
peekingScreen?.let {
// val peekingFraction = event.progress * 0.3f
val peekingFraction = event.progress
coroutineScope.launch {
currentScreen?.transition?.transitionAnimatable?.snapTo(peekingFraction)
}
coroutineScope.launch {
it.transition?.transitionAnimatable?.snapTo(peekingFraction)
}
}
},
onBackCancelled = {
currentScreen?.let { currentScreen ->
coroutineScope.launch {
currentScreen.transition?.stopPeeking()
currentScreen.transition = null
}
}
peekingScreen?.let { peekingScreen ->
coroutineScope.launch {
peekingScreen.transition?.stopPeeking()
peekingScreen.transition = null
animatedScreens.remove(peekingScreen)
}
}
peekingScreen = null
},
onBack = {
peekingScreen = null
if(navigator.pop().not()) {
navigator.parent?.pop()
}
}
)
// Put it here, otherwise lastevent is wrong
val lastEvent = navigator.lastEvent
StateChangeEffect(key1 = navigator.lastItemOrNull, canRunEffect = { navigator.lastItemOrNull != null }) {
val foundScreen = animatedScreens.findLast { it.screen == navigator.lastItem }
val newScreen = foundScreen ?: ScreenHolder(navigator.lastItem)
// Screen can already be in animatedScreens when peeking
if(foundScreen == null) {
if(lastEvent == StackEvent.Pop) {
animatedScreens.add(0, newScreen)
} else {
animatedScreens.add(newScreen)
}
}
currentScreen?.let { currentScreen ->
if(currentScreen.transition == null) {
currentScreen.transition = AndroidSlideOverTransition()
}
if(newScreen.transition == null) {
newScreen.transition = AndroidSlideOverTransition()
}
coroutineScope.launch {
newScreen.transition?.startTransition(lastStackEvent = lastEvent, isAnimatingIn = true)
newScreen.transition = null
}
coroutineScope.launch {
currentScreen.transition?.startTransition(lastStackEvent = lastEvent, isAnimatingAway = true,)
animatedScreens.remove(currentScreen)
}
}
currentScreen = newScreen
}
animatedScreens.fastForEach { screen ->
key(screen.screen.key) {
navigator.saveableState("transition", screen.screen) {
Box(
modifier = Modifier
.fillMaxSize()
.animatingModifier(screen)
) {
screen.screen.Content()
}
}
}
}
}
fun Modifier.animatingModifier(screenHolder: ScreenHolder) = screenHolder.run { this@animatingModifier.animatingModifier() }
class ScreenHolder(val screen: Screen) {
var transition by mutableStateOf<AndroidNavigatorScreenTransition?>(null)
fun Modifier.animatingModifier(): Modifier = transition?.run { this@animatingModifier.animatingModifier() } ?: this
}
// Put this in the androidMain folder
package nl.kevinvanmierlo.testapp
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.layout.layout
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import cafe.adriel.voyager.core.stack.StackEvent
abstract class AndroidNavigatorScreenTransition {
var lastStackEvent by mutableStateOf(StackEvent.Idle)
var isAnimatingIn by mutableStateOf(false)
var isAnimatingAway by mutableStateOf(false)
var transitionAnimatable = Animatable(0f)
var easeFunc: EaseFunc = EasingProvider.get(Ease.SINE_IN_OUT)
fun startPeeking(isPrevScreen: Boolean) {
this.lastStackEvent = StackEvent.Pop
this.isAnimatingIn = isPrevScreen
this.isAnimatingAway = !isPrevScreen
}
suspend fun stopPeeking() {
val durationMillis = 400f * (1f - transitionAnimatable.value)
transitionAnimatable.animateTo(0f, tween(durationMillis.toInt(), easing = LinearEasing))
}
suspend fun startTransition(lastStackEvent: StackEvent, isAnimatingIn: Boolean = false, isAnimatingAway: Boolean = false) {
this.lastStackEvent = lastStackEvent
this.isAnimatingIn = isAnimatingIn
this.isAnimatingAway = isAnimatingAway
transitionAnimatable.animateTo(1f, tween(400, easing = LinearEasing))
}
abstract fun Modifier.animatingModifier(): Modifier
}
class AndroidSlideOverTransition : AndroidNavigatorScreenTransition() {
override fun Modifier.animatingModifier(): Modifier = composed {
var modifier = this
val isPop = lastStackEvent == StackEvent.Pop
val transitionFractionState by remember { transitionAnimatable.asState() }
val transitionFraction by remember { derivedStateOf { easeFunc(transitionFractionState) } }
if(isAnimatingAway) {
if(isPop) {
// modifier = modifier
// .background(Color.Black.copy(alpha = (1f - transitionFraction) * 0.5f))
// .slideFraction(transitionFraction)
modifier = modifier
.slideFraction(0.3f * transitionFraction)
.graphicsLayer(
scaleX = 1f - (0.1f * transitionFraction),
scaleY = 1f - (0.1f * transitionFraction),
alpha = 1f - transitionFraction,
)
} else {
// Don't do anything, scrim will be put by overlay
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.slideFraction(-0.3f * transitionFraction)
.graphicsLayer(
scaleX = 1f - (0.1f * transitionFraction),
scaleY = 1f - (0.1f * transitionFraction),
alpha = 1f - transitionFraction,
)
}
} else if(isAnimatingIn) {
if(isPop) {
// Don't do anything, scrim will be put by overlay
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.slideFraction(-0.3f + (0.3f * transitionFraction))
.graphicsLayer(
scaleX = 0.9f + (0.1f * transitionFraction),
scaleY = 0.9f + (0.1f * transitionFraction),
alpha = transitionFraction,
)
} else {
// modifier = modifier
// .background(Color.Black.copy(alpha = transitionFraction * 0.5f))
// .slideFraction(1f - transitionFraction)
modifier = modifier
.slideFraction(0.3f - (0.3f * transitionFraction))
.graphicsLayer(
scaleX = 0.9f + (0.1f * transitionFraction),
scaleY = 0.9f + (0.1f * transitionFraction),
alpha = transitionFraction,
)
}
}
modifier
}
fun Modifier.slideFraction(fraction: Float): Modifier = this.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val measuredSize = IntSize(placeable.width, placeable.height)
layout(placeable.width, placeable.height) {
val slideValue = (measuredSize.width.toFloat() * fraction).toInt()
placeable.placeWithLayer(IntOffset(x = slideValue, y = 0))
}
}
}
// Put this in the androidMain folder
package nl.kevinvanmierlo.testapp
import androidx.activity.BackEventCompat
import androidx.activity.OnBackPressedCallback
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.platform.LocalLifecycleOwner
@Composable
public fun PredictiveBackHandler(
enabled: Boolean = true,
onBackStarted: () -> Unit,
onBackProgressed: (backEvent: BackEventCompat) -> Unit,
onBackCancelled: () -> Unit,
onBack: () -> Unit
) {
// Safely update the current `onBack` lambda when a new one is provided
val currentOnBack by rememberUpdatedState(onBack)
// Remember in Composition a back callback that calls the `onBack` lambda
val backCallback = remember {
object : OnBackPressedCallback(enabled) {
override fun handleOnBackCancelled() {
onBackCancelled()
}
override fun handleOnBackProgressed(backEvent: BackEventCompat) {
onBackProgressed(backEvent)
}
override fun handleOnBackStarted(backEvent: BackEventCompat) {
onBackStarted()
}
override fun handleOnBackPressed() {
currentOnBack()
}
}
}
// On every successful composition, update the callback with the `enabled` value
SideEffect {
backCallback.isEnabled = enabled
}
val backDispatcher = checkNotNull(LocalOnBackPressedDispatcherOwner.current) {
"No OnBackPressedDispatcherOwner was provided via LocalOnBackPressedDispatcherOwner"
}.onBackPressedDispatcher
val lifecycleOwner = LocalLifecycleOwner.current
DisposableEffect(lifecycleOwner, backDispatcher) {
// Add callback to the backDispatcher
backDispatcher.addCallback(lifecycleOwner, backCallback)
// When the effect leaves the Composition, remove the callback
onDispose {
backCallback.remove()
}
}
}
// Put this in the commmonMain folder
package nl.kevinvanmierlo.testapp
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.RememberObserver
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
/**
* This is different from the normal StateChangeEffect in that this only gives a callback on change
*/
@Composable
@NonRestartableComposable
fun StateChangeEffectIgnoringInitial(
key1: Any?,
canRunEffect: () -> Boolean = { true },
effect: () -> Unit
) {
var firstRemember by remember { mutableStateOf(true) }
remember(key1) {
StateChangeEffectImpl(
remembered = {
firstRemember = false
},
canRunEffect = canRunEffect,
effect = effect,
canRun = !firstRemember
)
}
}
@Composable
@NonRestartableComposable
fun StateChangeEffect(
key1: Any?,
canRunEffect: () -> Boolean = { true },
effect: () -> Unit
) {
remember(key1) { StateChangeEffectImpl(
canRunEffect = canRunEffect,
effect = effect,
) }
}
@Composable
@NonRestartableComposable
fun StateChangeEffect(
key1: Any?,
key2: Any?,
canRunEffect: () -> Boolean = { true },
effect: () -> Unit
) {
remember(key1, key2) {
StateChangeEffectImpl(
canRunEffect = canRunEffect,
effect = effect,
)
}
}
private class StateChangeEffectImpl(
private val remembered: () -> Unit = {},
private val canRunEffect: () -> Boolean,
private val effect: () -> Unit,
private val canRun: Boolean = true,
) : RememberObserver {
override fun onRemembered() {
remembered()
// Remember will always be called once, but state change often only wants to get called when something changed
if(canRun && canRunEffect()) {
effect()
}
}
override fun onForgotten() {
// Nothing to do, we only need onRemembered to run
}
override fun onAbandoned() {
// Nothing to do as [onRemembered] was not called.
}
}
// Put this in the commonMain folder
package nl.kevinvanmierlo.testapp
import kotlin.math.PI
import kotlin.math.abs
import kotlin.math.cos
import kotlin.math.pow
import kotlin.math.sin
import kotlin.math.sqrt
/**
* The Easing class provides a collection of ease functions. It does not use the standard 4 param
* ease signature. Instead it uses a single param which indicates the current linear ratio (0 to 1) of the tween.
*/
class EasingInterpolator(val ease: Ease) {
val easingProvider = EasingProvider.get(ease)
fun getInterpolation(input: Float): Float {
return easingProvider(input)
}
}
/**
* The Easing class provides a collection of ease functions. It does not use the standard 4 param
* ease signature. Instead it uses a single param which indicates the current linear ratio (0 to 1) of the tween.
*/
enum class Ease {
LINEAR,
QUAD_IN,
QUAD_OUT,
QUAD_IN_OUT,
CUBIC_IN,
CUBIC_OUT,
CUBIC_IN_OUT,
QUART_IN,
QUART_OUT,
QUART_IN_OUT,
QUINT_IN,
QUINT_OUT,
QUINT_IN_OUT,
SINE_IN,
SINE_OUT,
SINE_IN_OUT,
BACK_IN,
BACK_OUT,
BACK_IN_OUT,
CIRC_IN,
CIRC_OUT,
CIRC_IN_OUT
}
typealias EaseFunc = (Float) -> Float
/**
* The Easing class provides a collection of ease functions. It does not use the standard 4 param
* ease signature. Instead it uses a single param which indicates the current linear ratio (0 to 1) of the tween.
*/
internal object EasingProvider {
operator fun get(ease: Ease?): EaseFunc {
return when(ease) {
Ease.LINEAR -> { { it } }
Ease.QUAD_IN -> { { getPowIn(it, 2.0) } }
Ease.QUAD_OUT -> { { getPowOut(it, 2.0) } }
Ease.QUAD_IN_OUT -> { { getPowInOut(it, 2.0) } }
Ease.CUBIC_IN -> { { getPowIn(it, 3.0) } }
Ease.CUBIC_OUT -> { { getPowOut(it, 3.0) } }
Ease.CUBIC_IN_OUT -> { { getPowInOut(it, 3.0) } }
Ease.QUART_IN -> { { getPowIn(it, 4.0) } }
Ease.QUART_OUT -> { { getPowOut(it, 4.0) } }
Ease.QUART_IN_OUT -> { { getPowInOut(it, 4.0) } }
Ease.QUINT_IN -> { { getPowIn(it, 5.0) } }
Ease.QUINT_OUT -> { { getPowOut(it, 5.0) } }
Ease.QUINT_IN_OUT -> { { getPowInOut(it, 5.0) } }
Ease.SINE_IN -> { { (1f - cos(it * PI / 2f)).toFloat() } }
Ease.SINE_OUT -> { { sin(it * PI / 2f).toFloat() } }
Ease.SINE_IN_OUT -> { { (-0.5f * (cos(PI * it) - 1f)).toFloat() } }
Ease.BACK_IN -> { { (it * it * ((1.7 + 1f) * it - 1.7)).toFloat() } }
Ease.BACK_OUT -> { {
val elapsedTimeRate = it - 1
(elapsedTimeRate * elapsedTimeRate * ((1.7 + 1f) * elapsedTimeRate + 1.7) + 1f).toFloat()
} }
Ease.BACK_IN_OUT -> { { getBackInOut(it, 1.7f) } }
Ease.CIRC_IN -> { { -(sqrt((1f - it * it).toDouble()) - 1).toFloat() } }
Ease.CIRC_OUT -> { {
val elapsedTimeRate = it - 1
sqrt((1f - elapsedTimeRate * elapsedTimeRate).toDouble()).toFloat()
} }
Ease.CIRC_IN_OUT -> {
{
var elapsedTimeRate = it * 2f
if (elapsedTimeRate < 1f) {
(-0.5f * (sqrt(1f - elapsedTimeRate * elapsedTimeRate) - 1f))
} else {
elapsedTimeRate -= 2f
(0.5f * (sqrt(1f - elapsedTimeRate * elapsedTimeRate) + 1f));
}
}
}
else -> { { it } }
}
}
/**
* @param elapsedTimeRate Elapsed displayDateTime / Total displayDateTime
* @param pow pow The exponent to use (ex. 3 would return a cubic ease).
* @return easedValue
*/
private fun getPowIn(elapsedTimeRate: Float, pow: Double): Float {
return elapsedTimeRate.toDouble().pow(pow).toFloat()
}
/**
* @param elapsedTimeRate Elapsed displayDateTime / Total displayDateTime
* @param pow pow The exponent to use (ex. 3 would return a cubic ease).
* @return easedValue
*/
private fun getPowOut(elapsedTimeRate: Float, pow: Double): Float {
return (1f - (1 - elapsedTimeRate).toDouble().pow(pow)).toFloat()
}
/**
* @param elapsedTimeRate Elapsed displayDateTime / Total displayDateTime
* @param pow pow The exponent to use (ex. 3 would return a cubic ease).
* @return easedValue
*/
private fun getPowInOut(elapsedTimeRate: Float, pow: Double): Float {
var elapsedTimeRate = elapsedTimeRate
return if(2.let { elapsedTimeRate *= it; elapsedTimeRate } < 1) {
(0.5 * elapsedTimeRate.toDouble().pow(pow)).toFloat()
} else (1 - 0.5 * abs((2 - elapsedTimeRate).toDouble().pow(pow))).toFloat()
}
/**
* @param elapsedTimeRate Elapsed displayDateTime / Total displayDateTime
* @param amount amount The strength of the ease.
* @return easedValue
*/
private fun getBackInOut(elapsedTimeRate: Float, amount: Float): Float {
var elapsedTimeRate = elapsedTimeRate
var amount = amount
amount *= 1.525.toFloat()
return if(2.let { elapsedTimeRate *= it; elapsedTimeRate } < 1) {
(0.5 * (elapsedTimeRate * elapsedTimeRate * ((amount + 1) * elapsedTimeRate - amount))).toFloat()
} else (0.5 * (2.let { elapsedTimeRate -= it; elapsedTimeRate } * elapsedTimeRate * ((amount + 1) * elapsedTimeRate + amount) + 2)).toFloat()
}
}
// Put this in the iosMain folder
package nl.kevinvanmierlo.testapp
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.fillMaxSize
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.FractionalThreshold
import androidx.compose.material.ResistanceConfig
import androidx.compose.material.SwipeableDefaults
import androidx.compose.material.rememberDismissState
import androidx.compose.material.rememberSwipeableState
import androidx.compose.material.swipeable
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.util.fastForEach
import cafe.adriel.voyager.core.screen.Screen
import cafe.adriel.voyager.core.stack.StackEvent
import cafe.adriel.voyager.navigator.Navigator
import kotlinx.coroutines.launch
@OptIn(ExperimentalMaterialApi::class)
@Composable
actual fun PlatformNavigatorContent(navigator: Navigator) {
val coroutineScope = rememberCoroutineScope()
var currentScreen by remember { mutableStateOf<ScreenHolder?>(null) }
val animatedScreens = remember { mutableStateListOf<ScreenHolder>() }
var peekingScreen by remember { mutableStateOf<ScreenHolder?>(null) }
var swipeState by remember { mutableStateOf(DismissState(DismissValue.Default, { true })) }
// Put it here, otherwise lastevent is wrong
val lastEvent = navigator.lastEvent
StateChangeEffect(key1 = navigator.lastItemOrNull, canRunEffect = { navigator.lastItemOrNull != null }) {
val foundScreen = animatedScreens.findLast { it.screen == navigator.lastItem }
val newScreen = foundScreen ?: ScreenHolder(navigator.lastItem)
// Screen can already be in animatedScreens when peeking
if(foundScreen == null) {
if(lastEvent == StackEvent.Pop) {
animatedScreens.add(0, newScreen)
} else {
animatedScreens.add(newScreen)
}
}
currentScreen?.let { currentScreen ->
if(currentScreen.transition == null) {
currentScreen.transition = iOSSlideTransition()
}
if(newScreen.transition == null) {
newScreen.transition = iOSSlideTransition()
}
coroutineScope.launch {
newScreen.transition?.startTransition(lastStackEvent = lastEvent, isAnimatingIn = true)
newScreen.transition = null
}
coroutineScope.launch {
currentScreen.transition?.startTransition(lastStackEvent = lastEvent, isAnimatingAway = true,)
animatedScreens.remove(currentScreen)
}
}
currentScreen = newScreen
swipeState = DismissState(DismissValue.Default, { true })
}
BoxWithConstraints(
modifier = Modifier.fillMaxSize()
) {
val maxWidthPx = constraints.maxWidth.toFloat()
val anchors = mutableMapOf(
0f to DismissValue.Default,
maxWidthPx to DismissValue.DismissedToEnd,
)
StateChangeEffect(swipeState.offset.value) {
if(swipeState.currentValue == DismissValue.Default) {
coroutineScope.launch {
if(swipeState.offset.value > 0f) {
if(currentScreen?.transition == null && navigator.size >= 2) {
currentScreen?.transition = iOSSlideTransition()
currentScreen?.transition?.startPeeking(isPrevScreen = false)
peekingScreen = ScreenHolder(navigator.items[navigator.size - 2])
peekingScreen?.transition = iOSSlideTransition()
peekingScreen?.transition?.startPeeking(isPrevScreen = true)
animatedScreens.add(0, peekingScreen!!)
}
peekingScreen?.let {
val peekingFraction = swipeState.offset.value / maxWidthPx
coroutineScope.launch {
currentScreen?.transition?.transitionAnimatable?.snapTo(peekingFraction)
}
coroutineScope.launch {
it.transition?.transitionAnimatable?.snapTo(peekingFraction)
}
}
} else {
currentScreen?.let { currentScreen ->
coroutineScope.launch {
currentScreen.transition?.stopPeeking()
currentScreen.transition = null
}
}
peekingScreen?.let { peekingScreen ->
coroutineScope.launch {
peekingScreen.transition?.stopPeeking()
peekingScreen.transition = null
animatedScreens.remove(peekingScreen)
}
}
peekingScreen = null
}
}
}
}
StateChangeEffect(swipeState.currentValue) {
println("swipstate value: ${swipeState.currentValue}")
if(swipeState.currentValue == DismissValue.DismissedToEnd) {
peekingScreen = null
if(navigator.pop().not()) {
navigator.parent?.pop()
}
}
}
val swipeModifier = Modifier.swipeable(
state = swipeState,
anchors = anchors,
thresholds = { _, _ -> FractionalThreshold(0.4f) },
orientation = Orientation.Horizontal,
enabled = swipeState.currentValue == DismissValue.Default,
reverseDirection = false,
resistance = ResistanceConfig(
basis = maxWidthPx,
factorAtMin = SwipeableDefaults.StiffResistanceFactor,
factorAtMax = SwipeableDefaults.StandardResistanceFactor,
),
)
animatedScreens.fastForEach { screen ->
key(screen.screen.key) {
navigator.saveableState("transition", screen.screen) {
Box(
modifier = Modifier
.fillMaxSize()
.then(if(screen == currentScreen && navigator.canPop) swipeModifier else Modifier)
.animatingModifier(screen)
) {
screen.screen.Content()
}
}
}
}
}
}
fun Modifier.animatingModifier(screenHolder: ScreenHolder) = screenHolder.run { this@animatingModifier.animatingModifier() }
class ScreenHolder(val screen: Screen) {
var transition by mutableStateOf<iOSNavigatorScreenTransition?>(null)
fun Modifier.animatingModifier(): Modifier = transition?.run { this@animatingModifier.animatingModifier() } ?: this
}
// Put this in the iosMain folder
package nl.kevinvanmierlo.testapp
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.tween
import androidx.compose.foundation.background
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
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.layout.layout
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.IntSize
import cafe.adriel.voyager.core.stack.StackEvent
abstract class iOSNavigatorScreenTransition {
var lastStackEvent by mutableStateOf(StackEvent.Idle)
var isAnimatingIn by mutableStateOf(false)
var isAnimatingAway by mutableStateOf(false)
var transitionAnimatable = Animatable(0f)
var easeFunc: EaseFunc = EasingProvider.get(Ease.SINE_IN_OUT)
fun startPeeking(isPrevScreen: Boolean) {
this.lastStackEvent = StackEvent.Pop
this.isAnimatingIn = isPrevScreen
this.isAnimatingAway = !isPrevScreen
}
suspend fun stopPeeking() {
val durationMillis = 250f * (1f - transitionAnimatable.value)
transitionAnimatable.animateTo(0f, tween(durationMillis.toInt(), easing = LinearEasing))
}
suspend fun startTransition(lastStackEvent: StackEvent, isAnimatingIn: Boolean = false, isAnimatingAway: Boolean = false) {
this.lastStackEvent = lastStackEvent
this.isAnimatingIn = isAnimatingIn
this.isAnimatingAway = isAnimatingAway
transitionAnimatable.animateTo(1f, tween(250, easing = LinearEasing))
}
abstract fun Modifier.animatingModifier(): Modifier
}
class iOSSlideTransition : iOSNavigatorScreenTransition() {
override fun Modifier.animatingModifier(): Modifier = composed {
var modifier = this
val isPop = lastStackEvent == StackEvent.Pop
val transitionFractionState by remember { transitionAnimatable.asState() }
val transitionFraction by remember { derivedStateOf { easeFunc(transitionFractionState) } }
if(isAnimatingAway) {
if(isPop) {
modifier = modifier
.slideFraction(transitionFraction)
} else {
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.slideFraction(-0.25f * transitionFraction)
.drawWithContent {
drawContent()
drawRect(
Color.Black,
alpha = transitionFraction * 0.25f,
)
}
}
} else if(isAnimatingIn) {
if(isPop) {
modifier = modifier
.background(MaterialTheme.colorScheme.background)
.slideFraction(-0.25f + (0.25f * transitionFraction))
.drawWithContent {
drawContent()
drawRect(
Color.Black,
alpha = 0.25f - (transitionFraction * 0.25f),
)
}
} else {
modifier = modifier
.slideFraction(1f - transitionFraction)
}
}
modifier
}
fun Modifier.slideFraction(fraction: Float): Modifier = this.layout { measurable, constraints ->
val placeable = measurable.measure(constraints)
val measuredSize = IntSize(placeable.width, placeable.height)
layout(placeable.width, placeable.height) {
val slideValue = (measuredSize.width.toFloat() * fraction).toInt()
placeable.placeWithLayer(IntOffset(x = slideValue, y = 0))
}
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment