1 /*
<lambda>null2 * Copyright 2021 The Android Open Source Project
3 *
4 * Licensed under the Apache License, Version 2.0 (the "License");
5 * you may not use this file except in compliance with the License.
6 * You may obtain a copy of the License at
7 *
8 * https://www.apache.org/licenses/LICENSE-2.0
9 *
10 * Unless required by applicable law or agreed to in writing, software
11 * distributed under the License is distributed on an "AS IS" BASIS,
12 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
13 * See the License for the specific language governing permissions and
14 * limitations under the License.
15 */
16
17 package com.google.accompanist.navigation.material
18
19 import android.annotation.SuppressLint
20 import androidx.activity.compose.BackHandler
21 import androidx.compose.animation.core.AnimationSpec
22 import androidx.compose.animation.core.SpringSpec
23 import androidx.compose.foundation.layout.ColumnScope
24 import androidx.compose.material.ExperimentalMaterialApi
25 import androidx.compose.material.ModalBottomSheetState
26 import androidx.compose.material.ModalBottomSheetValue
27 import androidx.compose.material.rememberModalBottomSheetState
28 import androidx.compose.runtime.Composable
29 import androidx.compose.runtime.LaunchedEffect
30 import androidx.compose.runtime.Stable
31 import androidx.compose.runtime.collectAsState
32 import androidx.compose.runtime.getValue
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.produceState
35 import androidx.compose.runtime.remember
36 import androidx.compose.runtime.saveable.rememberSaveableStateHolder
37 import androidx.compose.runtime.setValue
38 import androidx.navigation.FloatingWindow
39 import androidx.navigation.NavBackStackEntry
40 import androidx.navigation.NavDestination
41 import androidx.navigation.NavOptions
42 import androidx.navigation.Navigator
43 import androidx.navigation.NavigatorState
44 import com.google.accompanist.navigation.material.BottomSheetNavigator.Destination
45 import kotlinx.coroutines.CancellationException
46 import kotlinx.coroutines.flow.MutableStateFlow
47 import kotlinx.coroutines.flow.StateFlow
48 import kotlinx.coroutines.flow.transform
49
50 /**
51 * The state of a [ModalBottomSheetLayout] that the [BottomSheetNavigator] drives
52 *
53 * @param sheetState The sheet state that is driven by the [BottomSheetNavigator]
54 */
55 @ExperimentalMaterialNavigationApi
56 @OptIn(ExperimentalMaterialApi::class)
57 @Stable
58 public class BottomSheetNavigatorSheetState(internal val sheetState: ModalBottomSheetState) {
59 /**
60 * @see ModalBottomSheetState.isVisible
61 */
62 public val isVisible: Boolean
63 get() = sheetState.isVisible
64
65 /**
66 * @see ModalBottomSheetState.currentValue
67 */
68 public val currentValue: ModalBottomSheetValue
69 get() = sheetState.currentValue
70
71 /**
72 * @see ModalBottomSheetState.targetValue
73 */
74 public val targetValue: ModalBottomSheetValue
75 get() = sheetState.targetValue
76 }
77
78 /**
79 * Create and remember a [BottomSheetNavigator]
80 */
81 @ExperimentalMaterialNavigationApi
82 @OptIn(ExperimentalMaterialApi::class)
83 @Composable
rememberBottomSheetNavigatornull84 public fun rememberBottomSheetNavigator(
85 animationSpec: AnimationSpec<Float> = SpringSpec<Float>()
86 ): BottomSheetNavigator {
87 val sheetState = rememberModalBottomSheetState(
88 ModalBottomSheetValue.Hidden,
89 animationSpec = animationSpec
90 )
91 return remember { BottomSheetNavigator(sheetState) }
92 }
93
94 /**
95 * Navigator that drives a [ModalBottomSheetState] for use of [ModalBottomSheetLayout]s
96 * with the navigation library. Every destination using this Navigator must set a valid
97 * [Composable] by setting it directly on an instantiated [Destination] or calling
98 * [androidx.navigation.compose.material.bottomSheet].
99 *
100 * <b>The [sheetContent] [Composable] will always host the latest entry of the back stack. When
101 * navigating from a [BottomSheetNavigator.Destination] to another
102 * [BottomSheetNavigator.Destination], the content of the sheet will be replaced instead of a
103 * new bottom sheet being shown.</b>
104 *
105 * When the sheet is dismissed by the user, the [state]'s [NavigatorState.backStack] will be popped.
106 *
107 * The primary constructor is not intended for public use. Please refer to
108 * [rememberBottomSheetNavigator] instead.
109 *
110 * @param sheetState The [ModalBottomSheetState] that the [BottomSheetNavigator] will use to
111 * drive the sheet state
112 */
113 @ExperimentalMaterialNavigationApi
114 @OptIn(ExperimentalMaterialApi::class)
115 @Navigator.Name("BottomSheetNavigator")
116 public class BottomSheetNavigator(
117 internal val sheetState: ModalBottomSheetState
118 ) : Navigator<Destination>() {
119
120 private var attached by mutableStateOf(false)
121
122 /**
123 * Get the back stack from the [state]. In some cases, the [sheetContent] might be composed
124 * before the Navigator is attached, so we specifically return an empty flow if we aren't
125 * attached yet.
126 */
127 private val backStack: StateFlow<List<NavBackStackEntry>>
128 get() = if (attached) {
129 state.backStack
130 } else {
131 MutableStateFlow(emptyList())
132 }
133
134 /**
135 * Get the transitionsInProgress from the [state]. In some cases, the [sheetContent] might be
136 * composed before the Navigator is attached, so we specifically return an empty flow if we
137 * aren't attached yet.
138 */
139 internal val transitionsInProgress: StateFlow<Set<NavBackStackEntry>>
140 get() = if (attached) {
141 state.transitionsInProgress
142 } else {
143 MutableStateFlow(emptySet())
144 }
145
146 /**
147 * Access properties of the [ModalBottomSheetLayout]'s [ModalBottomSheetState]
148 */
149 public val navigatorSheetState: BottomSheetNavigatorSheetState = BottomSheetNavigatorSheetState(sheetState)
150
151 /**
152 * A [Composable] function that hosts the current sheet content. This should be set as
153 * sheetContent of your [ModalBottomSheetLayout].
154 */
<lambda>null155 public val sheetContent: @Composable ColumnScope.() -> Unit = {
156 val saveableStateHolder = rememberSaveableStateHolder()
157 val transitionsInProgressEntries by transitionsInProgress.collectAsState()
158
159 // The latest back stack entry, retained until the sheet is completely hidden
160 // While the back stack is updated immediately, we might still be hiding the sheet, so
161 // we keep the entry around until the sheet is hidden
162 val retainedEntry by produceState<NavBackStackEntry?>(
163 initialValue = null,
164 key1 = backStack
165 ) {
166 backStack
167 .transform { backStackEntries ->
168 // Always hide the sheet when the back stack is updated
169 // Regardless of whether we're popping or pushing, we always want to hide
170 // the sheet first before deciding whether to re-show it or keep it hidden
171 try {
172 sheetState.hide()
173 } catch (_: CancellationException) {
174 // We catch but ignore possible cancellation exceptions as we don't want
175 // them to bubble up and cancel the whole produceState coroutine
176 } finally {
177 emit(backStackEntries.lastOrNull())
178 }
179 }
180 .collect {
181 value = it
182 }
183 }
184
185 if (retainedEntry != null) {
186 LaunchedEffect(retainedEntry) {
187 sheetState.show()
188 }
189
190 BackHandler {
191 state.popWithTransition(popUpTo = retainedEntry!!, saveState = false)
192 }
193 }
194
195 SheetContentHost(
196 backStackEntry = retainedEntry,
197 sheetState = sheetState,
198 saveableStateHolder = saveableStateHolder,
199 onSheetShown = {
200 transitionsInProgressEntries.forEach(state::markTransitionComplete)
201 },
202 onSheetDismissed = { backStackEntry ->
203 // Sheet dismissal can be started through popBackStack in which case we have a
204 // transition that we'll want to complete
205 if (transitionsInProgressEntries.contains(backStackEntry)) {
206 state.markTransitionComplete(backStackEntry)
207 }
208 // If there is no transition in progress, the sheet has been dimissed by the
209 // user (for example by tapping on the scrim or through an accessibility action)
210 // In this case, we will immediately pop without a transition as the sheet has
211 // already been hidden
212 else {
213 state.pop(popUpTo = backStackEntry, saveState = false)
214 }
215 }
216 )
217 }
218
onAttachnull219 override fun onAttach(state: NavigatorState) {
220 super.onAttach(state)
221 attached = true
222 }
223
createDestinationnull224 override fun createDestination(): Destination = Destination(
225 navigator = this,
226 content = {}
227 )
228
229 @SuppressLint("NewApi") // b/187418647
navigatenull230 override fun navigate(
231 entries: List<NavBackStackEntry>,
232 navOptions: NavOptions?,
233 navigatorExtras: Extras?
234 ) {
235 entries.forEach { entry ->
236 state.pushWithTransition(entry)
237 }
238 }
239
popBackStacknull240 override fun popBackStack(popUpTo: NavBackStackEntry, savedState: Boolean) {
241 state.popWithTransition(popUpTo, savedState)
242 }
243
244 /**
245 * [NavDestination] specific to [BottomSheetNavigator]
246 */
247 @NavDestination.ClassType(Composable::class)
248 public class Destination(
249 navigator: BottomSheetNavigator,
250 internal val content: @Composable ColumnScope.(NavBackStackEntry) -> Unit
251 ) : NavDestination(navigator), FloatingWindow
252 }
253