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:
parent
45a653be03
commit
400315e39a
4 changed files with 150 additions and 25 deletions
1
changelog.d/928.bugfix
Normal file
1
changelog.d/928.bugfix
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
Make sure Snackbars are only displayed once.
|
||||||
|
|
@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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 = {},
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
Loading…
Add table
Add a link
Reference in a new issue