Skip to content

Instantly share code, notes, and snippets.

@chachako
Last active October 18, 2023 18:14
Show Gist options
  • Save chachako/677bf70b20891742ada1ff3e2c11209a to your computer and use it in GitHub Desktop.
Save chachako/677bf70b20891742ada1ff3e2c11209a to your computer and use it in GitHub Desktop.
An actionable Jetpack-Compose toast bar with beautiful UI and animations
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()
}
}
}
@chachako
Copy link
Author

chachako commented Oct 12, 2023

Usage

In your Main?Activity.kt:

@AndroidEntryPoint
class MainActivity : ComponentActivity() {
  @Inject lateinit var toast: ToastFlow
  
    // Optionally, 

    setContent {
      CompositionLocalProvider(
        LocalToast provides toast,
      ) {
        Your content here...
      }
    }
...

Then, just declare Toast() in any Composable you like.

Finally, just inject it into any ViewModel if you want.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment