Implement predictive back animation (#10273)

For home screen tabs, Navigator screens and most dialogs
This commit is contained in:
Ivan Iskandar 2023-12-28 23:01:01 +07:00 committed by GitHub
parent 4b208fc7ce
commit 9c120e6231
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 510 additions and 32 deletions

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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