Skip to content

Commit 36f0bec

Browse files
authored
Make sure Snackbars are only displayed once (#1175)
* Make sure Snackbars are only displayed once * Use a queue instead * Fix docs * Add tests for `SnackbarDispatcher`.
1 parent 0b88dac commit 36f0bec

File tree

4 files changed

+148
-23
lines changed

4 files changed

+148
-23
lines changed

changelog.d/928.bugfix

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
Make sure Snackbars are only displayed once.

libraries/designsystem/build.gradle.kts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,5 +43,11 @@ android {
4343

4444
ksp(libs.showkase.processor)
4545
kspTest(libs.showkase.processor)
46+
47+
testImplementation(libs.test.junit)
48+
testImplementation(libs.coroutines.test)
49+
testImplementation(libs.molecule.runtime)
50+
testImplementation(libs.test.truth)
51+
testImplementation(libs.test.turbine)
4652
}
4753
}

libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/utils/Snackbar.kt

Lines changed: 50 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -34,33 +34,40 @@ import androidx.compose.ui.unit.dp
3434
import io.element.android.libraries.designsystem.components.button.ButtonVisuals
3535
import io.element.android.libraries.designsystem.theme.components.IconSource
3636
import io.element.android.libraries.designsystem.theme.components.Snackbar
37+
import kotlinx.coroutines.CancellationException
38+
import kotlinx.coroutines.currentCoroutineContext
3739
import kotlinx.coroutines.flow.Flow
38-
import kotlinx.coroutines.flow.MutableStateFlow
39-
import kotlinx.coroutines.flow.asStateFlow
40-
import kotlinx.coroutines.flow.update
40+
import kotlinx.coroutines.flow.flow
4141
import kotlinx.coroutines.isActive
42-
import kotlinx.coroutines.launch
4342
import kotlinx.coroutines.sync.Mutex
44-
import kotlinx.coroutines.sync.withLock
43+
import java.util.concurrent.atomic.AtomicBoolean
4544

4645
/**
4746
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
4847
*/
4948
class SnackbarDispatcher {
50-
private val mutex = Mutex()
51-
52-
private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null)
53-
val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
49+
private val queueMutex = Mutex()
50+
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
51+
val snackbarMessage: Flow<SnackbarMessage?> = flow {
52+
while (currentCoroutineContext().isActive) {
53+
queueMutex.lock()
54+
emit(snackBarMessageQueue.firstOrNull())
55+
}
56+
}
5457

5558
suspend fun post(message: SnackbarMessage) {
56-
mutex.withLock {
57-
_snackbarMessage.update { message }
59+
if (snackBarMessageQueue.isEmpty()) {
60+
snackBarMessageQueue.add(message)
61+
if (queueMutex.isLocked) queueMutex.unlock()
62+
} else {
63+
snackBarMessageQueue.add(message)
5864
}
5965
}
6066

61-
suspend fun clear() {
62-
mutex.withLock {
63-
_snackbarMessage.update { null }
67+
fun clear() {
68+
if (snackBarMessageQueue.isNotEmpty()) {
69+
snackBarMessageQueue.removeFirstOrNull()
70+
if (queueMutex.isLocked) queueMutex.unlock()
6471
}
6572
}
6673
}
@@ -87,31 +94,51 @@ fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) {
8794
}
8895
}
8996

97+
/**
98+
* Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations.
99+
*/
90100
@Composable
91101
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
92102
val snackbarHostState = remember { SnackbarHostState() }
93103
val snackbarMessageText = snackbarMessage?.let {
94104
stringResource(id = snackbarMessage.messageResId)
95-
}
105+
} ?: return snackbarHostState
106+
96107
val dispatcher = LocalSnackbarDispatcher.current
97-
LaunchedEffect(snackbarMessage) {
98-
if (snackbarMessageText == null) return@LaunchedEffect
99-
launch {
100-
snackbarHostState.showSnackbar(
101-
message = snackbarMessageText,
102-
duration = snackbarMessage.duration,
103-
)
104-
if (isActive) {
108+
LaunchedEffect(snackbarMessageText) {
109+
// If the message wasn't already displayed, do it now, and mark it as displayed
110+
// This will prevent the message from appearing in any other active SnackbarHosts
111+
if (snackbarMessage.isDisplayed.getAndSet(true) == false) {
112+
try {
113+
snackbarHostState.showSnackbar(
114+
message = snackbarMessageText,
115+
duration = snackbarMessage.duration,
116+
)
117+
// The snackbar item was displayed and dismissed, clear its message
118+
dispatcher.clear()
119+
} catch (e: CancellationException) {
120+
// The snackbar was being displayed when the coroutine was cancelled,
121+
// so we need to clear its message
105122
dispatcher.clear()
123+
throw e
106124
}
107125
}
108126
}
109127
return snackbarHostState
110128
}
111129

130+
/**
131+
* A message to be displayed in a [Snackbar].
132+
* @param messageResId The message to be displayed.
133+
* @param duration The duration of the message. The default value is [SnackbarDuration.Short].
134+
* @param actionResId The action text to be displayed. The default value is `null`.
135+
* @param isDisplayed Used to track if the current message is already displayed or not.
136+
* @param action The action to be performed when the action is clicked.
137+
*/
112138
data class SnackbarMessage(
113139
@StringRes val messageResId: Int,
114140
val duration: SnackbarDuration = SnackbarDuration.Short,
115141
@StringRes val actionResId: Int? = null,
142+
val isDisplayed: AtomicBoolean = AtomicBoolean(false),
116143
val action: () -> Unit = {},
117144
)
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,91 @@
1+
/*
2+
* Copyright (c) 2023 New Vector Ltd
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 io.element.android.libraries.designsystem.utils
18+
19+
import app.cash.turbine.test
20+
import com.google.common.truth.Truth.assertThat
21+
import kotlinx.coroutines.test.runTest
22+
import org.junit.Test
23+
24+
class SnackbarDispatcherTests {
25+
26+
@Test
27+
fun `given an empty queue the flow emits a null item`() = runTest {
28+
val snackbarDispatcher = SnackbarDispatcher()
29+
snackbarDispatcher.snackbarMessage.test {
30+
assertThat(awaitItem()).isNull()
31+
}
32+
}
33+
34+
@Test
35+
fun `given an empty queue calling clear does nothing`() = runTest {
36+
val snackbarDispatcher = SnackbarDispatcher()
37+
snackbarDispatcher.snackbarMessage.test {
38+
assertThat(awaitItem()).isNull()
39+
snackbarDispatcher.clear()
40+
expectNoEvents()
41+
}
42+
}
43+
44+
@Test
45+
fun `given a non-empty queue the flow emits an item`() = runTest {
46+
val snackbarDispatcher = SnackbarDispatcher()
47+
snackbarDispatcher.snackbarMessage.test {
48+
snackbarDispatcher.post(SnackbarMessage(0))
49+
val result = expectMostRecentItem()
50+
assertThat(result).isNotNull()
51+
}
52+
}
53+
54+
@Test
55+
fun `given a call to clear, the current message is cleared`() = runTest {
56+
val snackbarDispatcher = SnackbarDispatcher()
57+
snackbarDispatcher.snackbarMessage.test {
58+
snackbarDispatcher.post(SnackbarMessage(0))
59+
val item = expectMostRecentItem()
60+
assertThat(item).isNotNull()
61+
snackbarDispatcher.clear()
62+
assertThat(awaitItem()).isNull()
63+
}
64+
}
65+
66+
@Test
67+
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
68+
val snackbarDispatcher = SnackbarDispatcher()
69+
snackbarDispatcher.snackbarMessage.test {
70+
val messageA = SnackbarMessage(0)
71+
val messageB = SnackbarMessage(1)
72+
73+
// Send message A - it is the most recent item
74+
snackbarDispatcher.post(messageA)
75+
assertThat(expectMostRecentItem()).isEqualTo(messageA)
76+
77+
// Send message B - message A is still the most recent item
78+
snackbarDispatcher.post(messageB)
79+
expectNoEvents()
80+
81+
// Clear the last message - message B is now the most recent item
82+
snackbarDispatcher.clear()
83+
assertThat(expectMostRecentItem()).isEqualTo(messageB)
84+
85+
// Clear again - the queue is empty
86+
snackbarDispatcher.clear()
87+
assertThat(awaitItem()).isNull()
88+
}
89+
}
90+
91+
}

0 commit comments

Comments
 (0)