mirror of
https://github.com/signalapp/Signal-Android.git
synced 2026-04-20 08:39:22 +01:00
Add nicer snackbar propagation.
This commit is contained in:
committed by
jeffrey-signal
parent
a3e8ca8d33
commit
d76eb9a9e4
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.snackbars
|
||||
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import androidx.fragment.app.Fragment
|
||||
import com.google.android.material.snackbar.Snackbar
|
||||
|
||||
fun Fragment.makeSnackbar(state: SnackbarState) {
|
||||
if (view == null) {
|
||||
return
|
||||
}
|
||||
|
||||
val snackbar = Snackbar.make(
|
||||
requireView(),
|
||||
state.message,
|
||||
when (state.duration) {
|
||||
SnackbarDuration.Short -> Snackbar.LENGTH_SHORT
|
||||
SnackbarDuration.Long -> Snackbar.LENGTH_LONG
|
||||
SnackbarDuration.Indefinite -> Snackbar.LENGTH_INDEFINITE
|
||||
}
|
||||
)
|
||||
|
||||
state.actionState?.let { actionState ->
|
||||
snackbar.setAction(actionState.action) { actionState.onActionClick() }
|
||||
snackbar.setActionTextColor(requireContext().getColor(actionState.color))
|
||||
}
|
||||
|
||||
snackbar.show()
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.snackbars
|
||||
|
||||
/**
|
||||
* Marker interface for identifying snackbar host locations.
|
||||
*
|
||||
* Implement this interface to define distinct snackbar display locations within the app.
|
||||
* When a [SnackbarState] is emitted, its [SnackbarState.hostKey] is used to route the
|
||||
* snackbar to the appropriate registered consumer.
|
||||
*/
|
||||
interface SnackbarHostKey {
|
||||
object Global : SnackbarHostKey
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.snackbars
|
||||
|
||||
import androidx.annotation.ColorRes
|
||||
import androidx.compose.material3.SnackbarDuration
|
||||
import org.thoughtcrime.securesms.R
|
||||
|
||||
/**
|
||||
* Represents the state of a snackbar to be displayed.
|
||||
*
|
||||
* @property message The text message to display in the snackbar.
|
||||
* @property actionState Optional action button configuration.
|
||||
* @property showProgress Whether to show a progress indicator in the snackbar.
|
||||
* @property duration How long the snackbar should be displayed.
|
||||
* @property hostKey The target host where this snackbar should be displayed.
|
||||
* @property fallbackKey Optional host to fallback upon if the host key is not registered. Defaults to the Global key.
|
||||
*/
|
||||
data class SnackbarState(
|
||||
val message: String,
|
||||
val actionState: ActionState? = null,
|
||||
val showProgress: Boolean = false,
|
||||
val duration: SnackbarDuration = SnackbarDuration.Long,
|
||||
val hostKey: SnackbarHostKey,
|
||||
val fallbackKey: SnackbarHostKey? = SnackbarHostKey.Global
|
||||
) {
|
||||
/**
|
||||
* Configuration for a snackbar action button.
|
||||
*
|
||||
* @property action The text label for the action button.
|
||||
* @property color The color resource for the action text.
|
||||
* @property onActionClick Callback invoked when the action is clicked.
|
||||
*/
|
||||
data class ActionState(
|
||||
val action: String,
|
||||
@ColorRes val color: Int = R.color.core_white,
|
||||
val onActionClick: () -> Unit
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.snackbars
|
||||
|
||||
/**
|
||||
* A consumer that can display snackbar messages.
|
||||
*
|
||||
* Implementations are typically UI components that host a snackbar display area.
|
||||
*/
|
||||
fun interface SnackbarStateConsumer {
|
||||
/**
|
||||
* Consumes the given snackbar state.
|
||||
*
|
||||
* @param snackbarState The snackbar to display.
|
||||
*/
|
||||
fun consume(snackbarState: SnackbarState)
|
||||
}
|
||||
@@ -0,0 +1,165 @@
|
||||
/*
|
||||
* Copyright 2025 Signal Messenger, LLC
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
*/
|
||||
|
||||
package org.thoughtcrime.securesms.components.snackbars
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.State
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.core.util.Consumer
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.Lifecycle
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import androidx.lifecycle.ViewModel
|
||||
import org.thoughtcrime.securesms.main.MainSnackbarHostKey
|
||||
import java.io.Closeable
|
||||
|
||||
/**
|
||||
* CompositionLocal providing access to the [SnackbarStateConsumerRegistry].
|
||||
*/
|
||||
val LocalSnackbarStateConsumerRegistry = staticCompositionLocalOf<SnackbarStateConsumerRegistry> {
|
||||
error("No SnackbarStateConsumerRegistry provided")
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberSnackbarState(
|
||||
key: SnackbarHostKey
|
||||
): State<SnackbarState?> {
|
||||
val state: MutableState<SnackbarState?> = remember(key) { mutableStateOf(null) }
|
||||
|
||||
val registry = LocalSnackbarStateConsumerRegistry.current
|
||||
DisposableEffect(registry, key) {
|
||||
val registration = registry.register(MainSnackbarHostKey.MainChrome) {
|
||||
state.value = it
|
||||
}
|
||||
|
||||
onDispose {
|
||||
registration.close()
|
||||
}
|
||||
}
|
||||
|
||||
return state
|
||||
}
|
||||
|
||||
/**
|
||||
* Registry for managing snackbar consumers tied to lifecycle-aware components.
|
||||
*
|
||||
* Consumers are automatically enabled when their lifecycle resumes, disabled when paused,
|
||||
* and removed when destroyed.
|
||||
*/
|
||||
class SnackbarStateConsumerRegistry : ViewModel() {
|
||||
|
||||
private val entries = mutableSetOf<Entry>()
|
||||
|
||||
/**
|
||||
* Registers a snackbar consumer for the given host and returns a [Closeable] to unregister it.
|
||||
*
|
||||
* The consumer starts enabled immediately. Call [Closeable.close] to unregister.
|
||||
* This is useful for Compose components using DisposableEffect.
|
||||
*
|
||||
* If a consumer is already registered for the given host, it will be replaced.
|
||||
*
|
||||
* @param host The host key identifying this consumer's display location.
|
||||
* @param consumer The consumer that will handle snackbar display.
|
||||
* @return A [Closeable] that unregisters the consumer when closed.
|
||||
*/
|
||||
fun register(host: SnackbarHostKey, consumer: Consumer<SnackbarState>): Closeable {
|
||||
entries.removeAll { it.host == host }
|
||||
|
||||
val entry = Entry(
|
||||
host = host,
|
||||
consumer = consumer,
|
||||
enabled = true
|
||||
)
|
||||
entries.add(entry)
|
||||
|
||||
return Closeable { entries.remove(entry) }
|
||||
}
|
||||
|
||||
/**
|
||||
* Registers a snackbar consumer for the given host, bound to a lifecycle.
|
||||
*
|
||||
* The consumer will be automatically managed based on the lifecycle:
|
||||
* - Enabled when the lifecycle is in RESUMED state
|
||||
* - Disabled when paused
|
||||
* - Removed when destroyed
|
||||
*
|
||||
* If a consumer is already registered for the given host, it will be replaced.
|
||||
*
|
||||
* @param host The host key identifying this consumer's display location.
|
||||
* @param lifecycleOwner The lifecycle owner to bind the consumer to.
|
||||
* @param consumer The consumer that will handle snackbar display.
|
||||
* @throws IllegalStateException if the lifecycle is not at least CREATED.
|
||||
*/
|
||||
fun register(host: SnackbarHostKey, lifecycleOwner: LifecycleOwner, consumer: Consumer<SnackbarState>) {
|
||||
val currentState = lifecycleOwner.lifecycle.currentState
|
||||
check(currentState.isAtLeast(Lifecycle.State.CREATED)) {
|
||||
"Cannot register a consumer with a lifecycle in state $currentState"
|
||||
}
|
||||
|
||||
val closeable = register(host, consumer)
|
||||
val entry = entries.find { it.host == host }!!
|
||||
entry.enabled = currentState.isAtLeast(Lifecycle.State.RESUMED)
|
||||
|
||||
lifecycleOwner.lifecycle.addObserver(EntryLifecycleObserver(entry, closeable, lifecycleOwner))
|
||||
}
|
||||
|
||||
/**
|
||||
* Emits a snackbar state to be consumed by a registered consumer.
|
||||
*
|
||||
* The snackbar is first offered to the consumer registered for the matching [SnackbarState.hostKey].
|
||||
* If no matching consumer is enabled, the [SnackbarState.fallbackKey] is tried next (if present).
|
||||
* Finally, the snackbar is offered to the first enabled registered consumer.
|
||||
*
|
||||
* @param snackbarState The snackbar state to emit.
|
||||
*/
|
||||
fun emit(snackbarState: SnackbarState) {
|
||||
val matchingEntry = entries.find { it.host == snackbarState.hostKey && it.enabled }
|
||||
if (matchingEntry != null) {
|
||||
matchingEntry.consumer.accept(snackbarState)
|
||||
return
|
||||
}
|
||||
|
||||
val fallbackEntry = snackbarState.fallbackKey?.let { fallback ->
|
||||
entries.find { it.host == fallback && it.enabled }
|
||||
}
|
||||
if (fallbackEntry != null) {
|
||||
fallbackEntry.consumer.accept(snackbarState)
|
||||
return
|
||||
}
|
||||
|
||||
val firstEnabled = entries.find { it.enabled }
|
||||
firstEnabled?.consumer?.accept(snackbarState)
|
||||
}
|
||||
|
||||
private class EntryLifecycleObserver(
|
||||
private val entry: Entry,
|
||||
private val closeable: Closeable,
|
||||
private val lifecycleOwner: LifecycleOwner
|
||||
) : DefaultLifecycleObserver {
|
||||
override fun onResume(owner: LifecycleOwner) {
|
||||
entry.enabled = true
|
||||
}
|
||||
|
||||
override fun onPause(owner: LifecycleOwner) {
|
||||
entry.enabled = false
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
closeable.close()
|
||||
lifecycleOwner.lifecycle.removeObserver(this)
|
||||
}
|
||||
}
|
||||
|
||||
private data class Entry(
|
||||
val host: SnackbarHostKey,
|
||||
val consumer: Consumer<SnackbarState>,
|
||||
var enabled: Boolean
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user