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) ksp(libs.showkase.processor)
kspTest(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.components.button.ButtonVisuals
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Snackbar 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.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex 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]. * A global dispatcher of [SnackbarMessage] to be displayed in [Snackbar] via a [SnackbarHostState].
*/ */
class SnackbarDispatcher { class SnackbarDispatcher {
private val mutex = Mutex() private val queueMutex = Mutex()
private val snackBarMessageQueue = ArrayDeque<SnackbarMessage>()
private val _snackbarMessage = MutableStateFlow<SnackbarMessage?>(null) val snackbarMessage: Flow<SnackbarMessage?> = flow {
val snackbarMessage: Flow<SnackbarMessage?> = _snackbarMessage.asStateFlow() while (currentCoroutineContext().isActive) {
queueMutex.lock()
suspend fun post(message: SnackbarMessage) { emit(snackBarMessageQueue.firstOrNull())
mutex.withLock {
_snackbarMessage.update { message }
} }
} }
suspend fun clear() { suspend fun post(message: SnackbarMessage) {
mutex.withLock { if (snackBarMessageQueue.isEmpty()) {
_snackbarMessage.update { null } 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 @Composable
fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState { fun rememberSnackbarHostState(snackbarMessage: SnackbarMessage?): SnackbarHostState {
val snackbarHostState = remember { SnackbarHostState() } val snackbarHostState = remember { SnackbarHostState() }
val snackbarMessageText = snackbarMessage?.let { val snackbarMessageText = snackbarMessage?.let {
stringResource(id = snackbarMessage.messageResId) stringResource(id = snackbarMessage.messageResId)
} } ?: return snackbarHostState
val dispatcher = LocalSnackbarDispatcher.current val dispatcher = LocalSnackbarDispatcher.current
LaunchedEffect(snackbarMessage) { LaunchedEffect(snackbarMessageText) {
if (snackbarMessageText == null) return@LaunchedEffect // If the message wasn't already displayed, do it now, and mark it as displayed
launch { // This will prevent the message from appearing in any other active SnackbarHosts
snackbarHostState.showSnackbar( if (snackbarMessage.isDisplayed.getAndSet(true) == false) {
message = snackbarMessageText, try {
duration = snackbarMessage.duration, snackbarHostState.showSnackbar(
) message = snackbarMessageText,
if (isActive) { duration = snackbarMessage.duration,
)
// The snackbar item was displayed and dismissed, clear its message
dispatcher.clear() 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 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( data class SnackbarMessage(
@StringRes val messageResId: Int, @StringRes val messageResId: Int,
val duration: SnackbarDuration = SnackbarDuration.Short, val duration: SnackbarDuration = SnackbarDuration.Short,
@StringRes val actionResId: Int? = null, @StringRes val actionResId: Int? = null,
val isDisplayed: AtomicBoolean = AtomicBoolean(false),
val action: () -> Unit = {}, 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()
}
}
}