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