Last active
December 31, 2023 12:56
-
-
Save kafri8889/0b2a644695964320e3406dfb05f5969c to your computer and use it in GitHub Desktop.
BubbleNotification, similar to Snackbar, is on top with enter/exit slide animation
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
enum class BubbleNotificationDuration { | |
Long, | |
Short, | |
Indefinite | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
@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") | |
} | |
} | |
} | |
} | |
} |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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