Implement predictive back animation (#10273)
For home screen tabs, Navigator screens and most dialogs
This commit is contained in:
parent
4b208fc7ce
commit
9c120e6231
8 changed files with 510 additions and 32 deletions
|
@ -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 = {
|
||||
|
|
|
@ -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 = {
|
||||
|
|
|
@ -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> { 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<Float>(
|
||||
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
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
|
||||
|
|
|
@ -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(
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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<Float>(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 <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, 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<Float>(durationMillis = 350)
|
||||
|
|
Reference in a new issue