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