diff --git a/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt b/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt index 15d05a6ec..0b04913d4 100644 --- a/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt +++ b/app/src/main/java/eu/kanade/presentation/components/AdaptiveSheet.kt @@ -1,10 +1,9 @@ package eu.kanade.presentation.components -import androidx.activity.compose.BackHandler +import androidx.compose.animation.SizeTransform import androidx.compose.animation.core.tween import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.animation.togetherWith import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.window.Dialog @@ -28,20 +27,14 @@ fun NavigatorAdaptiveSheet( screen = screen, content = { sheetNavigator -> AdaptiveSheet( - enableSwipeDismiss = enableSwipeDismiss(sheetNavigator), onDismissRequest = onDismissRequest, + enableSwipeDismiss = enableSwipeDismiss(sheetNavigator), ) { ScreenTransition( navigator = sheetNavigator, - transition = { - fadeIn(animationSpec = tween(220, delayMillis = 90)) togetherWith - fadeOut(animationSpec = tween(90)) - }, - ) - - BackHandler( - enabled = sheetNavigator.size > 1, - onBack = sheetNavigator::pop, + enterTransition = { fadeIn(animationSpec = tween(220, delayMillis = 90)) }, + exitTransition = { fadeOut(animationSpec = tween(90)) }, + sizeTransform = { SizeTransform() }, ) } @@ -79,10 +72,10 @@ fun AdaptiveSheet( properties = dialogProperties, ) { AdaptiveSheetImpl( - modifier = modifier, isTabletUi = isTabletUi, enableSwipeDismiss = enableSwipeDismiss, onDismissRequest = onDismissRequest, + modifier = modifier, ) { content() } 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 981e3a57e..89da97806 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -349,13 +349,9 @@ private fun MangaScreenSmallImpl( } // SY <-- - BackHandler(onBack = { - if (isAnySelected) { - onAllChapterSelected(false) - } else { - navigateUp() - } - }) + BackHandler(enabled = isAnySelected) { + onAllChapterSelected(false) + } Scaffold( topBar = { @@ -680,13 +676,9 @@ fun MangaScreenLargeImpl( val chapterListState = rememberLazyListState() - BackHandler(onBack = { - if (isAnySelected) { - onAllChapterSelected(false) - } else { - navigateUp() - } - }) + BackHandler(enabled = isAnySelected) { + 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 5196e11a9..3987dfcd5 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,9 @@ 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.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,18 +28,22 @@ 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 +import androidx.core.graphics.drawable.toDrawable import androidx.core.view.updatePadding import coil3.asDrawable import coil3.imageLoader @@ -49,11 +56,14 @@ 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.PredictiveBack import tachiyomi.presentation.core.util.clickableNoIndication +import kotlin.coroutines.cancellation.CancellationException @Composable fun MangaCoverDialog( @@ -152,10 +162,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, PredictiveBack.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 = { @@ -172,20 +204,20 @@ fun MangaCoverDialog( .memoryCachePolicy(CachePolicy.DISABLED) .target { image -> val drawable = image.asDrawable(view.context.resources) - // Copy bitmap in case it came from memory cache // Because SSIV needs to thoroughly read the image - val copy = (drawable as? BitmapDrawable)?.let { - val config = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { - Bitmap.Config.HARDWARE - } else { - Bitmap.Config.ARGB_8888 - } - BitmapDrawable( - view.context.resources, - it.bitmap.copy(config, false), + val copy = (drawable as? BitmapDrawable) + ?.bitmap + ?.copy( + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { + Bitmap.Config.HARDWARE + } else { + Bitmap.Config.ARGB_8888 + }, + false, ) - } ?: drawable + ?.toDrawable(view.context.resources) + ?: drawable view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500)) } .build() diff --git a/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt index c5fd8c2fa..2888b5935 100644 --- a/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/onboarding/OnboardingScreen.kt @@ -42,7 +42,9 @@ fun OnboardingScreen( } val isLastStep = currentStep == steps.lastIndex - BackHandler(enabled = currentStep != 0, onBack = { currentStep-- }) + BackHandler(enabled = currentStep != 0) { + currentStep-- + } InfoScreen( icon = Icons.Outlined.RocketLaunch, diff --git a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt index d966437a1..e72d229bd 100644 --- a/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/updates/UpdatesScreen.kt @@ -60,7 +60,9 @@ fun UpdateScreen( onUpdateSelected: (UpdatesItem, Boolean, Boolean, Boolean) -> Unit, onOpenChapter: (UpdatesItem) -> Unit, ) { - BackHandler(enabled = state.selectionMode, onBack = { onSelectAll(false) }) + BackHandler(enabled = state.selectionMode) { + onSelectAll(false) + } Scaffold( topBar = { scrollBehavior -> 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 3db4bd084..745e005b5 100644 --- a/app/src/main/java/eu/kanade/presentation/util/Navigator.kt +++ b/app/src/main/java/eu/kanade/presentation/util/Navigator.kt @@ -1,12 +1,46 @@ 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.SizeTransform +import androidx.compose.animation.core.AnimationSpec +import androidx.compose.animation.core.SeekableTransitionState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.rememberTransition +import androidx.compose.animation.core.spring +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.MutableState import androidx.compose.runtime.ProvidableCompositionLocal +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableFloatStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.platform.LocalViewConfiguration +import androidx.compose.ui.unit.dp +import androidx.compose.ui.util.lerp +import cafe.adriel.voyager.core.annotation.InternalVoyagerApi import cafe.adriel.voyager.core.model.ScreenModel import cafe.adriel.voyager.core.model.ScreenModelStore import cafe.adriel.voyager.core.screen.Screen @@ -15,18 +49,28 @@ 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.cancel +import kotlinx.coroutines.flow.dropWhile +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch import kotlinx.coroutines.plus -import soup.compose.material.motion.animation.materialSharedAxisX +import soup.compose.material.motion.animation.materialSharedAxisXIn +import soup.compose.material.motion.animation.materialSharedAxisXOut import soup.compose.material.motion.animation.rememberSlideDistance +import tachiyomi.presentation.core.util.PredictiveBack +import kotlin.coroutines.cancellation.CancellationException +import kotlin.math.absoluteValue /** * For invoking back press to the parent activity */ +@SuppressLint("ComposeCompositionLocalUsage") val LocalBackPress: ProvidableCompositionLocal<(() -> Unit)?> = staticCompositionLocalOf { null } interface Tab : cafe.adriel.voyager.navigator.tab.Tab { @@ -59,39 +103,278 @@ interface AssistContentScreen { fun onProvideAssistUrl(): String? } +@OptIn(InternalVoyagerApi::class) @Composable fun DefaultNavigatorScreenTransition( navigator: Navigator, modifier: Modifier = Modifier, ) { - val slideDistance = rememberSlideDistance() + val screenCandidatesToDispose = rememberSaveable(saver = screenCandidatesToDisposeSaver()) { + mutableStateOf(emptySet()) + } + val currentScreens = navigator.items + DisposableEffect(currentScreens) { + onDispose { + val newScreenKeys = navigator.items.map { it.key } + screenCandidatesToDispose.value += currentScreens.filter { it.key !in newScreenKeys } + } + } + + val slideDistance = rememberSlideDistance(slideDistance = 30.dp) ScreenTransition( navigator = navigator, - transition = { - materialSharedAxisX( - forward = navigator.lastEvent != StackEvent.Pop, - slideDistance = slideDistance, - ) - }, modifier = modifier, + enterTransition = { + if (it == SwipeEdge.Right) { + materialSharedAxisXIn(forward = false, slideDistance = slideDistance) + } else { + materialSharedAxisXIn(forward = true, slideDistance = slideDistance) + } + }, + exitTransition = { + if (it == SwipeEdge.Right) { + materialSharedAxisXOut(forward = false, slideDistance = slideDistance) + } else { + materialSharedAxisXOut(forward = true, slideDistance = slideDistance) + } + }, + popEnterTransition = { + if (it == SwipeEdge.Right) { + materialSharedAxisXIn(forward = true, slideDistance = slideDistance) + } else { + materialSharedAxisXIn(forward = false, slideDistance = slideDistance) + } + }, + popExitTransition = { + if (it == SwipeEdge.Right) { + materialSharedAxisXOut(forward = true, slideDistance = slideDistance) + } else { + materialSharedAxisXOut(forward = false, slideDistance = slideDistance) + } + }, + content = { screen -> + if (this.transition.targetState == this.transition.currentState) { + LaunchedEffect(Unit) { + val newScreens = navigator.items.map { it.key } + val screensToDispose = screenCandidatesToDispose.value.filterNot { it.key in newScreens } + if (screensToDispose.isNotEmpty()) { + screensToDispose.forEach { navigator.dispose(it) } + navigator.clearEvent() + } + screenCandidatesToDispose.value = emptySet() + } + } + screen.Content() + }, ) } +enum class SwipeEdge { + Unknown, + Left, + Right, +} + +private enum class AnimationType { + Pop, + Cancel, +} + @Composable fun ScreenTransition( navigator: Navigator, - transition: AnimatedContentTransitionScope.() -> ContentTransform, modifier: Modifier = Modifier, + enterTransition: AnimatedContentTransitionScope.(SwipeEdge) -> EnterTransition = { fadeIn() }, + exitTransition: AnimatedContentTransitionScope.(SwipeEdge) -> ExitTransition = { fadeOut() }, + popEnterTransition: AnimatedContentTransitionScope.(SwipeEdge) -> EnterTransition = enterTransition, + popExitTransition: AnimatedContentTransitionScope.(SwipeEdge) -> ExitTransition = exitTransition, + sizeTransform: (AnimatedContentTransitionScope.() -> SizeTransform?)? = null, + flingAnimationSpec: () -> AnimationSpec = { spring(stiffness = Spring.StiffnessLow) }, content: ScreenTransitionContent = { it.Content() }, ) { - AnimatedContent( - targetState = navigator.lastItem, - transitionSpec = transition, + val view = LocalView.current + val viewConfig = LocalViewConfiguration.current + val scope = rememberCoroutineScope() + val state = remember { + ScreenTransitionState( + navigator = navigator, + scope = scope, + flingAnimationSpec = flingAnimationSpec(), + windowCornerRadius = view.getWindowRadius().toFloat(), + ) + } + val transitionState = remember { SeekableTransitionState(navigator.lastItem) } + val transition = rememberTransition(transitionState = transitionState) + + if (state.isPredictiveBack || state.isAnimating) { + LaunchedEffect(state.progress) { + if (!state.isPredictiveBack) return@LaunchedEffect + val previousEntry = navigator.items.getOrNull(navigator.size - 2) + if (previousEntry != null) { + transitionState.seekTo(fraction = state.progress, targetState = previousEntry) + } + } + } else { + LaunchedEffect(navigator) { + snapshotFlow { navigator.lastItem } + .collect { + state.cancelCancelAnimation() + if (it != transitionState.currentState) { + transitionState.animateTo(it) + } else { + transitionState.snapTo(it) + } + } + } + } + + PredictiveBackHandler(enabled = navigator.canPop) { backEvent -> + state.cancelCancelAnimation() + var startOffset: Offset? = null + backEvent + .dropWhile { + if (startOffset == null) startOffset = Offset(it.touchX, it.touchY) + if (state.isAnimating) return@dropWhile true + // Touch slop check + val diff = Offset(it.touchX, it.touchY) - startOffset!! + diff.x.absoluteValue < viewConfig.touchSlop && diff.y.absoluteValue < viewConfig.touchSlop + } + .onCompletion { + if (it == null) { + state.finish() + } else { + state.cancel() + } + } + .collect { + state.setPredictiveBackProgress( + progress = it.progress, + swipeEdge = when (it.swipeEdge) { + BackEventCompat.EDGE_LEFT -> SwipeEdge.Left + BackEventCompat.EDGE_RIGHT -> SwipeEdge.Right + else -> SwipeEdge.Unknown + }, + ) + } + } + + transition.AnimatedContent( modifier = modifier, - label = "transition", - ) { screen -> - navigator.saveableState("transition", screen) { - content(screen) + transitionSpec = { + val pop = navigator.lastEvent == StackEvent.Pop || state.isPredictiveBack + ContentTransform( + targetContentEnter = if (pop) { + popEnterTransition(state.swipeEdge) + } else { + enterTransition(state.swipeEdge) + }, + initialContentExit = if (pop) { + popExitTransition(state.swipeEdge) + } else { + exitTransition(state.swipeEdge) + }, + targetContentZIndex = if (pop) 0f else 1f, + sizeTransform = sizeTransform?.invoke(this), + ) + }, + contentKey = { it.key }, + ) { + navigator.saveableState("transition", it) { + content(it) } } } + +@Stable +private class ScreenTransitionState( + private val navigator: Navigator, + private val scope: CoroutineScope, + private val flingAnimationSpec: AnimationSpec, + windowCornerRadius: Float, +) { + var isPredictiveBack: Boolean by mutableStateOf(false) + private set + + var progress: Float by mutableFloatStateOf(0f) + private set + + var swipeEdge: SwipeEdge by mutableStateOf(SwipeEdge.Unknown) + private set + + private var animationJob: Pair? by mutableStateOf(null) + + val isAnimating: Boolean + get() = animationJob?.first?.isActive == true + + val windowCornerShape = RoundedCornerShape(windowCornerRadius) + + private fun reset() { + this.isPredictiveBack = false + this.swipeEdge = SwipeEdge.Unknown + this.animationJob = null + } + + fun setPredictiveBackProgress(progress: Float, swipeEdge: SwipeEdge) { + this.progress = lerp(0f, 0.65f, PredictiveBack.transform(progress)) + this.swipeEdge = swipeEdge + this.isPredictiveBack = true + } + + fun finish() { + if (!isPredictiveBack) { + navigator.pop() + return + } + animationJob = scope.launch { + try { + animate( + initialValue = progress, + targetValue = 1f, + animationSpec = flingAnimationSpec, + block = { i, _ -> progress = i }, + ) + navigator.pop() + } catch (e: CancellationException) { + // Cancelled + progress = 0f + } finally { + reset() + } + } to AnimationType.Pop + } + + fun cancel() { + if (!isPredictiveBack) { + return + } + animationJob = scope.launch { + try { + animate( + initialValue = progress, + targetValue = 0f, + animationSpec = flingAnimationSpec, + block = { i, _ -> progress = i }, + ) + } catch (e: CancellationException) { + // Cancelled + progress = 1f + } finally { + reset() + } + } to AnimationType.Cancel + } + + fun cancelCancelAnimation() { + if (animationJob?.second == AnimationType.Cancel) { + animationJob?.first?.cancel() + animationJob = null + } + } +} + +private fun screenCandidatesToDisposeSaver(): Saver>, List> { + return Saver( + save = { it.value.toList() }, + restore = { mutableStateOf(it.toSet()) }, + ) +} 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 44fb6fc95..5c2686573 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,20 +1,21 @@ package eu.kanade.tachiyomi.ui.home -import androidx.activity.compose.BackHandler +import android.annotation.SuppressLint +import androidx.activity.compose.PredictiveBackHandler import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedVisibility +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 import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.WindowInsets -import androidx.compose.foundation.layout.consumeWindowInsets -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.Badge import androidx.compose.material3.BadgedBox import androidx.compose.material3.Icon -import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarItem import androidx.compose.material3.NavigationRailItem @@ -23,15 +24,24 @@ 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.runtime.staticCompositionLocalOf import androidx.compose.ui.Modifier +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.unit.Density +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastFilter 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 @@ -53,6 +63,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 @@ -61,8 +72,10 @@ import tachiyomi.presentation.core.components.material.NavigationBar import tachiyomi.presentation.core.components.material.NavigationRail import tachiyomi.presentation.core.components.material.Scaffold import tachiyomi.presentation.core.i18n.pluralStringResource +import tachiyomi.presentation.core.util.PredictiveBack import uy.kohesive.injekt.Injekt import uy.kohesive.injekt.api.get +import kotlin.coroutines.cancellation.CancellationException object HomeScreen : Screen() { @@ -70,8 +83,14 @@ object HomeScreen : Screen() { private val openTabEvent = Channel() private val showBottomNavEvent = Channel() - private const val TAB_FADE_DURATION = 200 - private const val TAB_NAVIGATOR_KEY = "HomeTabs" + @Suppress("ConstPropertyName") + private const val TabFadeDuration = 200 + + @Suppress("ConstPropertyName") + private const val TabNavigatorKey = "HomeTabs" + + @SuppressLint("ComposeCompositionLocalUsage") + val LocalHomeScreenInsetsProvider = staticCompositionLocalOf { WindowInsets(0.dp) } private val TABS = listOf( LibraryTab, @@ -84,6 +103,7 @@ object HomeScreen : Screen() { @Composable override fun Content() { val navigator = LocalNavigator.currentOrThrow + var scale by remember { mutableFloatStateOf(1f) } // SY --> val scope = rememberCoroutineScope() @@ -94,7 +114,7 @@ object HomeScreen : Screen() { TabNavigator( tab = LibraryTab, - key = TAB_NAVIGATOR_KEY, + key = TabNavigatorKey, ) { tabNavigator -> // Provide usable navigator to content screen CompositionLocalProvider(LocalNavigator provides navigator) { @@ -134,26 +154,62 @@ object HomeScreen : Screen() { } } }, - contentWindowInsets = WindowInsets(0), ) { contentPadding -> Box( modifier = Modifier - .padding(contentPadding) - .consumeWindowInsets(contentPadding), + .graphicsLayer { + scaleX = scale + scaleY = scale + } + .windowInsetsPadding( + remember { + object : WindowInsets { + override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int { + return with(density) { + contentPadding.calculateLeftPadding(layoutDirection).roundToPx() + } + } + + override fun getRight(density: Density, layoutDirection: LayoutDirection): Int { + return with(density) { + contentPadding.calculateRightPadding(layoutDirection).roundToPx() + } + } + + override fun getBottom(density: Density): Int = 0 + + override fun getTop(density: Density): Int = 0 + } + }, + ), ) { + val insets = remember { + object : WindowInsets { + override fun getBottom(density: Density): Int { + return with(density) { contentPadding.calculateBottomPadding().roundToPx() } + } + + override fun getTop(density: Density): Int { + return with(density) { contentPadding.calculateTopPadding().roundToPx() } + } + + override fun getLeft(density: Density, layoutDirection: LayoutDirection): Int = 0 + + override fun getRight(density: Density, layoutDirection: LayoutDirection): Int = 0 + } + } AnimatedContent( targetState = tabNavigator.current, transitionSpec = { - materialFadeThroughIn( - initialScale = 1f, - durationMillis = TAB_FADE_DURATION, - ) togetherWith - materialFadeThroughOut(durationMillis = TAB_FADE_DURATION) + materialFadeThroughIn(initialScale = 1f, durationMillis = TabFadeDuration) togetherWith + materialFadeThroughOut(durationMillis = TabFadeDuration) }, label = "tabContent", ) { - tabNavigator.saveableState(key = "currentTab", it) { - it.Content() + CompositionLocalProvider(LocalHomeScreenInsetsProvider provides insets) { + tabNavigator.saveableState(key = "currentTab", it) { + it.Content() + } } } } @@ -161,10 +217,32 @@ 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::class != LibraryTab::class, + ) { progress -> + handlingBack = true + val currentTab = tabNavigator.current + try { + progress.collect { backEvent -> + scale = lerp(1f, 0.92f, PredictiveBack.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 { @@ -312,8 +390,6 @@ object HomeScreen : Screen() { Icon( painter = tab.options.icon!!, contentDescription = tab.options.title, - // TODO: https://issuetracker.google.com/u/0/issues/316327367 - tint = LocalContentColor.current, ) } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt index a624c4730..7d0268636 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/more/OnboardingScreen.kt @@ -33,12 +33,9 @@ class OnboardingScreen : Screen() { val restoreSettingKey = stringResource(SettingsDataScreen.restorePreferenceKeyString) - BackHandler( - enabled = !shownOnboardingFlow, - onBack = { - // Prevent exiting if onboarding hasn't been completed - }, - ) + BackHandler(enabled = !shownOnboardingFlow) { + // Prevent exiting if onboarding hasn't been completed + } OnboardingScreen( onComplete = finishOnboarding, 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..17a7182af 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,19 +40,19 @@ class SettingsScreen( Destination.Tracking.id -> SettingsTrackingScreen else -> SettingsMainScreen }, - content = { - val pop: () -> Unit = { - if (it.canPop) { - it.pop() - } else { - parentNavigator.pop() - } + onBackPressed = null, + ) { + val pop: () -> Unit = { + if (it.canPop) { + it.pop() + } else { + parentNavigator.pop() } - CompositionLocalProvider(LocalBackPress provides pop) { - DefaultNavigatorScreenTransition(navigator = it) - } - }, - ) + } + CompositionLocalProvider(LocalBackPress provides pop) { + DefaultNavigatorScreenTransition(navigator = it) + } + } } else { Navigator( screen = when (destination) { @@ -61,6 +61,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/app/src/main/java/exh/debug/SettingsDebugScreen.kt b/app/src/main/java/exh/debug/SettingsDebugScreen.kt index d064b77ba..cc4f872e0 100644 --- a/app/src/main/java/exh/debug/SettingsDebugScreen.kt +++ b/app/src/main/java/exh/debug/SettingsDebugScreen.kt @@ -6,8 +6,6 @@ import androidx.compose.animation.Crossfade import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.forEachGesture -import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Spacer @@ -201,13 +199,7 @@ class SettingsDebugScreen : Screen() { Modifier .fillMaxSize() .background(color = Color.White.copy(alpha = 0.3F)) - .pointerInput(running && result == null) { - forEachGesture { - awaitPointerEventScope { - waitForUpOrCancellation()?.consume() - } - } - }, + .pointerInput(running && result == null) {}, contentAlignment = Alignment.Center, ) { CircularProgressIndicator() diff --git a/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt b/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt index f829eef76..033a75d2b 100644 --- a/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt +++ b/app/src/main/java/exh/md/follows/MangaDexFollowsScreen.kt @@ -84,7 +84,7 @@ class MangaDexFollowsScreen(private val sourceId: Long) : Screen() { val duplicateManga = screenModel.getDuplicateLibraryManga(manga) when { manga.favorite -> screenModel.setDialog(BrowseSourceScreenModel.Dialog.RemoveManga(manga)) - duplicateManga != null -> screenModel.setDialog( + duplicateManga.isNotEmpty() -> screenModel.setDialog( BrowseSourceScreenModel.Dialog.AddDuplicateManga( manga, duplicateManga, 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 a33ad3a5b..932af9deb 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,10 +1,13 @@ package tachiyomi.presentation.core.components -import androidx.activity.compose.BackHandler +import androidx.activity.compose.PredictiveBackHandler +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.animation.rememberSplineBasedDecay import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.AnchoredDraggableDefaults import androidx.compose.foundation.gestures.AnchoredDraggableState import androidx.compose.foundation.gestures.DraggableAnchors import androidx.compose.foundation.gestures.Orientation @@ -23,16 +26,21 @@ 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 import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable 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 @@ -41,14 +49,15 @@ import androidx.compose.ui.platform.LocalDensity 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 tachiyomi.presentation.core.util.PredictiveBack +import kotlin.coroutines.cancellation.CancellationException import kotlin.math.roundToInt -private val sheetAnimationSpec = tween(durationMillis = 350) - @Composable fun AdaptiveSheet( isTabletUi: Boolean, @@ -75,6 +84,7 @@ fun AdaptiveSheet( Box( modifier = Modifier .clickable( + enabled = true, interactionSource = null, indication = null, onClick = internalOnDismissRequest, @@ -85,6 +95,11 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier + .predictiveBackAnimation( + enabled = remember { derivedStateOf { alpha > 0f } }.value, + transformOrigin = TransformOrigin.Center, + onBack = internalOnDismissRequest, + ) .requiredWidthIn(max = 460.dp) .clickable( interactionSource = null, @@ -97,7 +112,6 @@ fun AdaptiveSheet( shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, content = { - BackHandler(enabled = alpha > 0f, onBack = internalOnDismissRequest) content() }, ) @@ -107,16 +121,14 @@ fun AdaptiveSheet( } } } else { - val decayAnimationSpec = rememberSplineBasedDecay() - val anchoredDraggableState = remember { - AnchoredDraggableState( - initialValue = 1, - positionalThreshold = { with(density) { 56.dp.toPx() } }, - velocityThreshold = { with(density) { 125.dp.toPx() } }, - snapAnimationSpec = sheetAnimationSpec, - decayAnimationSpec = decayAnimationSpec, - ) + val anchoredDraggableState = rememberSaveable(saver = AnchoredDraggableState.Saver()) { + AnchoredDraggableState(initialValue = 1) } + val flingBehavior = AnchoredDraggableDefaults.flingBehavior( + state = anchoredDraggableState, + positionalThreshold = { _: Float -> with(density) { 56.dp.toPx() } }, + animationSpec = sheetAnimationSpec, + ) val internalOnDismissRequest = { if (anchoredDraggableState.settledValue == 0) { scope.launch { anchoredDraggableState.animateTo(1) } @@ -141,6 +153,11 @@ fun AdaptiveSheet( ) { Surface( modifier = Modifier + .predictiveBackAnimation( + enabled = anchoredDraggableState.targetValue == 0, + transformOrigin = TransformOrigin(0.5f, 1f), + onBack = internalOnDismissRequest, + ) .widthIn(max = 460.dp) .clickable( interactionSource = null, @@ -151,9 +168,9 @@ fun AdaptiveSheet( if (enableSwipeDismiss) { Modifier.nestedScroll( remember(anchoredDraggableState) { - anchoredDraggableState.preUpPostDownNestedScrollConnection( - onFling = { scope.launch { anchoredDraggableState.settle(it) } }, - ) + anchoredDraggableState.preUpPostDownNestedScrollConnection { + scope.launch { anchoredDraggableState.settle(sheetAnimationSpec) } + } }, ) } else { @@ -174,16 +191,13 @@ fun AdaptiveSheet( state = anchoredDraggableState, orientation = Orientation.Vertical, enabled = enableSwipeDismiss, + flingBehavior = flingBehavior, ) .navigationBarsPadding() .statusBarsPadding(), shape = MaterialTheme.shapes.extraLarge, color = MaterialTheme.colorScheme.surfaceContainerHigh, content = { - BackHandler( - enabled = anchoredDraggableState.targetValue == 0, - onBack = internalOnDismissRequest, - ) content() }, ) @@ -238,7 +252,11 @@ private fun AnchoredDraggableState.preUpPostDownNestedScrollConnection( override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { onFling(available.toFloat()) - return available + return if (targetValue != settledValue) { + available + } else { + Velocity.Zero + } } private fun Float.toOffset(): Offset = Offset(0f, this) @@ -249,3 +267,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, PredictiveBack.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) diff --git a/presentation-core/src/main/java/tachiyomi/presentation/core/util/PredictiveBack.kt b/presentation-core/src/main/java/tachiyomi/presentation/core/util/PredictiveBack.kt new file mode 100644 index 000000000..b4280dbf1 --- /dev/null +++ b/presentation-core/src/main/java/tachiyomi/presentation/core/util/PredictiveBack.kt @@ -0,0 +1,9 @@ +package tachiyomi.presentation.core.util + +import androidx.compose.animation.core.CubicBezierEasing + +private val PredictiveBackEasing = CubicBezierEasing(0.1f, 0.1f, 0f, 1f) + +object PredictiveBack { + fun transform(progress: Float): Float = PredictiveBackEasing.transform(progress) +}