Add full predictive back support (#2085)

Co-authored-by: p
(cherry picked from commit c12bdbae8e7bc14da8966e45a3c450913e32129f)

# Conflicts:
#	app/src/main/java/eu/kanade/presentation/manga/components/MangaCoverDialog.kt
#	app/src/main/java/eu/kanade/tachiyomi/ui/home/HomeScreen.kt
This commit is contained in:
AntsyLich 2025-05-10 06:07:05 +06:00 committed by Jobobby04
parent 5c7b3c6c3b
commit ef3d9626c1
14 changed files with 582 additions and 130 deletions

View File

@ -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()
}

View File

@ -349,13 +349,9 @@ private fun MangaScreenSmallImpl(
}
// SY <--
BackHandler(onBack = {
if (isAnySelected) {
BackHandler(enabled = isAnySelected) {
onAllChapterSelected(false)
} else {
navigateUp()
}
})
Scaffold(
topBar = {
@ -680,13 +676,9 @@ fun MangaScreenLargeImpl(
val chapterListState = rememberLazyListState()
BackHandler(onBack = {
if (isAnySelected) {
BackHandler(enabled = isAnySelected) {
onAllChapterSelected(false)
} else {
navigateUp()
}
})
Scaffold(
topBar = {

View File

@ -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) {
val copy = (drawable as? BitmapDrawable)
?.bitmap
?.copy(
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),
},
false,
)
} ?: drawable
?.toDrawable(view.context.resources)
?: drawable
view.setImage(copy, ReaderPageImageView.Config(zoomDuration = 500))
}
.build()

View File

@ -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,

View File

@ -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 ->

View File

@ -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<Screen>.() -> ContentTransform,
modifier: Modifier = Modifier,
enterTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> EnterTransition = { fadeIn() },
exitTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> ExitTransition = { fadeOut() },
popEnterTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> EnterTransition = enterTransition,
popExitTransition: AnimatedContentTransitionScope<Screen>.(SwipeEdge) -> ExitTransition = exitTransition,
sizeTransform: (AnimatedContentTransitionScope<Screen>.() -> SizeTransform?)? = null,
flingAnimationSpec: () -> AnimationSpec<Float> = { 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<Float>,
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<Job, AnimationType>? 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<MutableState<Set<Screen>>, List<Screen>> {
return Saver(
save = { it.value.toList() },
restore = { mutableStateOf(it.toSet()) },
)
}

View File

@ -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<Tab>()
private val showBottomNavEvent = Channel<Boolean>()
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,24 +154,59 @@ 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",
) {
CompositionLocalProvider(LocalHomeScreenInsetsProvider provides insets) {
tabNavigator.saveableState(key = "currentTab", it) {
it.Content()
}
@ -159,12 +214,35 @@ 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,
)
}
}

View File

@ -33,12 +33,9 @@ class OnboardingScreen : Screen() {
val restoreSettingKey = stringResource(SettingsDataScreen.restorePreferenceKeyString)
BackHandler(
enabled = !shownOnboardingFlow,
onBack = {
BackHandler(enabled = !shownOnboardingFlow) {
// Prevent exiting if onboarding hasn't been completed
},
)
}
OnboardingScreen(
onComplete = finishOnboarding,

View File

@ -40,7 +40,8 @@ class SettingsScreen(
Destination.Tracking.id -> SettingsTrackingScreen
else -> SettingsMainScreen
},
content = {
onBackPressed = null,
) {
val pop: () -> Unit = {
if (it.canPop) {
it.pop()
@ -51,8 +52,7 @@ class SettingsScreen(
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(

View File

@ -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
}

View File

@ -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()

View File

@ -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,

View File

@ -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<Float>(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<Float>()
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 <T> AnchoredDraggableState<T>.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 <T> AnchoredDraggableState<T>.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<Float>(durationMillis = 350)

View File

@ -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)
}