Add full predictive back support (#2085)

Co-authored-by: p
This commit is contained in:
AntsyLich
2025-05-10 06:07:05 +06:00
committed by GitHub
parent 33d407ee9c
commit c12bdbae8e
12 changed files with 576 additions and 116 deletions

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,13 +24,23 @@ 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.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 +60,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
@@ -57,8 +69,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() {
@@ -66,8 +80,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,
@@ -80,9 +100,11 @@ object HomeScreen : Screen() {
@Composable
override fun Content() {
val navigator = LocalNavigator.currentOrThrow
var scale by remember { mutableFloatStateOf(1f) }
TabNavigator(
tab = LibraryTab,
key = TAB_NAVIGATOR_KEY,
key = TabNavigatorKey,
) { tabNavigator ->
// Provide usable navigator to content screen
CompositionLocalProvider(LocalNavigator provides navigator) {
@@ -114,26 +136,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()
}
}
}
}
@@ -141,10 +199,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 {
@@ -289,8 +369,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 = {
// Prevent exiting if onboarding hasn't been completed
},
)
BackHandler(enabled = !shownOnboardingFlow) {
// Prevent exiting if onboarding hasn't been completed
}
OnboardingScreen(
onComplete = finishOnboarding,

View File

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

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
}