diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/App.kt b/sharedUI/src/commonMain/kotlin/com/livrets/App.kt index 39fadc7..c1620bc 100644 --- a/sharedUI/src/commonMain/kotlin/com/livrets/App.kt +++ b/sharedUI/src/commonMain/kotlin/com/livrets/App.kt @@ -14,6 +14,7 @@ import com.livrets.ui.GameScreen import com.livrets.ui.HistoryScreen import com.livrets.ui.HomeScreen import com.livrets.ui.ResultsScreen +import com.livrets.ui.SwipeBack import kotlinx.coroutines.launch import okio.Path.Companion.toPath @@ -33,6 +34,10 @@ fun App( val history = remember { HistoryRepository("$filesDirPath/history.json".toPath()) } var screen by remember { mutableStateOf(Screen.Home) } + val homePreview: @Composable () -> Unit = { + HomeScreen(onPlay = {}, onHistory = {}) + } + when (val current = screen) { Screen.Home -> HomeScreen( onPlay = { config -> screen = Screen.Game(config) }, @@ -48,15 +53,25 @@ fun App( onQuit = { screen = Screen.Home }, ) - is Screen.Results -> ResultsScreen( - result = current.result, - onReplay = { screen = Screen.Game(current.result.config) }, - onHome = { screen = Screen.Home }, - ) - - Screen.History -> HistoryScreen( - history = history, + is Screen.Results -> SwipeBack( onBack = { screen = Screen.Home }, - ) + background = homePreview, + ) { + ResultsScreen( + result = current.result, + onReplay = { screen = Screen.Game(current.result.config) }, + onHome = { screen = Screen.Home }, + ) + } + + Screen.History -> SwipeBack( + onBack = { screen = Screen.Home }, + background = homePreview, + ) { + HistoryScreen( + history = history, + onBack = { screen = Screen.Home }, + ) + } } } diff --git a/sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt b/sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt index 8166a3a..8551319 100644 --- a/sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt +++ b/sharedUI/src/commonMain/kotlin/com/livrets/ui/SwipeBack.kt @@ -1,6 +1,30 @@ package com.livrets.ui +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.Composable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.positionChange +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.dp +import kotlin.math.abs +import kotlinx.coroutines.launch object SwipeBackGesture { val EdgeZone = 24.dp @@ -13,3 +37,109 @@ object SwipeBackGesture { velocityDpPerSec >= CommitVelocityDpPerSec } } + +@Composable +fun SwipeBack( + onBack: () -> Unit, + background: @Composable () -> Unit, + content: @Composable () -> Unit, +) { + val density = LocalDensity.current + val scope = rememberCoroutineScope() + val offset = remember { Animatable(0f) } + val edgeZonePx = with(density) { SwipeBackGesture.EdgeZone.toPx() } + var widthPx by remember { mutableFloatStateOf(0f) } + val showBackground by remember { derivedStateOf { offset.value > 0f } } + + Box( + Modifier + .fillMaxSize() + .onSizeChanged { widthPx = it.width.toFloat() }, + ) { + if (showBackground) { + Box(Modifier.fillMaxSize()) { background() } + } + Box( + Modifier + .fillMaxSize() + .graphicsLayer { translationX = offset.value } + .pointerInput(Unit) { + awaitEachGesture { + val down = awaitFirstDown() + if (down.position.x > edgeZonePx) return@awaitEachGesture + + val tracker = VelocityTracker() + tracker.addPosition(down.uptimeMillis, down.position) + var dragging = false + var accumulatedX = 0f + var accumulatedY = 0f + var dragged = 0f + + while (true) { + val event = awaitPointerEvent(PointerEventPass.Main) + val change = event.changes.firstOrNull { it.id == down.id } ?: break + + if (change.isConsumed && !change.pressed && change.previousPressed) { + if (dragging) { + scope.launch { offset.animateTo(0f, tween(200)) } + } + break + } + + if (!dragging && change.isConsumed) break + + val delta = change.positionChange() + accumulatedX += delta.x + accumulatedY += delta.y + + if (dragging) { + change.consume() + tracker.addPosition(change.uptimeMillis, change.position) + dragged = (dragged + delta.x).coerceAtLeast(0f) + scope.launch { offset.snapTo(dragged) } + } else { + val slop = viewConfiguration.touchSlop + if (abs(accumulatedX) > slop || abs(accumulatedY) > slop) { + if (abs(accumulatedX) > abs(accumulatedY)) { + dragging = true + change.consume() + tracker.resetTracking() + tracker.addPosition(change.uptimeMillis, change.position) + dragged = accumulatedX.coerceAtLeast(0f) + scope.launch { offset.snapTo(dragged) } + } else { + break + } + } + } + + if (change.changedToUpIgnoreConsumed()) { + if (dragging) { + val velocityDpPerSec = + tracker.calculateVelocity().x / density.density + val commit = SwipeBackGesture.shouldCommit( + offsetPx = dragged, + containerWidthPx = widthPx, + velocityDpPerSec = velocityDpPerSec, + ) + scope.launch { + if (commit) { + offset.snapTo(dragged) + offset.animateTo(widthPx, tween(200)) + onBack() + } else { + offset.snapTo(dragged) + offset.animateTo(0f, tween(200)) + } + } + } + break + } + } + } + }, + ) { + content() + } + } +}