1 /*
<lambda>null2 * Copyright 2020 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 * http://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 androidx.compose.material
18
19 import androidx.compose.animation.core.Animatable
20 import androidx.compose.animation.core.AnimationSpec
21 import androidx.compose.animation.core.FastOutSlowInEasing
22 import androidx.compose.animation.core.LinearEasing
23 import androidx.compose.animation.core.tween
24 import androidx.compose.foundation.layout.Box
25 import androidx.compose.runtime.Composable
26 import androidx.compose.runtime.LaunchedEffect
27 import androidx.compose.runtime.RecomposeScope
28 import androidx.compose.runtime.Stable
29 import androidx.compose.runtime.State
30 import androidx.compose.runtime.currentRecomposeScope
31 import androidx.compose.runtime.getValue
32 import androidx.compose.runtime.key
33 import androidx.compose.runtime.mutableStateOf
34 import androidx.compose.runtime.remember
35 import androidx.compose.runtime.setValue
36 import androidx.compose.ui.Modifier
37 import androidx.compose.ui.graphics.graphicsLayer
38 import androidx.compose.ui.platform.AccessibilityManager
39 import androidx.compose.ui.platform.LocalAccessibilityManager
40 import androidx.compose.ui.semantics.LiveRegionMode
41 import androidx.compose.ui.semantics.dismiss
42 import androidx.compose.ui.semantics.liveRegion
43 import androidx.compose.ui.semantics.paneTitle
44 import androidx.compose.ui.semantics.semantics
45 import androidx.compose.ui.util.fastFilterNotNull
46 import androidx.compose.ui.util.fastForEach
47 import androidx.compose.ui.util.fastMap
48 import androidx.compose.ui.util.fastMapTo
49 import kotlin.coroutines.resume
50 import kotlinx.coroutines.CancellableContinuation
51 import kotlinx.coroutines.delay
52 import kotlinx.coroutines.suspendCancellableCoroutine
53 import kotlinx.coroutines.sync.Mutex
54 import kotlinx.coroutines.sync.withLock
55
56 /**
57 * State of the [SnackbarHost], controls the queue and the current [Snackbar] being shown inside the
58 * [SnackbarHost].
59 *
60 * This state usually lives as a part of a [ScaffoldState] and provided to the [SnackbarHost]
61 * automatically, but can be decoupled from it and live separately when desired.
62 */
63 @Stable
64 class SnackbarHostState {
65
66 /**
67 * Only one [Snackbar] can be shown at a time. Since a suspending Mutex is a fair queue, this
68 * manages our message queue and we don't have to maintain one.
69 */
70 private val mutex = Mutex()
71
72 /** The current [SnackbarData] being shown by the [SnackbarHost], of `null` if none. */
73 var currentSnackbarData by mutableStateOf<SnackbarData?>(null)
74 private set
75
76 /**
77 * Shows or queues to be shown a [Snackbar] at the bottom of the [Scaffold] at which this state
78 * is attached and suspends until snackbar is disappeared.
79 *
80 * [SnackbarHostState] guarantees to show at most one snackbar at a time. If this function is
81 * called while another snackbar is already visible, it will be suspended until this snack bar
82 * is shown and subsequently addressed. If the caller is cancelled, the snackbar will be removed
83 * from display and/or the queue to be displayed.
84 *
85 * All of this allows for granular control over the snackbar queue from within:
86 *
87 * @sample androidx.compose.material.samples.ScaffoldWithCoroutinesSnackbar
88 *
89 * To change the Snackbar appearance, change it in 'snackbarHost' on the [Scaffold].
90 *
91 * @param message text to be shown in the Snackbar
92 * @param actionLabel optional action label to show as button in the Snackbar
93 * @param duration duration to control how long snackbar will be shown in [SnackbarHost], either
94 * [SnackbarDuration.Short], [SnackbarDuration.Long] or [SnackbarDuration.Indefinite]
95 * @return [SnackbarResult.ActionPerformed] if option action has been clicked or
96 * [SnackbarResult.Dismissed] if snackbar has been dismissed via timeout or by the user
97 */
98 suspend fun showSnackbar(
99 message: String,
100 actionLabel: String? = null,
101 duration: SnackbarDuration = SnackbarDuration.Short
102 ): SnackbarResult =
103 mutex.withLock {
104 try {
105 return suspendCancellableCoroutine { continuation ->
106 currentSnackbarData =
107 SnackbarDataImpl(message, actionLabel, duration, continuation)
108 }
109 } finally {
110 currentSnackbarData = null
111 }
112 }
113
114 @Stable
115 private class SnackbarDataImpl(
116 override val message: String,
117 override val actionLabel: String?,
118 override val duration: SnackbarDuration,
119 private val continuation: CancellableContinuation<SnackbarResult>
120 ) : SnackbarData {
121
122 override fun performAction() {
123 if (continuation.isActive) continuation.resume(SnackbarResult.ActionPerformed)
124 }
125
126 override fun dismiss() {
127 if (continuation.isActive) continuation.resume(SnackbarResult.Dismissed)
128 }
129 }
130 }
131
132 /**
133 * Host for [Snackbar]s to be used in [Scaffold] to properly show, hide and dismiss items based on
134 * material specification and the [hostState].
135 *
136 * This component with default parameters comes build-in with [Scaffold], if you need to show a
137 * default [Snackbar], use use [ScaffoldState.snackbarHostState] and
138 * [SnackbarHostState.showSnackbar].
139 *
140 * @sample androidx.compose.material.samples.ScaffoldWithSimpleSnackbar
141 *
142 * If you want to customize appearance of the [Snackbar], you can pass your own version as a child
143 * of the [SnackbarHost] to the [Scaffold]:
144 *
145 * @sample androidx.compose.material.samples.ScaffoldWithCustomSnackbar
146 * @param hostState state of this component to read and show [Snackbar]s accordingly
147 * @param modifier optional modifier for this component
148 * @param snackbar the instance of the [Snackbar] to be shown at the appropriate time with
149 * appearance based on the [SnackbarData] provided as a param
150 */
151 @Composable
SnackbarHostnull152 fun SnackbarHost(
153 hostState: SnackbarHostState,
154 modifier: Modifier = Modifier,
155 snackbar: @Composable (SnackbarData) -> Unit = { Snackbar(it) }
156 ) {
157 val currentSnackbarData = hostState.currentSnackbarData
158 val accessibilityManager = LocalAccessibilityManager.current
<lambda>null159 LaunchedEffect(currentSnackbarData) {
160 if (currentSnackbarData != null) {
161 val duration =
162 currentSnackbarData.duration.toMillis(
163 currentSnackbarData.actionLabel != null,
164 accessibilityManager
165 )
166 delay(duration)
167 currentSnackbarData.dismiss()
168 }
169 }
170 FadeInFadeOutWithScale(
171 current = hostState.currentSnackbarData,
172 modifier = modifier,
173 content = snackbar
174 )
175 }
176
177 /**
178 * Interface to represent one particular [Snackbar] as a piece of the [SnackbarHostState]
179 *
180 * @property message text to be shown in the [Snackbar]
181 * @property actionLabel optional action label to show as button in the Snackbar
182 * @property duration duration of the snackbar
183 */
184 interface SnackbarData {
185 val message: String
186 val actionLabel: String?
187 val duration: SnackbarDuration
188
189 /** Function to be called when Snackbar action has been performed to notify the listeners */
performActionnull190 fun performAction()
191
192 /** Function to be called when Snackbar is dismissed either by timeout or by the user */
193 fun dismiss()
194 }
195
196 /** Possible results of the [SnackbarHostState.showSnackbar] call */
197 enum class SnackbarResult {
198 /** [Snackbar] that is shown has been dismissed either by timeout of by user */
199 Dismissed,
200
201 /** Action on the [Snackbar] has been clicked before the time out passed */
202 ActionPerformed,
203 }
204
205 /** Possible durations of the [Snackbar] in [SnackbarHost] */
206 enum class SnackbarDuration {
207 /** Show the Snackbar for a short period of time */
208 Short,
209
210 /** Show the Snackbar for a long period of time */
211 Long,
212
213 /** Show the Snackbar indefinitely until explicitly dismissed or action is clicked */
214 Indefinite
215 }
216
217 // TODO: magic numbers adjustment
toMillisnull218 internal fun SnackbarDuration.toMillis(
219 hasAction: Boolean,
220 accessibilityManager: AccessibilityManager?
221 ): Long {
222 val original =
223 when (this) {
224 SnackbarDuration.Indefinite -> Long.MAX_VALUE
225 SnackbarDuration.Long -> 10000L
226 SnackbarDuration.Short -> 4000L
227 }
228 if (accessibilityManager == null) {
229 return original
230 }
231 return accessibilityManager.calculateRecommendedTimeoutMillis(
232 original,
233 containsIcons = true,
234 containsText = true,
235 containsControls = hasAction
236 )
237 }
238
239 // TODO: to be replaced with the public customizable implementation
240 // it's basically tweaked nullable version of Crossfade
241 @Composable
FadeInFadeOutWithScalenull242 private fun FadeInFadeOutWithScale(
243 current: SnackbarData?,
244 modifier: Modifier = Modifier,
245 content: @Composable (SnackbarData) -> Unit
246 ) {
247 val state = remember { FadeInFadeOutState<SnackbarData?>() }
248 val a11yPaneTitle = getString(Strings.SnackbarPaneTitle)
249 if (current != state.current) {
250 state.current = current
251 val keys = state.items.fastMap { it.key }.toMutableList()
252 if (!keys.contains(current)) {
253 keys.add(current)
254 }
255 state.items.clear()
256 keys.fastFilterNotNull().fastMapTo(state.items) { key ->
257 FadeInFadeOutAnimationItem(key) { children ->
258 val isVisible = key == current
259 val duration = if (isVisible) SnackbarFadeInMillis else SnackbarFadeOutMillis
260 val delay = SnackbarFadeOutMillis + SnackbarInBetweenDelayMillis
261 val animationDelay =
262 if (isVisible && keys.fastFilterNotNull().size != 1) {
263 delay
264 } else {
265 0
266 }
267 val opacity =
268 animatedOpacity(
269 animation =
270 tween(
271 easing = LinearEasing,
272 delayMillis = animationDelay,
273 durationMillis = duration
274 ),
275 visible = isVisible,
276 onAnimationFinish = {
277 if (key != state.current) {
278 // leave only the current in the list
279 state.items.removeAll { it.key == key }
280 state.scope?.invalidate()
281 }
282 }
283 )
284 val scale =
285 animatedScale(
286 animation =
287 tween(
288 easing = FastOutSlowInEasing,
289 delayMillis = animationDelay,
290 durationMillis = duration
291 ),
292 visible = isVisible
293 )
294 Box(
295 Modifier.graphicsLayer(
296 scaleX = scale.value,
297 scaleY = scale.value,
298 alpha = opacity.value
299 )
300 .semantics {
301 if (isVisible) {
302 liveRegion = LiveRegionMode.Polite
303 }
304 paneTitle = a11yPaneTitle
305 dismiss {
306 key.dismiss()
307 true
308 }
309 }
310 ) {
311 children()
312 }
313 }
314 }
315 }
316 Box(modifier) {
317 state.scope = currentRecomposeScope
318 state.items.fastForEach { (item, opacity) -> key(item) { opacity { content(item!!) } } }
319 }
320 }
321
322 private class FadeInFadeOutState<T> {
323 // we use Any here as something which will not be equals to the real initial value
324 var current: Any? = Any()
325 var items = mutableListOf<FadeInFadeOutAnimationItem<T>>()
326 var scope: RecomposeScope? = null
327 }
328
329 private data class FadeInFadeOutAnimationItem<T>(
330 val key: T,
331 val transition: FadeInFadeOutTransition
332 )
333
334 private typealias FadeInFadeOutTransition = @Composable (content: @Composable () -> Unit) -> Unit
335
336 @Composable
animatedOpacitynull337 private fun animatedOpacity(
338 animation: AnimationSpec<Float>,
339 visible: Boolean,
340 onAnimationFinish: () -> Unit = {}
341 ): State<Float> {
<lambda>null342 val alpha = remember { Animatable(if (!visible) 1f else 0f) }
<lambda>null343 LaunchedEffect(visible) {
344 alpha.animateTo(if (visible) 1f else 0f, animationSpec = animation)
345 onAnimationFinish()
346 }
347 return alpha.asState()
348 }
349
350 @Composable
animatedScalenull351 private fun animatedScale(animation: AnimationSpec<Float>, visible: Boolean): State<Float> {
352 val scale = remember { Animatable(if (!visible) 1f else 0.8f) }
353 LaunchedEffect(visible) {
354 scale.animateTo(if (visible) 1f else 0.8f, animationSpec = animation)
355 }
356 return scale.asState()
357 }
358
359 private const val SnackbarFadeInMillis = 150
360 private const val SnackbarFadeOutMillis = 75
361 private const val SnackbarInBetweenDelayMillis = 0
362