blob: 92a2db19f010cf704fc8fbe545c4f076ce1ee359 [file] [log] [blame]
/*
* Copyright 2024 The Android Open Source Project
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* https://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package androidx.compose.material.navigation
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.AnimationSpec
import androidx.compose.animation.core.SpringSpec
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveableStateHolder
import androidx.compose.runtime.setValue
import androidx.compose.ui.util.fastForEach
import androidx.navigation.FloatingWindow
import androidx.navigation.NavBackStackEntry
import androidx.navigation.NavDestination
import androidx.navigation.NavOptions
import androidx.navigation.Navigator
import androidx.navigation.NavigatorState
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.transform
/**
* The state of a [ModalBottomSheetLayout] that the [BottomSheetNavigator] drives
*
* @param sheetState The sheet state that is driven by the [BottomSheetNavigator]
*/
public class BottomSheetNavigatorSheetState(private val sheetState: ModalBottomSheetState) {
/**
* @see ModalBottomSheetState.isVisible
*/
public val isVisible: Boolean
get() = sheetState.isVisible
/**
* @see ModalBottomSheetState.currentValue
*/
public val currentValue: ModalBottomSheetValue
get() = sheetState.currentValue
/**
* @see ModalBottomSheetState.targetValue
*/
public val targetValue: ModalBottomSheetValue
get() = sheetState.targetValue
}
/**
* Create and remember a [BottomSheetNavigator]
*/
@Composable
public fun rememberBottomSheetNavigator(
animationSpec: AnimationSpec<Float> = SpringSpec()
): BottomSheetNavigator {
val sheetState = rememberModalBottomSheetState(
ModalBottomSheetValue.Hidden,
animationSpec = animationSpec
)
return remember(sheetState) { BottomSheetNavigator(sheetState) }
}
/**
* Navigator that drives a [ModalBottomSheetState] for use of [ModalBottomSheetLayout]s
* with the navigation library. Every destination using this Navigator must set a valid
* [Composable] by setting it directly on an instantiated [Destination] or calling
* [androidx.compose.material.navigation.bottomSheet].
*
* <b>The [sheetContent] [Composable] will always host the latest entry of the back stack. When
* navigating from a [BottomSheetNavigator.Destination] to another
* [BottomSheetNavigator.Destination], the content of the sheet will be replaced instead of a
* new bottom sheet being shown.</b>
*
* When the sheet is dismissed by the user, the [state]'s [NavigatorState.backStack] will be popped.
*
* The primary constructor is not intended for public use. Please refer to
* [rememberBottomSheetNavigator] instead.
*
* @param sheetState The [ModalBottomSheetState] that the [BottomSheetNavigator] will use to
* drive the sheet state
*/
@Navigator.Name("bottomSheet")
public class BottomSheetNavigator(
internal val sheetState: ModalBottomSheetState
) : Navigator<BottomSheetNavigator.Destination>() {
private var attached by mutableStateOf(false)
/**
* Get the back stack from the [state]. In some cases, the [sheetContent] might be composed
* before the Navigator is attached, so we specifically return an empty flow if we aren't
* attached yet.
*/
private val backStack: StateFlow<List<NavBackStackEntry>>
get() = if (attached) {
state.backStack
} else {
MutableStateFlow(emptyList())
}
/**
* Get the transitionsInProgress from the [state]. In some cases, the [sheetContent] might be
* composed before the Navigator is attached, so we specifically return an empty flow if we
* aren't attached yet.
*/
internal val transitionsInProgress: StateFlow<Set<NavBackStackEntry>>
get() = if (attached) {
state.transitionsInProgress
} else {
MutableStateFlow(emptySet())
}
/**
* Access properties of the [ModalBottomSheetLayout]'s [ModalBottomSheetState]
*/
public val navigatorSheetState: BottomSheetNavigatorSheetState =
BottomSheetNavigatorSheetState(sheetState)
/**
* A [Composable] function that hosts the current sheet content. This should be set as
* sheetContent of your [ModalBottomSheetLayout].
*/
internal val sheetContent: @Composable ColumnScope.() -> Unit = {
val saveableStateHolder = rememberSaveableStateHolder()
val transitionsInProgressEntries by transitionsInProgress.collectAsState()
// The latest back stack entry, retained until the sheet is completely hidden
// While the back stack is updated immediately, we might still be hiding the sheet, so
// we keep the entry around until the sheet is hidden
val retainedEntry by produceState<NavBackStackEntry?>(
initialValue = null,
key1 = backStack
) {
backStack
.transform { backStackEntries ->
// Always hide the sheet when the back stack is updated
// Regardless of whether we're popping or pushing, we always want to hide
// the sheet first before deciding whether to re-show it or keep it hidden
try {
sheetState.hide()
} catch (_: CancellationException) {
// We catch but ignore possible cancellation exceptions as we don't want
// them to bubble up and cancel the whole produceState coroutine
} finally {
emit(backStackEntries.lastOrNull())
}
}
.collect {
value = it
}
}
if (retainedEntry != null) {
LaunchedEffect(retainedEntry) {
sheetState.show()
}
BackHandler {
state.popWithTransition(popUpTo = retainedEntry!!, saveState = false)
}
}
SheetContentHost(
backStackEntry = retainedEntry,
sheetState = sheetState,
saveableStateHolder = saveableStateHolder,
onSheetShown = {
transitionsInProgressEntries.forEach(state::markTransitionComplete)
},
onSheetDismissed = { backStackEntry ->
// Sheet dismissal can be started through popBackStack in which case we have a
// transition that we'll want to complete
if (transitionsInProgressEntries.contains(backStackEntry)) {
state.markTransitionComplete(backStackEntry)
}
// If there is no transition in progress, the sheet has been dimissed by the
// user (for example by tapping on the scrim or through an accessibility action)
// In this case, we will immediately pop without a transition as the sheet has
// already been hidden
else {
state.pop(popUpTo = backStackEntry, saveState = false)
}
}
)
}
override fun onAttach(state: NavigatorState) {
super.onAttach(state)
attached = true
}
override fun createDestination(): Destination = Destination(
navigator = this,
content = {}
)
override fun navigate(
entries: List<NavBackStackEntry>,
navOptions: NavOptions?,
navigatorExtras: Extras?
) {
entries.fastForEach { entry ->
state.pushWithTransition(entry)
}
}
override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
state.popWithTransition(popUpTo, savedState)
}
/**
* [NavDestination] specific to [BottomSheetNavigator]
*/
@NavDestination.ClassType(Composable::class)
public class Destination(
navigator: BottomSheetNavigator,
internal val content: @Composable ColumnScope.(NavBackStackEntry) -> Unit
) : NavDestination(navigator), FloatingWindow
}