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`.
This commit is contained in:
Jorge Martin Espinosa
2023-08-30 10:43:23 +02:00
committed by GitHub
parent 45a653be03
commit 400315e39a
4 changed files with 150 additions and 25 deletions

1
changelog.d/928.bugfix Normal file
View File

@@ -0,0 +1 @@
Make sure Snackbars are only displayed once.

View File

@@ -43,5 +43,11 @@ android {
ksp(libs.showkase.processor)
kspTest(libs.showkase.processor)
testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
}
}

View File

@@ -34,33 +34,40 @@ import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.components.button.ButtonVisuals
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Snackbar
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.currentCoroutineContext
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import java.util.concurrent.atomic.AtomicBoolean
/**
* A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
*/
class SnackbarDispatcher {
private val mutex = Mutex()
private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null)
val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow()
suspend fun post(message: SnackbarMessage) {
mutex.withLock {
_snackbarMessage.update { message }
private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
val snackbarMessage: Flow<SnackbarMessage?> = flow {
while (currentCoroutineContext().isActive) {
queueMutex.lock()
emit(snackBarMessageQueue.firstOrNull())
}
}
suspend fun clear() {
mutex.withLock {
_snackbarMessage.update { null }
suspend fun post(message: SnackbarMessage) {
if (snackBarMessageQueue.isEmpty()) {
snackBarMessageQueue.add(message)
if (queueMutex.isLocked) queueMutex.unlock()
} else {
snackBarMessageQueue.add(message)
}
}
fun clear() {
if (snackBarMessageQueue.isNotEmpty()) {
snackBarMessageQueue.removeFirstOrNull()
if (queueMutex.isLocked) queueMutex.unlock()
}
}
}
@@ -87,31 +94,51 @@ fun SnackbarHost(hostState: SnackbarHostState, modifier: Modifier = Modifier) {
}
}
/**
* Helper method to display a [SnackbarMessage] in a [SnackbarHostState] handling cancellations.
*/
@Composable
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId)
}
} ?: return snackbarHostState
val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) {
if (snackbarMessageText == null) return@LaunchedEffect
launch {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
if (isActive) {
LaunchedEffect(snackbarMessageText) {
// If the message wasn't already displayed, do it now, and mark it as displayed
// This will prevent the message from appearing in any other active SnackbarHosts
if (snackbarMessage.isDisplayed.getAndSet(true) == false) {
try {
snackbarHostState.showSnackbar(
message = snackbarMessageText,
duration = snackbarMessage.duration,
)
// The snackbar item was displayed and dismissed, clear its message
dispatcher.clear()
} catch (e: CancellationException) {
// The snackbar was being displayed when the coroutine was cancelled,
// so we need to clear its message
dispatcher.clear()
throw e
}
}
}
return snackbarHostState
}
/**
* A message to be displayed in a [Snackbar].
* @param messageResId The message to be displayed.
* @param duration The duration of the message. The default value is [SnackbarDuration.Short].
* @param actionResId The action text to be displayed. The default value is `null`.
* @param isDisplayed Used to track if the current message is already displayed or not.
* @param action The action to be performed when the action is clicked.
*/
data class SnackbarMessage(
@StringRes val messageResId: Int,
val duration: SnackbarDuration = SnackbarDuration.Short,
@StringRes val actionResId: Int? = null,
val isDisplayed: AtomicBoolean = AtomicBoolean(false),
val action: () -> Unit = {},
)

View File

@@ -0,0 +1,91 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* 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
*
* http://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 io.element.android.libraries.designsystem.utils
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.test.runTest
import org.junit.Test
class SnackbarDispatcherTests {
@Test
fun `given an empty queue the flow emits a null item`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isNull()
}
}
@Test
fun `given an empty queue calling clear does nothing`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
assertThat(awaitItem()).isNull()
snackbarDispatcher.clear()
expectNoEvents()
}
}
@Test
fun `given a non-empty queue the flow emits an item`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
snackbarDispatcher.post(SnackbarMessage(0))
val result = expectMostRecentItem()
assertThat(result).isNotNull()
}
}
@Test
fun `given a call to clear, the current message is cleared`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
snackbarDispatcher.post(SnackbarMessage(0))
val item = expectMostRecentItem()
assertThat(item).isNotNull()
snackbarDispatcher.clear()
assertThat(awaitItem()).isNull()
}
}
@Test
fun `given 2 message emissions, the next message is displayed only after a call to clear`() = runTest {
val snackbarDispatcher = SnackbarDispatcher()
snackbarDispatcher.snackbarMessage.test {
val messageA = SnackbarMessage(0)
val messageB = SnackbarMessage(1)
// Send message A - it is the most recent item
snackbarDispatcher.post(messageA)
assertThat(expectMostRecentItem()).isEqualTo(messageA)
// Send message B - message A is still the most recent item
snackbarDispatcher.post(messageB)
expectNoEvents()
// Clear the last message - message B is now the most recent item
snackbarDispatcher.clear()
assertThat(expectMostRecentItem()).isEqualTo(messageB)
// Clear again - the queue is empty
snackbarDispatcher.clear()
assertThat(awaitItem()).isNull()
}
}
}