Instantly share code, notes, and snippets.
-
Star
(2)
2
You must be signed in to star a gist -
Fork
(0)
0
You must be signed in to fork a gist
-
Save rewhex/ff9fecb4bdacbd10921f55b580539aa0 to your computer and use it in GitHub Desktop.
Voyager iOS Swipe back navigation; thanks to @kevinvanmierlo
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// commonMain | |
@Composable | |
fun App() { | |
MyApplicationTheme { | |
Navigator( | |
screen = InitialScreen(), | |
disposeBehavior = NavigatorDisposeBehavior(), | |
onBackPressed = { true }, | |
) { | |
PlatformNavigatorContent(navigator) | |
} | |
} | |
} | |
@Composable | |
expect fun PlatformNavigatorContent(navigator: Navigator) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// androidMain | |
@Composable | |
actual fun PlatformNavigatorContent(navigator: Navigator) { | |
FadeTransition(navigator) | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// iosMain | |
import androidx.compose.animation.core.SpringSpec | |
import androidx.compose.foundation.ExperimentalFoundationApi | |
import androidx.compose.foundation.gestures.AnchoredDraggableState | |
import androidx.compose.foundation.gestures.DraggableAnchors | |
import androidx.compose.foundation.gestures.Orientation | |
import androidx.compose.foundation.gestures.anchoredDraggable | |
import androidx.compose.foundation.layout.Box | |
import androidx.compose.foundation.layout.BoxWithConstraints | |
import androidx.compose.foundation.layout.fillMaxSize | |
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.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.platform.LocalLayoutDirection | |
import androidx.compose.ui.unit.LayoutDirection | |
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 | |
enum class DismissValue { | |
Default, | |
DismissedToEnd, | |
} | |
@OptIn(ExperimentalFoundationApi::class) | |
@Composable | |
actual fun PlatformNavigatorContent(navigator: Navigator) { | |
val density = LocalDensity.current | |
val coroutineScope = rememberCoroutineScope() | |
var currentScreen by remember { mutableStateOf<ScreenHolder?>(null) } | |
val animatedScreens = remember { mutableStateListOf<ScreenHolder>() } | |
var peekingScreen by remember { mutableStateOf<ScreenHolder?>(null) } | |
BoxWithConstraints(Modifier.fillMaxSize()) { | |
val maxWidthPx = constraints.maxWidth.toFloat() | |
val anchors by remember(maxWidthPx) { | |
derivedStateOf { | |
DraggableAnchors { | |
DismissValue.Default at 0f | |
DismissValue.DismissedToEnd at maxWidthPx | |
} | |
} | |
} | |
val anchoredDraggableState by remember { | |
derivedStateOf { | |
AnchoredDraggableState( | |
initialValue = DismissValue.Default, | |
anchors = anchors, | |
positionalThreshold = { distance -> distance * 0.4f }, | |
velocityThreshold = { with(density) { 125.dp.toPx() } }, | |
animationSpec = SpringSpec(), | |
) | |
} | |
} | |
LaunchedEffect(anchors) { | |
anchoredDraggableState.updateAnchors(anchors) | |
} | |
val lastEvent = navigator.lastEvent | |
val currentValue = anchoredDraggableState.currentValue | |
val offset = anchoredDraggableState.offset | |
LaunchedEffect(navigator.lastItemOrNull?.key) { | |
if (navigator.lastItemOrNull != null) { | |
// Remove all transitions when lastItem is changed | |
animatedScreens.forEach { it.transition = 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 (currentValue == DismissValue.Default) { | |
if (currentScreen.transition == null) { | |
currentScreen.transition = SlideTransition() | |
} | |
if (newScreen.transition == null) { | |
newScreen.transition = SlideTransition() | |
} | |
coroutineScope.launch { | |
newScreen.transition?.startTransition( | |
lastStackEvent = lastEvent, | |
isAnimatingIn = true, | |
) | |
newScreen.transition = null | |
} | |
coroutineScope.launch { | |
currentScreen.transition?.startTransition( | |
lastStackEvent = lastEvent, | |
isAnimatingAway = true, | |
) | |
animatedScreens.remove(currentScreen) | |
} | |
} else { | |
animatedScreens.remove(currentScreen) | |
} | |
} | |
currentScreen = newScreen | |
anchoredDraggableState.anchoredDrag { dragTo(0f) } | |
} | |
} | |
LaunchedEffect(offset) { | |
if (currentValue == DismissValue.Default) { | |
if (offset > 0f) { | |
if (currentScreen?.transition == null && navigator.size >= 2) { | |
currentScreen?.transition = SlideTransition() | |
currentScreen?.transition?.startPeeking(isPrevScreen = false) | |
peekingScreen = ScreenHolder(navigator.items[navigator.size - 2]) | |
peekingScreen?.let { peekingScreen -> | |
peekingScreen.transition = SlideTransition() | |
peekingScreen.transition?.startPeeking(isPrevScreen = true) | |
animatedScreens.add(0, peekingScreen) | |
} | |
} | |
peekingScreen?.let { peekingScreen -> | |
val peekingFraction = offset / maxWidthPx | |
coroutineScope.launch { | |
currentScreen?.transition?.transitionAnimatable?.snapTo(peekingFraction) | |
} | |
coroutineScope.launch { | |
peekingScreen.transition?.transitionAnimatable?.snapTo(peekingFraction) | |
} | |
} | |
} else { | |
peekingScreen?.let { peekingScreen -> | |
currentScreen?.let { currentScreen -> | |
coroutineScope.launch { | |
currentScreen.transition?.stopPeeking() | |
currentScreen.transition = null | |
} | |
} | |
coroutineScope.launch { | |
peekingScreen.transition?.stopPeeking() | |
peekingScreen.transition = null | |
animatedScreens.remove(peekingScreen) | |
} | |
} | |
peekingScreen = null | |
} | |
} | |
} | |
LaunchedEffect(currentValue) { | |
if (currentValue == DismissValue.DismissedToEnd) { | |
peekingScreen = null | |
if (navigator.canPop) { | |
navigator.pop() | |
} else { | |
navigator.parent?.pop() | |
} | |
} | |
} | |
val currentScreenModifier = Modifier.anchoredDraggable( | |
state = anchoredDraggableState, | |
orientation = Orientation.Horizontal, | |
enabled = navigator.canPop && currentValue == DismissValue.Default, | |
reverseDirection = LocalLayoutDirection.current == LayoutDirection.Rtl, | |
) | |
animatedScreens.fastForEach { screen -> | |
key(screen.screen.key) { | |
navigator.saveableState("transition", screen.screen) { | |
Box( | |
Modifier | |
.fillMaxSize() | |
.then(if (screen == currentScreen) currentScreenModifier else Modifier) | |
.animatingModifier(screen), | |
) { | |
screen.screen.Content() | |
} | |
} | |
} | |
} | |
} | |
} | |
private fun Modifier.animatingModifier(screenHolder: ScreenHolder) = | |
screenHolder.run { [email protected]() } | |
private class ScreenHolder(val screen: Screen) { | |
var transition by mutableStateOf<NavigatorScreenTransition?>(null) | |
fun Modifier.animatingModifier(): Modifier = transition?.run { [email protected]() } ?: this | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
// iosMain | |
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.layout.layout | |
import androidx.compose.ui.unit.IntOffset | |
import androidx.compose.ui.unit.IntSize | |
import cafe.adriel.voyager.core.stack.StackEvent | |
import kotlin.math.PI | |
import kotlin.math.cos | |
abstract class NavigatorScreenTransition { | |
var lastStackEvent by mutableStateOf(StackEvent.Idle) | |
var isAnimatingIn by mutableStateOf(false) | |
var isAnimatingAway by mutableStateOf(false) | |
var transitionAnimatable = Animatable(0f) | |
var easeFunc: (Float) -> Float = { (-0.5f * (cos(PI * it) - 1f)).toFloat() } | |
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 SlideTransition : NavigatorScreenTransition() { | |
override fun Modifier.animatingModifier(): Modifier = | |
composed { | |
var modifier = this | |
val isPop = lastStackEvent == StackEvent.Pop | |
val transitionFractionState by remember(transitionAnimatable.value) { | |
transitionAnimatable.asState() | |
} | |
val transitionFraction by remember(transitionFractionState) { | |
derivedStateOf { easeFunc(transitionFractionState) } | |
} | |
if (isAnimatingAway) { | |
modifier = if (isPop) { | |
modifier.slideFraction(transitionFraction) | |
} else { | |
modifier | |
.background(MaterialTheme.colorScheme.background) | |
.slideFraction(-0.25f * transitionFraction) | |
.drawWithContent { | |
drawContent() | |
drawRect(Color.Black, alpha = transitionFraction * 0.25f) | |
} | |
} | |
} else if (isAnimatingIn) { | |
modifier = if (isPop) { | |
modifier | |
.background(MaterialTheme.colorScheme.background) | |
.slideFraction(-0.25f + (0.25f * transitionFraction)) | |
.drawWithContent { | |
drawContent() | |
drawRect(Color.Black, alpha = 0.25f - (transitionFraction * 0.25f)) | |
} | |
} else { | |
modifier.slideFraction(1f - transitionFraction) | |
} | |
} | |
modifier | |
} | |
private 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