From 60518ef9669e0eabe0ae7a6af60beed428da1963 Mon Sep 17 00:00:00 2001 From: Jan Skrasek Date: Sun, 14 Jul 2024 16:32:45 +0200 Subject: [PATCH 1/4] update demo to targetSdk=35 --- demo/build.gradle.kts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/demo/build.gradle.kts b/demo/build.gradle.kts index 77783e6..89578a0 100644 --- a/demo/build.gradle.kts +++ b/demo/build.gradle.kts @@ -16,7 +16,7 @@ android { defaultConfig { applicationId = "dev.hrach.navigation.demo" minSdk = 26 - targetSdk = 34 + targetSdk = 35 versionName = "1.0.0" versionCode = 1 testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" @@ -48,6 +48,7 @@ android { lint { disable.add("GradleDependency") + disable.add("OldTargetApi") abortOnError = true warningsAsErrors = true } From 8def2176bf8536df934588777d6cb78d4f99d222 Mon Sep 17 00:00:00 2001 From: Jan Skrasek Date: Sun, 14 Jul 2024 16:23:51 +0200 Subject: [PATCH 2/4] modal sheet: fix dark mode detection for status bar --- gradle/libs.versions.toml | 1 + modalsheet/build.gradle.kts | 1 + .../navigation/modalsheet/ModalSheetDialog.kt | 16 +++++++++++----- 3 files changed, 13 insertions(+), 5 deletions(-) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 8cf742c..012349b 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -9,6 +9,7 @@ kotlin-serialization-core = "org.jetbrains.kotlinx:kotlinx-serialization-core:1. kotlin-serialization-json = "org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3" appcompat = "androidx.appcompat:appcompat:1.6.1" androidx-lifecycle-runtime = "androidx.lifecycle:lifecycle-runtime:2.8.1" +compose-foundation = "androidx.compose.foundation:foundation:1.6.8" compose-material3 = "androidx.compose.material3:material3:1.3.0-beta04" navigation-compose = "androidx.navigation:navigation-compose:2.8.0-beta04" junit = { module = "junit:junit", version = "4.13.2" } diff --git a/modalsheet/build.gradle.kts b/modalsheet/build.gradle.kts index 31ed78c..a32a230 100644 --- a/modalsheet/build.gradle.kts +++ b/modalsheet/build.gradle.kts @@ -55,6 +55,7 @@ kotlinter { dependencies { implementation(libs.appcompat) + implementation(libs.compose.foundation) implementation(libs.navigation.compose) testImplementation(libs.junit) diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt index 983956e..3928b5d 100644 --- a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt @@ -13,6 +13,7 @@ import androidx.activity.ComponentDialog import androidx.activity.compose.PredictiveBackHandler import androidx.activity.setViewTreeOnBackPressedDispatcherOwner import androidx.appcompat.view.ContextThemeWrapper +import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionContext @@ -60,6 +61,7 @@ internal fun ModalSheetDialog( val composition = rememberCompositionContext() val currentContent by rememberUpdatedState(content) val dialogId = rememberSaveable { UUID.randomUUID() } + val darkThemeEnabled = isSystemInDarkTheme() val dialog = remember(view, density) { ModalSheetDialogWrapper( onPredictiveBack, @@ -68,6 +70,7 @@ internal fun ModalSheetDialog( layoutDirection, density, dialogId, + darkThemeEnabled ).apply { setContent(composition) { Box( @@ -90,6 +93,7 @@ internal fun ModalSheetDialog( onPredictiveBack = onPredictiveBack, securePolicy = securePolicy, layoutDirection = layoutDirection, + darkThemeEnabled = darkThemeEnabled, ) } } @@ -132,6 +136,7 @@ internal class ModalSheetDialogWrapper( layoutDirection: LayoutDirection, density: Density, dialogId: UUID, + darkThemeEnabled: Boolean, ) : ComponentDialog(ContextThemeWrapper(composeView.context, R.style.EdgeToEdgeFloatingDialogWindowTheme)), ViewRootForInspector { private val dialogLayout: ModalSheetDialogLayout @@ -181,11 +186,7 @@ internal class ModalSheetDialogWrapper( ) dialogLayout.setViewTreeOnBackPressedDispatcherOwner(this) // Initial setup - updateParameters(onPredictiveBack, securePolicy, layoutDirection) - WindowCompat.getInsetsController(window, window.decorView).apply { - isAppearanceLightStatusBars = true - isAppearanceLightNavigationBars = true - } + updateParameters(onPredictiveBack, securePolicy, layoutDirection, darkThemeEnabled) } private fun setLayoutDirection(layoutDirection: LayoutDirection) { @@ -216,6 +217,7 @@ internal class ModalSheetDialogWrapper( onPredictiveBack: suspend (Flow) -> Unit, securePolicy: SecureFlagPolicy, layoutDirection: LayoutDirection, + darkThemeEnabled: Boolean, ) { this.onPredictiveBack = onPredictiveBack setSecurePolicy(securePolicy) @@ -233,6 +235,10 @@ internal class ModalSheetDialogWrapper( WindowManager.LayoutParams.SOFT_INPUT_ADJUST_RESIZE }, ) + WindowCompat.getInsetsController(window!!, window!!.decorView).apply { + isAppearanceLightStatusBars = !darkThemeEnabled + isAppearanceLightNavigationBars = !darkThemeEnabled + } } fun disposeComposition() { From 0f07358647b91d0aed56bd2e9708011ed8972ffa Mon Sep 17 00:00:00 2001 From: Jan Skrasek Date: Sun, 14 Jul 2024 16:59:13 +0200 Subject: [PATCH 3/4] modal sheet: fix transition with a default bg --- .../dev/hrach/navigation/demo/NavHost.kt | 3 +- .../hrach/navigation/demo/screens/Modal1.kt | 29 ++++++++++--------- .../navigation/modalsheet/ModalSheetHost.kt | 6 +++- readme.md | 2 +- 4 files changed, 24 insertions(+), 16 deletions(-) diff --git a/demo/src/main/kotlin/dev/hrach/navigation/demo/NavHost.kt b/demo/src/main/kotlin/dev/hrach/navigation/demo/NavHost.kt index afe0472..5b934e2 100644 --- a/demo/src/main/kotlin/dev/hrach/navigation/demo/NavHost.kt +++ b/demo/src/main/kotlin/dev/hrach/navigation/demo/NavHost.kt @@ -1,5 +1,6 @@ package dev.hrach.navigation.demo +import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.navigation.NavHostController import androidx.navigation.compose.NavHost @@ -34,6 +35,6 @@ internal fun NavHost( modalSheet { Modal2() } bottomSheet { BottomSheet(navController) } } - ModalSheetHost(modalSheetNavigator) + ModalSheetHost(modalSheetNavigator, containerColor = MaterialTheme.colorScheme.background) BottomSheetHost(bottomSheetNavigator) } diff --git a/demo/src/main/kotlin/dev/hrach/navigation/demo/screens/Modal1.kt b/demo/src/main/kotlin/dev/hrach/navigation/demo/screens/Modal1.kt index a5725c2..20aa530 100644 --- a/demo/src/main/kotlin/dev/hrach/navigation/demo/screens/Modal1.kt +++ b/demo/src/main/kotlin/dev/hrach/navigation/demo/screens/Modal1.kt @@ -1,7 +1,6 @@ package dev.hrach.navigation.demo.screens import android.annotation.SuppressLint -import androidx.compose.foundation.background import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.fillMaxSize @@ -9,6 +8,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Surface import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue @@ -42,19 +42,22 @@ private fun Modal1( navigate: (Any) -> Unit, bottomSheetResult: Int, ) { - Column( - Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.surface) - .windowInsetsPadding(WindowInsets.systemBars), + Surface( + color = MaterialTheme.colorScheme.inverseSurface, ) { - Text("Modal 1") - OutlinedButton(onClick = { navigate(Destinations.Modal2) }) { - Text("Modal 2") + Column( + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding(WindowInsets.systemBars), + ) { + Text("Modal 1") + OutlinedButton(onClick = { navigate(Destinations.Modal2) }) { + Text("Modal 2") + } + OutlinedButton(onClick = { navigate(Destinations.BottomSheet) }) { + Text("BottomSheet") + } + Text("BottomSheetResult: $bottomSheetResult") } - OutlinedButton(onClick = { navigate(Destinations.BottomSheet) }) { - Text("BottomSheet") - } - Text("BottomSheetResult: $bottomSheetResult") } } diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt index 18cf641..f3ce684 100644 --- a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt @@ -10,6 +10,7 @@ import androidx.compose.animation.core.tween import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.runtime.Composable @@ -26,6 +27,7 @@ import androidx.compose.runtime.setValue import androidx.compose.runtime.snapshots.SnapshotStateList import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalInspectionMode import androidx.compose.ui.window.SecureFlagPolicy import androidx.lifecycle.Lifecycle @@ -39,6 +41,7 @@ import kotlinx.coroutines.CancellationException @Composable public fun ModalSheetHost( modalSheetNavigator: ModalSheetNavigator, + containerColor: Color, modifier: Modifier = Modifier, enterTransition: (AnimatedContentTransitionScope.() -> @JvmSuppressWildcards EnterTransition) = { fadeIn(animationSpec = tween(700)) }, @@ -134,7 +137,8 @@ public fun ModalSheetHost( securePolicy = securePolicy, ) { transition.AnimatedContent( - modifier = modifier, + modifier = modifier + .background(if (transition.targetState == null) Color.Unspecified else containerColor), contentAlignment = Alignment.TopStart, transitionSpec = block@{ val initialState = initialState ?: return@block ContentTransform( diff --git a/readme.md b/readme.md index e2383b1..5e820c0 100644 --- a/readme.md +++ b/readme.md @@ -37,7 +37,7 @@ NavHost( modalSheet { Modal(navController) } bottomSheet { BottomSheet(navController) } } -ModalSheetHost(modalSheetNavigator) +ModalSheetHost(modalSheetNavigator, containerColor = MaterialTheme.colorScheme.background) BottomSheetHost(bottomSheetNavigator) ``` From 8bbc4de5c662dd1c3160be0b2173098dd413f542 Mon Sep 17 00:00:00 2001 From: Jan Skrasek Date: Sun, 14 Jul 2024 18:05:14 +0200 Subject: [PATCH 4/4] modal sheet: properly implement predictive back handling --- .../navigation/modalsheet/ModalSheetDialog.kt | 175 ++++++++++++++++-- .../navigation/modalsheet/ModalSheetHost.kt | 66 ++++++- 2 files changed, 222 insertions(+), 19 deletions(-) diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt index 3928b5d..e1d4cfc 100644 --- a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetDialog.kt @@ -1,6 +1,5 @@ package dev.hrach.navigation.modalsheet -import android.annotation.SuppressLint import android.content.Context import android.graphics.Outline import android.os.Build @@ -8,10 +7,15 @@ import android.view.View import android.view.ViewOutlineProvider import android.view.Window import android.view.WindowManager +import android.window.BackEvent +import android.window.OnBackAnimationCallback +import android.window.OnBackInvokedCallback +import android.window.OnBackInvokedDispatcher import androidx.activity.BackEventCompat import androidx.activity.ComponentDialog -import androidx.activity.compose.PredictiveBackHandler import androidx.activity.setViewTreeOnBackPressedDispatcherOwner +import androidx.annotation.DoNotInline +import androidx.annotation.RequiresApi import androidx.appcompat.view.ContextThemeWrapper import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.Box @@ -19,10 +23,12 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionContext import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.State import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCompositionContext +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue @@ -47,7 +53,16 @@ import androidx.lifecycle.setViewTreeViewModelStoreOwner import androidx.savedstate.findViewTreeSavedStateRegistryOwner import androidx.savedstate.setViewTreeSavedStateRegistryOwner import java.util.UUID +import java.util.concurrent.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.channels.BufferOverflow.SUSPEND +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.channels.Channel.Factory.BUFFERED import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.launch @Composable internal fun ModalSheetDialog( @@ -62,15 +77,19 @@ internal fun ModalSheetDialog( val currentContent by rememberUpdatedState(content) val dialogId = rememberSaveable { UUID.randomUUID() } val darkThemeEnabled = isSystemInDarkTheme() + val currentOnPredictiveBack = rememberUpdatedState(onPredictiveBack) + val scope = rememberCoroutineScope() + val dialog = remember(view, density) { ModalSheetDialogWrapper( - onPredictiveBack, + currentOnPredictiveBack, view, + scope, securePolicy, layoutDirection, density, dialogId, - darkThemeEnabled + darkThemeEnabled, ).apply { setContent(composition) { Box( @@ -90,7 +109,6 @@ internal fun ModalSheetDialog( } SideEffect { dialog.updateParameters( - onPredictiveBack = onPredictiveBack, securePolicy = securePolicy, layoutDirection = layoutDirection, darkThemeEnabled = darkThemeEnabled, @@ -104,9 +122,11 @@ internal fun ModalSheetDialog( private class ModalSheetDialogLayout( context: Context, override val window: Window, - private var onPredictiveBack: suspend (Flow) -> Unit, + private val onPredictiveBack: State) -> Unit>, + private val scope: CoroutineScope, ) : AbstractComposeView(context), DialogWindowProvider { private var content: @Composable () -> Unit by mutableStateOf({}) + private var backCallback: Any? = null override var shouldCreateCompositionOnAttachedToWindow: Boolean = false private set @@ -117,21 +137,122 @@ private class ModalSheetDialogLayout( createComposition() } - // Display width and height logic removed, size will always span fillMaxSize(). - @SuppressLint("NoCollectCallFound") @Composable override fun Content() { - PredictiveBackHandler { onPredictiveBack(it) } content() } + + override fun onAttachedToWindow() { + super.onAttachedToWindow() + maybeRegisterBackCallback() + } + + override fun onDetachedFromWindow() { + super.onDetachedFromWindow() + maybeUnregisterBackCallback() + } + + private fun maybeRegisterBackCallback() { + if (Build.VERSION.SDK_INT < 33) return + if (backCallback == null) { + backCallback = when { + Build.VERSION.SDK_INT >= 34 -> Api34Impl.createBackCallback(onPredictiveBack, scope) + else -> Api33Impl.createBackCallback(onPredictiveBack, scope) + } + } + Api33Impl.maybeRegisterBackCallback(this, backCallback) + } + + private fun maybeUnregisterBackCallback() { + if (Build.VERSION.SDK_INT >= 33) { + Api33Impl.maybeUnregisterBackCallback(this, backCallback) + } + backCallback = null + } + + @RequiresApi(34) + private object Api34Impl { + @JvmStatic + @DoNotInline + fun createBackCallback( + currentOnBack: State) -> Unit>, + scope: CoroutineScope, + ) = object : OnBackAnimationCallback { + var onBackInstance: OnBackInstance? = null + + override fun onBackStarted(backEvent: BackEvent) { + onBackInstance?.cancel() + onBackInstance = OnBackInstance(scope, true, currentOnBack.value) + } + + override fun onBackProgressed(backEvent: BackEvent) { + onBackInstance?.send(BackEventCompat(backEvent)) + } + + override fun onBackInvoked() { + onBackInstance?.apply { + if (!isPredictiveBack) { + cancel() + onBackInstance = null + } + } + if (onBackInstance == null) { + onBackInstance = OnBackInstance(scope, false, currentOnBack.value) + } + onBackInstance?.close() + onBackInstance?.isPredictiveBack = false + } + + override fun onBackCancelled() { + onBackInstance?.cancel() + onBackInstance = null + onBackInstance?.isPredictiveBack = false + } + } + } + + @RequiresApi(33) + private object Api33Impl { + @JvmStatic + @DoNotInline + fun createBackCallback( + currentOnBack: State) -> Unit>, + scope: CoroutineScope, + ) { + OnBackInvokedCallback { + scope.launch { + currentOnBack.value.invoke(flowOf()) + } + } + } + + @JvmStatic + @DoNotInline + fun maybeRegisterBackCallback(view: View, backCallback: Any?) { + if (backCallback !is OnBackInvokedCallback) return + val dispatcher = view.findOnBackInvokedDispatcher() ?: return + dispatcher.registerOnBackInvokedCallback( + OnBackInvokedDispatcher.PRIORITY_OVERLAY, + backCallback, + ) + } + + @JvmStatic + @DoNotInline + fun maybeUnregisterBackCallback(view: View, backCallback: Any?) { + if (backCallback !is OnBackInvokedCallback) return + view.findOnBackInvokedDispatcher()?.unregisterOnBackInvokedCallback(backCallback) + } + } } // Fork of androidx.compose.ui.window.DialogWrapper. // predictiveBackProgress and scope params added for predictive back implementation. // EdgeToEdgeFloatingDialogWindowTheme provided to allow theme to extend into status bar. internal class ModalSheetDialogWrapper( - private var onPredictiveBack: suspend (Flow) -> Unit, + onPredictiveBack: State) -> Unit>, private val composeView: View, + scope: CoroutineScope, securePolicy: SecureFlagPolicy, layoutDirection: LayoutDirection, density: Density, @@ -155,6 +276,7 @@ internal class ModalSheetDialogWrapper( context, window, onPredictiveBack, + scope, ).apply { // Set unique id for AbstractComposeView. This allows state restoration for the state // defined inside the Dialog via rememberSaveable() @@ -186,7 +308,7 @@ internal class ModalSheetDialogWrapper( ) dialogLayout.setViewTreeOnBackPressedDispatcherOwner(this) // Initial setup - updateParameters(onPredictiveBack, securePolicy, layoutDirection, darkThemeEnabled) + updateParameters(securePolicy, layoutDirection, darkThemeEnabled) } private fun setLayoutDirection(layoutDirection: LayoutDirection) { @@ -214,12 +336,10 @@ internal class ModalSheetDialogWrapper( } fun updateParameters( - onPredictiveBack: suspend (Flow) -> Unit, securePolicy: SecureFlagPolicy, layoutDirection: LayoutDirection, darkThemeEnabled: Boolean, ) { - this.onPredictiveBack = onPredictiveBack setSecurePolicy(securePolicy) setLayoutDirection(layoutDirection) // Window flags to span parent window. @@ -251,6 +371,35 @@ internal class ModalSheetDialogWrapper( } } +private class OnBackInstance( + scope: CoroutineScope, + var isPredictiveBack: Boolean, + onBack: suspend (progress: Flow) -> Unit, +) { + val channel = Channel(capacity = BUFFERED, onBufferOverflow = SUSPEND) + val job = scope.launch { + var completed = false + onBack( + channel.consumeAsFlow().onCompletion { + completed = true + }, + ) + check(completed) { + "You must collect the progress flow" + } + } + + fun send(backEvent: BackEventCompat) = channel.trySend(backEvent) + + // idempotent if invoked more than once + fun close() = channel.close() + + fun cancel() { + channel.cancel(CancellationException("onBack cancelled")) + job.cancel() + } +} + internal fun View.isFlagSecureEnabled(): Boolean { val windowParams = rootView.layoutParams as? WindowManager.LayoutParams if (windowParams != null) { diff --git a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt index f3ce684..8ecb759 100644 --- a/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt +++ b/modalsheet/src/main/kotlin/dev/hrach/navigation/modalsheet/ModalSheetHost.kt @@ -6,8 +6,10 @@ 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.SeekableTransitionState +import androidx.compose.animation.core.animate +import androidx.compose.animation.core.rememberTransition import androidx.compose.animation.core.tween -import androidx.compose.animation.core.updateTransition import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background @@ -36,6 +38,7 @@ import androidx.navigation.NavBackStackEntry import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.compose.LocalOwnersProvider import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.launch @Suppress("UNUSED_ANONYMOUS_PARAMETER") @Composable @@ -66,11 +69,12 @@ public fun ModalSheetHost( val visibleEntries = rememberVisibleList(modalBackStack) visibleEntries.PopulateVisibleList(modalBackStack) - val backStackEntry: NavBackStackEntry? = if (LocalInspectionMode.current) { - modalSheetNavigator.backStack.collectAsState(emptyList()).value.lastOrNull() + val currentBackStack = if (LocalInspectionMode.current) { + modalSheetNavigator.backStack.collectAsState(emptyList()).value } else { - visibleEntries.lastOrNull() + visibleEntries } + val backStackEntry: NavBackStackEntry? = currentBackStack.lastOrNull() val finalEnter: AnimatedContentTransitionScope.() -> EnterTransition = { val targetDestination = targetState.destination as ModalSheetNavigator.Destination @@ -105,10 +109,54 @@ public fun ModalSheetHost( } ?: sizeTransform?.invoke(this) } - val transition = updateTransition(backStackEntry, label = "entry") + val transitionState = remember { + // The state returned here cannot be nullable cause it produces the input of the + // transitionSpec passed into the AnimatedContent and that must match the non-nullable + // scope exposed by the transitions on the NavHost and composable APIs. + SeekableTransitionState(backStackEntry) + } + val transition = rememberTransition(transitionState, label = "entry") val nothingToShow = transition.currentState == transition.targetState && transition.currentState == null && backStackEntry == null + + if (inPredictiveBack) { + LaunchedEffect(progress) { + val previousEntry = currentBackStack.getOrNull(currentBackStack.size - 2) + transitionState.seekTo(progress, previousEntry) + } + } else { + LaunchedEffect(backStackEntry) { + // This ensures we don't animate after the back gesture is cancelled and we + // are already on the current state + if (transitionState.currentState != backStackEntry) { + transitionState.animateTo(backStackEntry) + } else { + // convert from nanoseconds to milliseconds + val totalDuration = transition.totalDurationNanos / 1000000 + // When the predictive back gesture is cancel, we need to manually animate + // the SeekableTransitionState from where it left off, to zero and then + // snapTo the final position. + animate( + transitionState.fraction, + 0f, + animationSpec = tween((transitionState.fraction * totalDuration).toInt()), + ) { value, _ -> + this@LaunchedEffect.launch { + if (value > 0) { + // Seek the original transition back to the currentState + transitionState.seekTo(value) + } + if (value == 0f) { + // Once we animate to the start, we need to snap to the right state. + transitionState.snapTo(backStackEntry) + } + } + } + } + } + } + if (!nothingToShow) { val securePolicy = (backStackEntry?.destination as? ModalSheetNavigator.Destination) ?.securePolicy @@ -170,7 +218,13 @@ public fun ModalSheetHost( sizeTransform = finalSizeTransform(this), ) }, - ) { currentEntry -> + ) { + val currentEntry = if (inPredictiveBack) { + it + } else { + visibleEntries.lastOrNull { entry -> it == entry } + } + if (currentEntry == null) { Box(Modifier.fillMaxSize()) {} return@AnimatedContent