Last active
October 18, 2023 18:14
-
-
Save chachako/677bf70b20891742ada1ff3e2c11209a to your computer and use it in GitHub Desktop.
An actionable Jetpack-Compose toast bar with beautiful UI and animations
This file contains hidden or 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
package chachako.ui | |
import androidx.compose.animation.AnimatedContent | |
import androidx.compose.animation.ContentTransform | |
import androidx.compose.animation.SizeTransform | |
import androidx.compose.animation.core.CubicBezierEasing | |
import androidx.compose.animation.core.EaseInOutQuad | |
import androidx.compose.animation.core.Spring | |
import androidx.compose.animation.core.VisibilityThreshold | |
import androidx.compose.animation.core.spring | |
import androidx.compose.animation.core.tween | |
import androidx.compose.animation.fadeIn | |
import androidx.compose.animation.fadeOut | |
import androidx.compose.animation.slideInVertically | |
import androidx.compose.animation.slideOutVertically | |
import androidx.compose.foundation.background | |
import androidx.compose.foundation.clickable | |
import androidx.compose.foundation.layout.Spacer | |
import androidx.compose.foundation.layout.fillMaxWidth | |
import androidx.compose.foundation.layout.imePadding | |
import androidx.compose.foundation.layout.padding | |
import androidx.compose.foundation.layout.size | |
import androidx.compose.material.LocalContentColor | |
import androidx.compose.material.Text | |
import androidx.compose.runtime.Composable | |
import androidx.compose.runtime.CompositionLocalProvider | |
import androidx.compose.runtime.LaunchedEffect | |
import androidx.compose.runtime.compositionLocalOf | |
import androidx.compose.runtime.getValue | |
import androidx.compose.runtime.mutableStateOf | |
import androidx.compose.runtime.setValue | |
import androidx.compose.ui.Modifier | |
import androidx.compose.ui.draw.shadow | |
import androidx.compose.ui.res.painterResource | |
import androidx.compose.ui.text.font.FontWeight | |
import androidx.compose.ui.text.style.TextOverflow | |
import androidx.compose.ui.unit.IntOffset | |
import androidx.compose.ui.unit.dp | |
import androidx.compose.ui.unit.sp | |
import kotlinx.coroutines.delay | |
import chachako.ui.theme.colors | |
import chachako.ui.theme.shapes | |
import chachako.ui.theme.spacing | |
import javax.inject.Inject | |
import javax.inject.Singleton | |
val LocalToast = compositionLocalOf<ToastFlow> { error("No ToastFlow provided") } | |
const val ToastDurationShort = 3000 | |
const val ToastDurationLong = 8000 | |
data class ToastData( | |
val message: String, | |
val duration: Int, | |
val icon: Int, | |
val action: String?, | |
val onAction: ToastFlow.() -> Unit, | |
) | |
@Singleton | |
class ToastFlow @Inject constructor() { | |
var previous: ToastData? = null | |
var current by mutableStateOf<ToastData?>(null) | |
private set | |
/** | |
* Shows a toast with the given message. | |
* | |
* @param message The message to show. | |
* @param duration The millisecond duration to show the toast. | |
* @param icon The icon resource id of the toast. | |
* @param action The action text to show on the toast. | |
* @param onAction The action to perform when the action text is clicked. | |
*/ | |
fun show( | |
message: String, | |
duration: Int = ToastDurationShort, | |
icon: Int = -1, | |
action: String? = null, | |
onAction: ToastFlow.() -> Unit = { dismiss() }, | |
) { | |
previous = current | |
current = ToastData(message, duration, icon, action, onAction) | |
} | |
/** | |
* Dismisses the current toast. | |
*/ | |
fun dismiss() { | |
current = null | |
} | |
} | |
@Composable | |
fun Toast(modifier: Modifier = Modifier) { | |
val flow = LocalToast.current | |
val data = flow.current | |
val isSwitching = flow.previous != null && flow.previous != data && data != null | |
AnimatedContent( | |
targetState = data, | |
transitionSpec = { | |
ContentTransform( | |
targetContentEnter = slideInVertically( | |
animationSpec = when (isSwitching) { | |
// We want it to have some delay when switching so the old toast goes up first | |
true -> tween(440, easing = CubicBezierEasing(0.56f, -0.41f, 0.4f, 1.4f)) | |
// Otherwise, we just use the normal animation | |
false -> spring( | |
stiffness = Spring.StiffnessMediumLow, | |
visibilityThreshold = IntOffset.VisibilityThreshold | |
) | |
}, | |
initialOffsetY = { it } | |
) + fadeIn(tween(if (isSwitching) 300 else 400)), | |
initialContentExit = when (isSwitching) { | |
// We want to slide up the old toast when we switch to a new one | |
true -> slideOutVertically( | |
animationSpec = tween(480, easing = EaseInOutQuad), | |
targetOffsetY = { -(it * 1.3).toInt() } | |
) + fadeOut(tween(480)) | |
// Otherwise, we just drop the toast down | |
false -> slideOutVertically(tween(260), targetOffsetY = { it }) + fadeOut() | |
}, | |
).using(SizeTransform(clip = false)) | |
}, | |
modifier = modifier.imePadding(), | |
) { | |
if (it != null) { | |
val color = colors.toastBackground | |
CenterRow( | |
modifier = Modifier | |
.shadow(24.dp, shapes.bar, ambientColor = color, spotColor = color) | |
.fillMaxWidth() | |
.background(color, shapes.bar) | |
) { | |
val contentSize = 20.dp | |
Gap(spacing.medium) | |
if (it.icon != -1) { | |
Icon( | |
painter = painterResource(it.icon), | |
tint = colors.toastContent, | |
modifier = Modifier.size(contentSize), | |
) | |
Gap(spacing.tiny) | |
} | |
Text( | |
text = it.message, | |
color = colors.toastContent, | |
maxLines = 1, | |
fontSize = 14.sp, | |
fontWeight = FontWeight.Medium, | |
overflow = TextOverflow.Ellipsis, | |
modifier = Modifier.weight(1f).padding(vertical = spacing.small), | |
) | |
if (it.action != null) { | |
Gap(spacing.medium) | |
Spacer( | |
modifier = Modifier | |
.size(width = 2.dp, height = contentSize) | |
.background(colors.toastContentTertiary, shapes.pill) | |
) | |
CompositionLocalProvider(LocalContentColor provides colors.toastContentSecondary) { | |
Text( | |
text = it.action, | |
color = colors.toastContentSecondary, | |
maxLines = 1, | |
fontSize = 14.sp, | |
fontWeight = FontWeight.Bold, | |
modifier = Modifier | |
.clickable { it.onAction.invoke(flow) } | |
.padding(horizontal = spacing.medium, vertical = spacing.small), | |
) | |
} | |
} else { | |
Gap(spacing.medium) | |
} | |
} | |
} | |
} | |
// Once the state changes, we re-trigger the timer to auto-dismiss | |
LaunchedEffect(data) { | |
if (data != null) { | |
delay(data.duration.toLong()) | |
flow.dismiss() | |
} | |
} | |
} |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Usage
In your
Main?Activity.kt
:Then, just declare
Toast()
in anyComposable
you like.Finally, just inject it into any
ViewModel
if you want.