diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index c3ce971e6..e8d871370 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -282,7 +282,10 @@ private fun MangaScreenSmallImpl( onBackClicked() } } - BackHandler(onBack = internalOnBackPressed) + BackHandler( + enabled = isAnySelected, + onBack = { onAllChapterSelected(false) }, + ) Scaffold( topBar = { @@ -540,7 +543,10 @@ fun MangaScreenLargeImpl( onBackClicked() } } - BackHandler(onBack = internalOnBackPressed) + BackHandler( + enabled = isAnySelected, + onBack = { onAllChapterSelected(false) }, + ) Scaffold( topBar = { diff --git a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt index b3c06a979..3b5d7e558 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt @@ -3,6 +3,10 @@ package eu.kanade.presentation.manga.components import android.graphics.Bitmap import android.graphics.drawable.BitmapDrawable import android.os.Build +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row @@ -25,15 +29,18 @@ import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf 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.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.unit.DpOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.window.Dialog import androidx.compose.ui.window.DialogProperties @@ -48,11 +55,13 @@ import eu.kanade.presentation.components.DropdownMenu import eu.kanade.presentation.manga.EditCoverAction import eu.kanade.tachiyomi.ui.reader.viewer.ReaderPageImageView import kotlinx.collections.immutable.persistentListOf +import soup.compose.material.motion.MotionConstants import tachiyomi.domain.manga.model.Manga import tachiyomi.i18n.MR import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.stringResource import tachiyomi.presentation.core.util.clickableNoIndication +import kotlin.coroutines.cancellation.CancellationException @Composable fun MangaCoverDialog( @@ -151,10 +160,32 @@ fun MangaCoverDialog( val statusBarPaddingPx = with(LocalDensity.current) { contentPadding.calculateTopPadding().roundToPx() } val bottomPaddingPx = with(LocalDensity.current) { contentPadding.calculateBottomPadding().roundToPx() } + var scale by remember { mutableFloatStateOf(1f) } + PredictiveBackHandler { progress -> + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.8f, LinearOutSlowInEasing.transform(backEvent.progress)) + } + onDismissRequest() + } catch (e: CancellationException) { + animate( + initialValue = scale, + targetValue = 1f, + animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), + ) { value, _ -> + scale = value + } + } + } + Box( modifier = Modifier .fillMaxSize() - .clickableNoIndication(onClick = onDismissRequest), + .clickableNoIndication(onClick = onDismissRequest) + .graphicsLayer { + scaleX = scale + scaleY = scale + }, ) { AndroidView( factory = { diff --git a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt index 86311d8e0..8083bc0af 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,13 +1,54 @@ package eu.kanade.presentation.util import android.annotation.SuppressLint +import androidx.activity.BackEventCompat +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContentTransitionScope import androidx.compose.animation.ContentTransform +import androidx.compose.animation.EnterTransition +import androidx.compose.animation.ExitTransition +import androidx.compose.animation.core.FastOutSlowInEasing +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween +import androidx.compose.animation.togetherWith +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.movableContentOf +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.setValue import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Rect +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.BlurEffect +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.ColorMatrix +import androidx.compose.ui.graphics.Paint +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.drawscope.drawIntoCanvas +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.layout.onSizeChanged +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.toSize +import androidx.compose.ui.util.lerp +import androidx.compose.ui.zIndex import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.screen.Screen @@ -16,14 +57,25 @@ import cafe.adriel.voyager.core.screen.uniqueScreenKey import cafe.adriel.voyager.core.stack.StackEvent import cafe.adriel.voyager.navigator.Navigator import cafe.adriel.voyager.transitions.ScreenTransitionContent +import eu.kanade.tachiyomi.util.view.getWindowRadius import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.cancel +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.launch import kotlinx.coroutines.plus +import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialSharedAxisX import soup.compose.material.motion.animation.rememberSlideDistance +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.PI +import kotlin.math.sin /** * For invoking back press to the parent activity @@ -57,17 +109,299 @@ interface AssistContentScreen { } @Composable -fun DefaultNavigatorScreenTransition(navigator: Navigator) { - val slideDistance = rememberSlideDistance() - ScreenTransition( - navigator = navigator, - transition = { - materialSharedAxisX( - forward = navigator.lastEvent != StackEvent.Pop, - slideDistance = slideDistance, +fun DefaultNavigatorScreenTransition( + navigator: Navigator, + modifier: Modifier = Modifier, +) { + val scope = rememberCoroutineScope() + val view = LocalView.current + val handler = remember { + OnBackHandler( + scope = scope, + windowCornerRadius = view.getWindowRadius(), + onBackPressed = navigator::pop, + ) + } + PredictiveBackHandler(enabled = navigator.canPop) { progress -> + progress + .onStart { handler.reset() } + .onCompletion { e -> + if (e == null) { + handler.onBackConfirmed() + } else { + handler.onBackCancelled() + } + } + .collect(handler::onBackEvent) + } + + Box(modifier = modifier.onSizeChanged { handler.updateContainerSize(it.toSize()) }) { + val currentSceneEntry = navigator.lastItem + val showPrev by remember { + derivedStateOf { handler.scale < 1f || handler.translationY != 0f } + } + val visibleItems = remember(currentSceneEntry, showPrev) { + if (showPrev) { + val prevSceneEntry = navigator.items.getOrNull(navigator.size - 2) + listOfNotNull(currentSceneEntry, prevSceneEntry) + } else { + listOfNotNull(currentSceneEntry) + } + } + + val slideDistance = rememberSlideDistance() + + val screenContent = remember { + movableContentOf { screen -> + navigator.saveableState("transition", screen) { + screen.Content() + } + } + } + + visibleItems.forEachIndexed { index, backStackEntry -> + val isPrev = index == 1 && visibleItems.size > 1 + if (!isPrev) { + AnimatedContent( + targetState = backStackEntry, + transitionSpec = { + val forward = navigator.lastEvent != StackEvent.Pop + if (!forward && !handler.isReady) { + // Pop screen without animation when predictive back is in use + EnterTransition.None togetherWith ExitTransition.None + } else { + materialSharedAxisX( + forward = forward, + slideDistance = slideDistance, + ) + } + }, + modifier = Modifier + .zIndex(1f) + .graphicsLayer { + this.alpha = handler.alpha + this.transformOrigin = TransformOrigin( + pivotFractionX = if (handler.swipeEdge == BackEventCompat.EDGE_LEFT) 0.8f else 0.2f, + pivotFractionY = 0.5f, + ) + this.scaleX = handler.scale + this.scaleY = handler.scale + this.translationY = handler.translationY + this.clip = true + this.shape = if (showPrev) { + RoundedCornerShape(handler.windowCornerRadius.toFloat()) + } else { + RectangleShape + } + } + .then( + if (showPrev) { + Modifier.pointerInput(Unit) { + // Animated content should not be interactive + } + } else { + Modifier + }, + ), + content = { + if (visibleItems.size == 2 && visibleItems.getOrNull(1) == it) { + // Avoid drawing previous screen + return@AnimatedContent + } + screenContent(it) + }, + ) + } else { + Box( + modifier = Modifier + .zIndex(0f) + .drawWithCache { + val bounds = Rect(Offset.Zero, size) + val matrix = ColorMatrix().apply { + // Reduce saturation and brightness + setToSaturation(lerp(1f, 0.95f, handler.alpha)) + set(0, 4, lerp(0f, -25f, handler.alpha)) + set(1, 4, lerp(0f, -25f, handler.alpha)) + set(2, 4, lerp(0f, -25f, handler.alpha)) + } + val paint = Paint().apply { colorFilter = ColorFilter.colorMatrix(matrix) } + onDrawWithContent { + drawIntoCanvas { + it.saveLayer(bounds, paint) + drawContent() + it.restore() + } + } + } + .graphicsLayer { + val blurRadius = 5.dp.toPx() * handler.alpha + renderEffect = if (blurRadius > 0f) { + BlurEffect(blurRadius, blurRadius) + } else { + null + } + } + .pointerInput(Unit) { + // bg content should not be interactive + }, + content = { screenContent(backStackEntry) }, + ) + } + } + + LaunchedEffect(currentSceneEntry) { + // Reset *after* the screen is popped successfully + // so that the correct transition is applied + handler.setReady() + } + } +} + +@Stable +private class OnBackHandler( + private val scope: CoroutineScope, + val windowCornerRadius: Int, + private val onBackPressed: () -> Unit, +) { + + var isReady = true + private set + + var alpha by mutableFloatStateOf(1f) + private set + + var scale by mutableFloatStateOf(1f) + private set + + var translationY by mutableFloatStateOf(0f) + private set + + var swipeEdge by mutableIntStateOf(BackEventCompat.EDGE_LEFT) + private set + + private var containerSize = Size.Zero + private var startPointY = Float.NaN + + var isPredictiveBack by mutableStateOf(false) + private set + + private var animationJob: Job? = null + set(value) { + isReady = false + field = value + } + + fun updateContainerSize(size: Size) { + containerSize = size + } + + fun setReady() { + reset() + animationJob?.cancel() + animationJob = null + isReady = true + isPredictiveBack = false + } + + fun reset() { + startPointY = Float.NaN + } + + fun onBackEvent(backEvent: BackEventCompat) { + if (!isReady) return + isPredictiveBack = true + swipeEdge = backEvent.swipeEdge + + val progress = LinearOutSlowInEasing.transform(backEvent.progress) + scale = lerp(1f, 0.85f, progress) + + if (startPointY.isNaN()) { + startPointY = backEvent.touchY + } + val deltaYRatio = (backEvent.touchY - startPointY) / containerSize.height + val translateYDistance = containerSize.height / 20 + translationY = sin(deltaYRatio * PI * 0.5).toFloat() * translateYDistance * progress + } + + fun onBackConfirmed() { + if (!isReady) return + if (isPredictiveBack) { + // Continue predictive animation and pop the screen + val animationSpec = tween( + durationMillis = MotionConstants.DefaultMotionDuration, + easing = FastOutSlowInEasing, ) - }, - ) + animationJob = scope.launch { + try { + listOf( + async { + animate( + initialValue = alpha, + targetValue = 0f, + animationSpec = animationSpec, + ) { value, _ -> + alpha = value + } + }, + async { + animate( + initialValue = scale, + targetValue = scale - 0.05f, + animationSpec = animationSpec, + ) { value, _ -> + scale = value + } + }, + ).awaitAll() + } catch (e: CancellationException) { + // no-op + } finally { + onBackPressed() + alpha = 1f + translationY = 0f + scale = 1f + } + } + } else { + // Pop right away and use default transition + onBackPressed() + } + } + + fun onBackCancelled() { + // Reset states + isPredictiveBack = false + animationJob = scope.launch { + listOf( + async { + animate( + initialValue = scale, + targetValue = 1f, + ) { value, _ -> + scale = value + } + }, + async { + animate( + initialValue = alpha, + targetValue = 1f, + ) { value, _ -> + alpha = value + } + }, + async { + animate( + initialValue = translationY, + targetValue = 0f, + ) { value, _ -> + translationY = value + } + }, + ).awaitAll() + + isReady = true + } + } } @Composable diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt index ff2cb7075..7e61985d5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt @@ -1,8 +1,11 @@ package eu.kanade.tachiyomi.ui.home -import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.tween import androidx.compose.animation.expandVertically import androidx.compose.animation.shrinkVertically import androidx.compose.animation.togetherWith @@ -23,13 +26,20 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.produceState +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.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.semantics.contentDescription import androidx.compose.ui.semantics.semantics import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.lerp import cafe.adriel.voyager.navigator.LocalNavigator import cafe.adriel.voyager.navigator.currentOrThrow import cafe.adriel.voyager.navigator.tab.LocalTabNavigator @@ -49,6 +59,7 @@ import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.launch +import soup.compose.material.motion.MotionConstants import soup.compose.material.motion.animation.materialFadeThroughIn import soup.compose.material.motion.animation.materialFadeThroughOut import tachiyomi.domain.library.service.LibraryPreferences @@ -59,6 +70,7 @@ import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.pluralStringResource import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.coroutines.cancellation.CancellationException object HomeScreen : Screen() { @@ -80,6 +92,8 @@ object HomeScreen : Screen() { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow + var scale by remember { mutableFloatStateOf(1f) } + TabNavigator( tab = LibraryTab, key = TabNavigatorKey, @@ -118,6 +132,11 @@ object HomeScreen : Screen() { ) { contentPadding -> Box( modifier = Modifier + .graphicsLayer { + scaleX = scale + scaleY = scale + transformOrigin = TransformOrigin(0.5f, 1f) + } .padding(contentPadding) .consumeWindowInsets(contentPadding), ) { @@ -138,10 +157,30 @@ object HomeScreen : Screen() { } val goToLibraryTab = { tabNavigator.current = LibraryTab } - BackHandler( - enabled = tabNavigator.current != LibraryTab, - onBack = goToLibraryTab, - ) + + var handlingBack by remember { mutableStateOf(false) } + PredictiveBackHandler(enabled = handlingBack || tabNavigator.current != LibraryTab) { progress -> + handlingBack = true + val currentTab = tabNavigator.current + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.92f, LinearOutSlowInEasing.transform(backEvent.progress)) + tabNavigator.current = if (backEvent.progress > 0.25f) tabs[0] else currentTab + } + goToLibraryTab() + } catch (e: CancellationException) { + tabNavigator.current = currentTab + } finally { + animate( + initialValue = scale, + targetValue = 1f, + animationSpec = tween(durationMillis = MotionConstants.DefaultMotionDuration), + ) { value, _ -> + scale = value + } + handlingBack = false + } + } LaunchedEffect(Unit) { launch { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt index 78c688c2e..7184388a8 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/main/MainActivity.kt @@ -11,7 +11,6 @@ import android.os.Bundle import android.view.View import androidx.activity.ComponentActivity import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.consumeWindowInsets @@ -223,14 +222,13 @@ class MainActivity : BaseActivity() { contentWindowInsets = scaffoldInsets, ) { contentPadding -> // Consume insets already used by app state banners - Box( + // Shows current screen + DefaultNavigatorScreenTransition( + navigator = navigator, modifier = Modifier .padding(contentPadding) .consumeWindowInsets(contentPadding), - ) { - // Shows current screen - DefaultNavigatorScreenTransition(navigator = navigator) - } + ) } // Pop source-related screens when incognito mode is turned off diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt index 3d396ace9..3ba5f6dec 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsScreen.kt @@ -40,6 +40,7 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsMainScreen }, + onBackPressed = null, content = { val pop: () -> Unit = { if (it.canPop) { @@ -61,6 +62,7 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsAppearanceScreen }, + onBackPressed = null, ) { val insets = WindowInsets.systemBars.only(WindowInsetsSides.Horizontal) TwoPanelBox( diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt index 60d357a53..7e8651111 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/view/ViewExtensions.kt @@ -4,9 +4,11 @@ package eu.kanade.tachiyomi.util.view import android.content.res.Resources import android.graphics.Rect +import android.os.Build import android.view.Gravity import android.view.Menu import android.view.MenuItem +import android.view.RoundedCorner import android.view.View import androidx.activity.ComponentActivity import androidx.activity.compose.setContent @@ -95,3 +97,22 @@ fun View?.isVisibleOnScreen(): Boolean { Rect(0, 0, Resources.getSystem().displayMetrics.widthPixels, Resources.getSystem().displayMetrics.heightPixels) return actualPosition.intersect(screen) } + +/** + * Returns window radius (in pixel) applied to this view + */ +fun View.getWindowRadius(): Int { + val rad = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { + val windowInsets = rootWindowInsets + listOfNotNull( + windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_LEFT), + windowInsets.getRoundedCorner(RoundedCorner.POSITION_TOP_RIGHT), + windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_LEFT), + windowInsets.getRoundedCorner(RoundedCorner.POSITION_BOTTOM_RIGHT), + ) + .minOfOrNull { it.radius } + } else { + null + } + return rad ?: 0 +} diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt index d36e2593f..7e770fb43 100644 --- a/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/components/AdaptiveSheet.kt @@ -1,7 +1,11 @@ package tachiyomi.presentation.core.components -import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +import androidx.compose.animation.core.LinearOutSlowInEasing +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.spring import androidx.compose.animation.core.tween import androidx.compose.foundation.clickable import androidx.compose.foundation.gestures.AnchoredDraggableState @@ -26,6 +30,7 @@ import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableFloatStateOf import androidx.compose.runtime.remember @@ -34,8 +39,11 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.composed import androidx.compose.ui.draw.alpha import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.TransformOrigin +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll @@ -45,14 +53,14 @@ import androidx.compose.ui.unit.Dp import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp import kotlinx.coroutines.flow.collectLatest import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.launch +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt -private val sheetAnimationSpec = tween(durationMillis = 350) - @Composable fun AdaptiveSheet( isTabletUi: Boolean, @@ -91,6 +99,11 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier + .predictiveBackAnimation( + enabled = remember { derivedStateOf { alpha > 0f } }.value, + transformOrigin = TransformOrigin.Center, + onBack = internalOnDismissRequest, + ) .requiredWidthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -103,7 +116,6 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { - BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest) content() }, ) @@ -145,6 +157,11 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier + .predictiveBackAnimation( + enabled = anchoredDraggableState.targetValue == 0, + transformOrigin = TransformOrigin(0.5f, 1f), + onBack = internalOnDismissRequest, + ) .widthIn(max = 460.dp) .clickable( interactionSource = remember { MutableInteractionSource() }, @@ -184,10 +201,6 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, tonalElevation = tonalElevation, content = { - BackHandler( - enabled = anchoredDraggableState.targetValue == 0, - onBack = internalOnDismissRequest, - ) content() }, ) @@ -257,3 +270,37 @@ private fun AnchoredDraggableState.preUpPostDownNestedScrollConnection() @JvmName("offsetToFloat") private fun Offset.toFloat(): Float = this.y } + +private fun Modifier.predictiveBackAnimation( + enabled: Boolean, + transformOrigin: TransformOrigin, + onBack: () -> Unit, +) = composed { + var scale by remember { mutableFloatStateOf(1f) } + PredictiveBackHandler(enabled = enabled) { progress -> + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.85f, LinearOutSlowInEasing.transform(backEvent.progress)) + } + // Completion + onBack() + } catch (e: CancellationException) { + // Cancellation + } finally { + animate( + initialValue = scale, + targetValue = 1f, + animationSpec = spring(stiffness = Spring.StiffnessLow), + ) { value, _ -> + scale = value + } + } + } + Modifier.graphicsLayer { + this.scaleX = scale + this.scaleY = scale + this.transformOrigin = transformOrigin + } +} + +private val sheetAnimationSpec = tween(durationMillis = 350)