Skip to content

Instantly share code, notes, and snippets.

@kafri8889
Last active December 31, 2023 12:56
Show Gist options
  • Save kafri8889/0b2a644695964320e3406dfb05f5969c to your computer and use it in GitHub Desktop.
Save kafri8889/0b2a644695964320e3406dfb05f5969c to your computer and use it in GitHub Desktop.
BubbleNotification, similar to Snackbar, is on top with enter/exit slide animation
@Composable
fun BubbleNotificationHost(
hostState: BubbleNotificationHostState,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<IntOffset> = tween(300),
bubbleNotification: @Composable (BubbleNotificationData) -> Unit = { BubbleNotification(it) },
content: @Composable () -> Unit
) {
val currentBubbleNotificationData = hostState.currentBubbleNotificationData
val accessibilityManager = LocalAccessibilityManager.current
LaunchedEffect(currentBubbleNotificationData) {
if (currentBubbleNotificationData != null) {
val duration = currentBubbleNotificationData.visuals.duration.toMillis(
hasAction = currentBubbleNotificationData.visuals.actionLabel != null,
accessibilityManager = accessibilityManager
)
delay(duration)
currentBubbleNotificationData.dismiss()
}
}
Box {
SlideInSlideOut(
current = hostState.currentBubbleNotificationData,
animationSpec = animationSpec,
content = bubbleNotification,
modifier = modifier
.zIndex(1f)
)
content()
}
}
@Composable
private fun SlideInSlideOut(
current: BubbleNotificationData?,
modifier: Modifier = Modifier,
animationSpec: FiniteAnimationSpec<IntOffset>,
content: @Composable (BubbleNotificationData) -> Unit
) {
val state = remember { SlideInSlideOutState<BubbleNotificationData?>() }
if (current != state.current) {
state.current = current
val keys = state.items.map { it.key }.toMutableList()
if (!keys.contains(current)) {
keys.add(current)
}
state.items.clear()
keys.filterNotNull().mapTo(state.items) { key ->
SlideInSlideOutAnimationItem(key) { children ->
val isVisible = key == current
val duration = if (isVisible) BubbleNotificationFadeInMillis else BubbleNotificationFadeOutMillis
val delay = BubbleNotificationFadeOutMillis + BubbleNotificationInBetweenDelayMillis
val animationDelay = if (isVisible && keys.filterNotNull().size != 1) delay else 0
val opacity = animatedOpacity(
animation = tween(
easing = LinearEasing,
delayMillis = animationDelay,
durationMillis = duration
),
visible = isVisible,
onAnimationFinish = {
}
)
LaunchedEffect(opacity) {
if (key != state.current && opacity.value < 0.1f) {
// leave only the current in the list
state.items.removeAll { it.key == key }
state.scope?.invalidate()
}
}
AnimatedVisibility(
visible = opacity.value > 0.1,
enter = slideInVertically(
animationSpec = animationSpec
),
exit = slideOutVertically(
animationSpec = animationSpec
)
) {
Box(
Modifier
.semantics {
liveRegion = LiveRegionMode.Polite
dismiss { key.dismiss(); true }
}
) {
children()
}
}
}
}
}
Box(modifier) {
state.scope = currentRecomposeScope
state.items.forEach { (item, opacity) ->
key(item) {
opacity {
content(item!!)
}
}
}
}
}
@Composable
private fun animatedOpacity(
animation: AnimationSpec<Float>,
visible: Boolean,
onAnimationFinish: () -> Unit = {}
): State<Float> {
val alpha = remember { Animatable(if (!visible) 1f else 0f) }
LaunchedEffect(visible) {
alpha.animateTo(
if (visible) 1f else 0f,
animationSpec = animation
)
onAnimationFinish()
}
return alpha.asState()
}
private class SlideInSlideOutState<T> {
// we use Any here as something which will not be equals to the real initial value
var current: Any? = Any()
var items = mutableListOf<SlideInSlideOutAnimationItem<T>>()
var scope: RecomposeScope? = null
}
private data class SlideInSlideOutAnimationItem<T>(
val key: T,
val transition: SlideInSlideOutTransition
)
private typealias SlideInSlideOutTransition = @Composable (content: @Composable () -> Unit) -> Unit
@Composable
fun BubbleNotification(
modifier: Modifier = Modifier,
shape: Shape = BubbleNotificationDefaults.shape,
containerColor: Color = BubbleNotificationDefaults.color,
contentColor: Color = BubbleNotificationDefaults.contentColor,
actionContentColor: Color = BubbleNotificationDefaults.actionContentColor,
dismissActionContentColor: Color = BubbleNotificationDefaults.dismissActionContentColor,
dismissAction: @Composable (() -> Unit)? = null,
action: @Composable (() -> Unit)? = null,
text: @Composable () -> Unit
) {
Surface(
shape = shape,
color = containerColor,
contentColor = contentColor,
shadowElevation = BubbleNotificationTokens.ContainerElevation,
modifier = Modifier
.padding(OuterPadding)
.then(modifier),
) {
val textStyle = BubbleNotificationTokens.SupportingTextFont
val actionTextStyle = BubbleNotificationTokens.ActionLabelTextFont
CompositionLocalProvider(LocalTextStyle provides textStyle) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.widthIn(max = ContainerMaxWidth)
.heightIn(max = ContainerMaxHeight)
.padding(
if (dismissAction != null) InnerPaddingWithAction
else InnerPadding
)
) {
Box(modifier = Modifier.weight(1f)) {
text()
}
if (action != null) {
CompositionLocalProvider(
LocalTextStyle provides actionTextStyle,
LocalContentColor provides actionContentColor
) {
action()
}
}
if (dismissAction != null) {
CompositionLocalProvider(
LocalContentColor provides dismissActionContentColor
) {
dismissAction()
}
}
}
}
}
}
@Composable
fun BubbleNotification(
bubbleNotificationData: BubbleNotificationData,
modifier: Modifier = Modifier,
shape: Shape = BubbleNotificationDefaults.shape,
containerColor: Color = BubbleNotificationDefaults.color,
contentColor: Color = BubbleNotificationDefaults.contentColor,
actionColor: Color = BubbleNotificationDefaults.actionColor,
actionContentColor: Color = BubbleNotificationDefaults.actionContentColor,
dismissActionContentColor: Color = BubbleNotificationDefaults.dismissActionContentColor
) {
val actionComposable: (@Composable () -> Unit)? = if (bubbleNotificationData.visuals.actionLabel != null) {
@Composable {
TextButton(
colors = ButtonDefaults.textButtonColors(contentColor = actionColor),
onClick = { bubbleNotificationData.performAction() },
content = { Text(bubbleNotificationData.visuals.actionLabel!!) }
)
}
} else null
val dismissActionComposable: (@Composable () -> Unit)? = if (bubbleNotificationData.visuals.withDismissAction) {
@Composable {
IconButton(
onClick = { bubbleNotificationData.dismiss() },
content = {
Icon(
Icons.Filled.Close,
contentDescription = null,
)
}
)
}
} else null
BubbleNotification(
shape = shape,
containerColor = containerColor,
contentColor = contentColor,
actionContentColor = actionContentColor,
dismissActionContentColor = dismissActionContentColor,
modifier = modifier,
action = actionComposable,
dismissAction = dismissActionComposable,
text = { Text(bubbleNotificationData.visuals.message) }
)
}
private const val BubbleNotificationFadeInMillis = 150
private const val BubbleNotificationFadeOutMillis = 75
private const val BubbleNotificationInBetweenDelayMillis = 0
enum class BubbleNotificationDuration {
Long,
Short,
Indefinite
}
interface BubbleNotificationVisuals {
val message: String
val actionLabel: String?
val withDismissAction: Boolean
val duration: BubbleNotificationDuration
}
interface BubbleNotificationData {
val visuals: BubbleNotificationVisuals
/**
* Function to be called when BubbleNotification action has been performed to notify the listeners.
*/
fun performAction()
/**
* Function to be called when BubbleNotification is dismissed either by timeout or by the user.
*/
fun dismiss()
}
private class BubbleNotificationVisualsImpl(
override val message: String,
override val actionLabel: String?,
override val withDismissAction: Boolean,
override val duration: BubbleNotificationDuration
): BubbleNotificationVisuals {
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as BubbleNotificationVisualsImpl
if (message != other.message) return false
if (actionLabel != other.actionLabel) return false
if (withDismissAction != other.withDismissAction) return false
if (duration != other.duration) return false
return true
}
override fun hashCode(): Int {
var result = message.hashCode()
result = 31 * result + actionLabel.hashCode()
result = 31 * result + withDismissAction.hashCode()
result = 31 * result + duration.hashCode()
return result
}
}
private class BubbleNotificationDataImpl(
override val visuals: BubbleNotificationVisuals,
private val continuation: CancellableContinuation<BubbleNotificationResult>
): BubbleNotificationData {
override fun performAction() {
if (continuation.isActive) continuation.resume(BubbleNotificationResult.ActionPerformed)
}
override fun dismiss() {
if (continuation.isActive) continuation.resume(BubbleNotificationResult.Dismissed)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other == null || this::class != other::class) return false
other as BubbleNotificationDataImpl
if (visuals != other.visuals) return false
if (continuation != other.continuation) return false
return true
}
override fun hashCode(): Int {
var result = visuals.hashCode()
result = 31 * result + continuation.hashCode()
return result
}
}
class BubbleNotificationHostState {
/**
* Only one [BubbleNotification] can be shown at a time. Since a suspending Mutex is a fair queue, this
* manages our message queue and we don't have to maintain one.
*/
private val mutex = Mutex()
/**
* The current [BubbleNotificationData] being shown by the [BubbleNotificationHostState], or `null` if none.
*/
var currentBubbleNotificationData by mutableStateOf<BubbleNotificationData?>(null)
private set
suspend fun showBubble(
message: String,
actionLabel: String? = null,
withDismissAction: Boolean = false,
duration: BubbleNotificationDuration = if (actionLabel == null) BubbleNotificationDuration.Short
else BubbleNotificationDuration.Indefinite
): BubbleNotificationResult = showBubble(
visuals = BubbleNotificationVisualsImpl(
message = message,
actionLabel = actionLabel,
withDismissAction = withDismissAction,
duration = duration
)
)
suspend fun showBubble(visuals: BubbleNotificationVisuals): BubbleNotificationResult = mutex.withLock {
try {
return suspendCancellableCoroutine { continuation ->
currentBubbleNotificationData = BubbleNotificationDataImpl(visuals, continuation)
}
} finally {
currentBubbleNotificationData = null
}
}
}
internal fun BubbleNotificationDuration.toMillis(
hasAction: Boolean,
accessibilityManager: AccessibilityManager?
): Long {
val original = when (this) {
BubbleNotificationDuration.Long -> 8000L
BubbleNotificationDuration.Short -> 4000L
BubbleNotificationDuration.Indefinite -> Long.MAX_VALUE
}
if (accessibilityManager == null) {
return original
}
return accessibilityManager.calculateRecommendedTimeoutMillis(
original,
containsIcons = true,
containsText = true,
containsControls = hasAction
)
}
@Composable
fun rememberBubbleNotificationHostState(): BubbleNotificationHostState {
return remember { BubbleNotificationHostState() }
}
object BubbleNotificationDefaults {
/** Default shape of a bubble notification. */
val shape: Shape @Composable get() = BubbleNotificationTokens.ContainerShape
/** Default color of a bubble notification. */
val color: Color @Composable get() = BubbleNotificationTokens.ContainerColor
/** Default content color of a bubble notification. */
val contentColor: Color @Composable get() = BubbleNotificationTokens.SupportingTextColor
/** Default action color of a bubble notification. */
val actionColor: Color @Composable get() = BubbleNotificationTokens.ActionLabelTextColor
/** Default action content color of a bubble notification. */
val actionContentColor: Color @Composable get() = BubbleNotificationTokens.ActionLabelTextColor
/** Default dismiss action content color of a bubble notification. */
val dismissActionContentColor: Color @Composable get() = BubbleNotificationTokens.IconColor
}
private val ContainerMaxWidth = 600.dp
private val ContainerMaxHeight = 128.dp
private val OuterPadding = 16.dp
private val InnerPadding = 12.dp
private val InnerPaddingWithAction = 8.dp
enum class BubbleNotificationResult {
/**
* [Snackbar] that is shown has been dismissed either by timeout of by user
*/
Dismissed,
/**
* Action on the [Snackbar] has been clicked before the time out passed
*/
ActionPerformed
}
@Composable
fun Screen() {
val scope = rememberCoroutineScope()
val hostState = rememberBubbleNotificationHostState()
BubbleNotificationHost(
hostState = hostState
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.fillMaxSize()
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally
) {
Button(
onClick = {
scope.launch {
hostState.showBubble(
message = "Looooooooooooooooooooooooooooooooooooooooooooooooooooooonnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnnngggggggggggggggggggggggggggggggggggggggggggggggggg Message"
)
}
}
) {
Text("Only message")
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
scope.launch {
hostState.showBubble(
message = "Message",
actionLabel = "action",
duration = BubbleNotificationDuration.Short
)
}
}
) {
Text("Message with action")
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
scope.launch {
hostState.showBubble(
message = "Message",
withDismissAction = true,
duration = BubbleNotificationDuration.Short
)
}
}
) {
Text("Message with dismiss action")
}
Spacer(modifier = Modifier.height(16.dp))
Button(
onClick = {
scope.launch {
hostState.showBubble(
message = "Message",
actionLabel = "action",
withDismissAction = true,
duration = BubbleNotificationDuration.Short
)
}
}
) {
Text("Message with action and dismiss action")
}
}
}
}
}
object BubbleNotificationTokens {
val ContainerColor: Color @Composable get() = MaterialTheme.colorScheme.tertiary
val IconColor: Color @Composable get() = MaterialTheme.colorScheme.inverseOnSurface
val SupportingTextColor: Color @Composable get() = MaterialTheme.colorScheme.inverseOnSurface
val ActionLabelTextColor: Color @Composable get() = MaterialTheme.colorScheme.tertiaryContainer
val ActionLabelTextFont: TextStyle @Composable get() = MaterialTheme.typography.labelLarge
val SupportingTextFont: TextStyle @Composable get() = MaterialTheme.typography.bodyMedium
val ContainerShape: Shape @Composable get() = MaterialTheme.shapes.extraSmall
val ContainerElevation = 6.0.dp
val IconSize = 24.0.dp
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment