Toast 是Android中常见的轻量级提示
本文将介绍如何使用Compose技术实现一个Toast组件
不是一个简单的toast
优雅-简洁-动画 才是我的风格
系统原生的Toast默认是在底部弹出,配合kotlin语音的特性,简单封装一下,使用方法非常简洁
inline fun Context.toast(text: CharSequence) =
Toast.makeText(this, text, Toast.LENGTH_SHORT).show()
在github上看到一个很棒的实现方式,现在要丢弃原生的Toast
使用Compose组件来实现一个 优雅-简洁-动画 的 Toast
分享一个 🔥我用Compose写了个笔记App,代码开源~里面用到了这个超级好看的Toast
使用方法
val toastState = remember { ToastUIState() }
val scope = rememberCoroutineScope()
ToastUI(toastState)
弹出toast
scope.launch {
toastState.show("hi")
}
懒得看的同学 可以直接跳到 源码 和 使用方法处
效果图
教程
布局
@Composable
private fun Toast()
Surface{
Row {
Icon()
Text("hi")
}
}
左边图标 右边跟着文本
显示动画
利用Animatedvisibility可以很轻松实现各种组合动画
弹出效果 :渐渐显示+垂直往下
消失效果 :渐渐消失+垂直往上
将ToastUI放在AnimatedVisibility组件下即可
AnimatedVisibility(
visible = { it },
modifier = modifier,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
) {
ToastUI("hi")
}
内部过度动画
val progress = remember { Animatable(0f) }
使用动画的函数创建一个浮动值保持器
定义一个进度值 范围是0f-1f
接着
使用Paint绘制一个圆角矩形。
drawRoundRect(
color = color,
size = Size(width = fraction, height = size.height),
cornerRadius = CornerRadius(6.dp.toPx()),
alpha = 0.1f,
)
animateTo
开始动画 从0f-1f animationSpec
设置动画时长
LaunchedEffect(animateDuration) {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = 3000 //3秒
),
)
}
现在UI已经基本实现了,接下来的操作就是为了简洁易用
接口
public interface ToastData {
public val message: String // 提示文本
public val icon: ImageVector? //图标
public val animationDuration: StateFlow<Int?>//动画时长
}
直接用 material3 提供的图标 ,当然可以用drawable,为了简洁而且 material3 提供的图标基本满足大部分场景的使用
接口的实现
ToastData接口的实现ToastDataImpl
主要是启动一个协程监听animationDuration,在经过duration时长后cancel当前协程并隐藏Toast;
@Stable
private class ToastDataImpl(
...
) : ToastData {
override suspend fun run(accessibilityManager: AccessibilityManager?) {
...
supervisorScope {
launch {
animationDuration.collectLatest { duration ->
val animationScale = coroutineContext.durationScale
started = System.currentTimeMillis()
// 关闭动画后,只需显示、等待和隐藏即可。
val finalDuration = when (animationScale) {
0f -> duration.toLong()
else -> (duration.toLong() * animationScale).roundToLong()
}
delay(finalDuration)
this@launch.cancel()
}
}
}
}
Toast的UI状态管理
定义了ToastUIState,用于管理Toast的UI状态
@Stable
class ToastUIState {
// 使用mutex锁同步访问currentData,避免并发修改导致的问题;
private val mutex = Mutex()
// 存储当前显示的Toast
public var currentData: ToastData? by mutableStateOf(null)
private set
/**
*show函数返回一个协程,调用方可以对其进行cancel操作,实现对Toast的生命周期控制
*/
public suspend fun show(
message: String,
icon: ImageVector? = null,
): Unit = mutex.withLock {
// 获取mutex锁
try {
// 构建ToastDataImpl并启动协程
return suspendCancellableCoroutine { cont ->
currentData = ToastDataImpl(
message,
icon,
cont
)
}
}
// 确保finally块执行,currentData被置空
finally {
currentData = null
}
}
}
Toast 触摸暂停 上滑消失
public interface ToastData {
public val message: String
public val icon: ImageVector?
public val animationDuration: StateFlow<Int?>
public val type: ToastModel.Type?
public suspend fun run(accessibilityManager: AccessibilityManager?)
public fun pause() //暂停
public fun resume() //重新开始
public fun dismiss() //开始关闭
public fun dismissed() //完全关闭
}
这个手势交互检测实现了Toast的滑动消失效果,并在超过一定距离时执行onDismissed回调以完全隐藏Toast。
private fun Modifier.toastGesturesDetector(
onPause: () -> Unit,
onResume: () -> Unit,
onDismissed: () -> Unit,
): Modifier = composed {
// 记录Toast的Y偏移量
val offsetY = remember { Animatable(0f) }
// 记录Toast的透明度
val alpha = remember { Animatable(1f) }
// 监听手势事件
pointerInput(Unit) {
// 计算偏移量衰变比率
val decay = splineBasedDecay<Float>(this)
coroutineScope {
while (true) {
awaitPointerEventScope {
// Detect a touch down event.
val down = awaitFirstDown()
onPause()
val pointerId = down.id
// 记录手指滑动速度
val velocityTracker = VelocityTracker()
// Stop any ongoing animation.
// 停止任何正在进行的动画
launch(start = CoroutineStart.UNDISPATCHED) {
offsetY.stop()
alpha.stop()
}
// 监听垂直滑动
verticalDrag(pointerId) { change ->
onPause()
// 根据滑动事件更新动画值
val changeY = (offsetY.value + change.positionChange().y).coerceAtMost(0f)
launch {
offsetY.snapTo(changeY)
}
// 重置速度跟踪器
if (changeY == 0f) {
velocityTracker.resetTracking()
} else {
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
}
}
// 滑动结束,准备启动动画
onResume()
val velocity = velocityTracker.calculateVelocity().y
val targetOffsetY = decay.calculateTargetValue(
offsetY.value,
velocity,
)
// 动画结束时停止
offsetY.updateBounds(
lowerBound = -size.height.toFloat() * 3,
upperBound = size.height.toFloat(),
)
launch {
if (velocity >= 0 || targetOffsetY.absoluteValue <= size.height) {
// 没有足够的速度; 滑回.
offsetY.animateTo(
targetValue = 0f,
initialVelocity = velocity,
)
} else {
// 被滑走
launch { offsetY.animateDecay(velocity, decay) }
launch {
alpha.animateTo(targetValue = 0f, animationSpec = tween(300))
onDismissed()
}
}
}
}
}
}
}
.offset {
IntOffset(0, offsetY.value.roundToInt())
}
.alpha(alpha.value)
}
Surface(
modifier = Modifier
//实现事件监听
.toastGesturesDetector(onPause, onResume, onDismissed)
) {
Row {
Icon()
Text("hi")
}
}
封装固定类型 比如 : 错误/警告
定义了5种类型
data class ToastModel(
val message: String,
val type: Type
){
enum class Type {
Normal, Success, Info, Warning, Error,
}
}
//颜色实体类
private data class ColorData(
val backgroundColor: Color,
val textColor: Color,
val iconColor: Color,
val icon: ImageVector? = null,
)
根据5种类型分别定制对应的颜色
val colorData = when (toastData.type) {
ToastModel.Type.Normal -> ColorData(
backgroundColor = WordsFairyTheme.colors.background,
textColor = WordsFairyTheme.colors.textPrimary,
iconColor = WordsFairyTheme.colors.textPrimary,
icon = Icons.Rounded.Notifications,
)
ToastModel.Type.Success -> ColorData(
backgroundColor = WordsFairyTheme.colors.success,
textColor = WordsFairyTheme.colors.textWhite,
iconColor = WordsFairyTheme.colors.textWhite,
icon = Icons.Rounded.Check,
)
...
else -> ColorData(
backgroundColor = WordsFairyTheme.colors.dialogBackground,
textColor = WordsFairyTheme.colors.textPrimary,
iconColor = WordsFairyTheme.colors.textPrimary,
icon = Icons.Rounded.Notifications,
)
}
完整代码
Toast.kt
在Surface(color = WordsFairyTheme.colors.dialogBackground)
换成你喜欢的AppColor,也可以 定义一个color作为参数 实现动态颜色
public interface ToastData {
public val message: String
public val icon: ImageVector?
public val animationDuration: StateFlow<Int?>
public val type: ToastModel.Type?
public suspend fun run(accessibilityManager: AccessibilityManager?)
public fun pause()
public fun resume()
public fun dismiss()
public fun dismissed()
}
data class ToastModel(
val message: String,
val type: Type
){
enum class Type {
Normal, Success, Info, Warning, Error,
}
}
private data class ColorData(
val backgroundColor: Color,
val textColor: Color,
val iconColor: Color,
val icon: ImageVector? = null,
)
@Composable
public fun Toast(
toastData: ToastData,
) {
val animateDuration by toastData.animationDuration.collectAsState()
val colorData = when (toastData.type) {
ToastModel.Type.Normal -> ColorData(
backgroundColor = WordsFairyTheme.colors.background,
textColor = WordsFairyTheme.colors.textPrimary,
iconColor = WordsFairyTheme.colors.textPrimary,
icon = Icons.Rounded.Notifications,
)
ToastModel.Type.Success -> ColorData(
backgroundColor = WordsFairyTheme.colors.success,
textColor = WordsFairyTheme.colors.textWhite,
iconColor = WordsFairyTheme.colors.textWhite,
icon = Icons.Rounded.Check,
)
ToastModel.Type.Info -> ColorData(
backgroundColor = WordsFairyTheme.colors.info,
textColor = WordsFairyTheme.colors.textWhite,
iconColor = WordsFairyTheme.colors.textWhite,
icon = Icons.Rounded.Info,
)
ToastModel.Type.Warning -> ColorData(
backgroundColor = AppColor.warning,
textColor = WordsFairyTheme.colors.textWhite,
iconColor = WordsFairyTheme.colors.textWhite,
icon = Icons.Rounded.Warning,
)
ToastModel.Type.Error -> ColorData(
backgroundColor = WordsFairyTheme.colors.error,
textColor = WordsFairyTheme.colors.textWhite,
iconColor = WordsFairyTheme.colors.textWhite,
icon = Icons.Rounded.Warning,
)
else -> ColorData(
backgroundColor = WordsFairyTheme.colors.dialogBackground,
textColor = WordsFairyTheme.colors.textPrimary,
iconColor = WordsFairyTheme.colors.textPrimary,
icon = Icons.Rounded.Notifications,
)
}
val icon = toastData.icon ?: colorData.icon
key(toastData) {
Toast(
message = toastData.message,
icon = icon,
backgroundColor = colorData.backgroundColor,
iconColor = colorData.iconColor,
textColor = colorData.textColor,
animateDuration = animateDuration,
onPause = toastData::pause,
onResume = toastData::resume,
onDismissed = toastData::dismissed,
)
}
}
@Composable
private fun Toast(
message: String,
icon: ImageVector?,
backgroundColor: Color,
iconColor: Color,
textColor: Color,
animateDuration: Int? = 0,
onPause: () -> Unit = {},
onResume: () -> Unit = {},
onDismissed: () -> Unit = {},
) {
val roundedValue = 26.dp
Surface(
modifier = Modifier
.systemBarsPadding()
.padding(8.dp)
.widthIn(max = 520.dp)
.fillMaxWidth()
.toastGesturesDetector(onPause, onResume, onDismissed),
color = backgroundColor,
shape = RoundedCornerShape(roundedValue),
tonalElevation = 2.dp,
) {
val progress = remember { Animatable(0f) }
LaunchedEffect(animateDuration) {
// Do not run animation when animations are turned off.
if (coroutineContext.durationScale == 0f) return@LaunchedEffect
if (animateDuration == null) {
progress.stop()
} else {
progress.animateTo(
targetValue = 1f,
animationSpec = tween(
durationMillis = animateDuration,
easing = EaseOut,
),
)
}
}
val color = LocalContentColor.current
Row(
Modifier
.drawBehind {
val fraction = progress.value * size.width
drawRoundRect(
color = color,
size = Size(width = fraction, height = size.height),
cornerRadius = CornerRadius(roundedValue.toPx()),
alpha = 0.1f,
)
}
.padding(12.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
if (icon != null) {
Icon(
icon,
contentDescription = null,
Modifier.size(24.dp),
tint = iconColor
)
}
Title(message, color = textColor)
}
}
}
private fun Modifier.toastGesturesDetector(
onPause: () -> Unit,
onResume: () -> Unit,
onDismissed: () -> Unit,
): Modifier = composed {
val offsetY = remember { Animatable(0f) }
val alpha = remember { Animatable(1f) }
pointerInput(Unit) {
val decay = splineBasedDecay<Float>(this)
coroutineScope {
while (true) {
awaitPointerEventScope {
// Detect a touch down event.
val down = awaitFirstDown()
onPause()
val pointerId = down.id
val velocityTracker = VelocityTracker()
// Stop any ongoing animation.
launch(start = CoroutineStart.UNDISPATCHED) {
offsetY.stop()
alpha.stop()
}
verticalDrag(pointerId) { change ->
onPause()
// Update the animation value with touch events.
val changeY = (offsetY.value + change.positionChange().y).coerceAtMost(0f)
launch {
offsetY.snapTo(changeY)
}
if (changeY == 0f) {
velocityTracker.resetTracking()
} else {
velocityTracker.addPosition(
change.uptimeMillis,
change.position,
)
}
}
onResume()
// No longer receiving touch events. Prepare the animation.
val velocity = velocityTracker.calculateVelocity().y
val targetOffsetY = decay.calculateTargetValue(
offsetY.value,
velocity,
)
// The animation stops when it reaches the bounds.
offsetY.updateBounds(
lowerBound = -size.height.toFloat() * 3,
upperBound = size.height.toFloat(),
)
launch {
if (velocity >= 0 || targetOffsetY.absoluteValue <= size.height) {
// Not enough velocity; Slide back.
offsetY.animateTo(
targetValue = 0f,
initialVelocity = velocity,
)
} else {
// The element was swiped away.
launch { offsetY.animateDecay(velocity, decay) }
launch {
alpha.animateTo(targetValue = 0f, animationSpec = tween(300))
onDismissed()
}
}
}
}
}
}
}
.offset {
IntOffset(0, offsetY.value.roundToInt())
}
.alpha(alpha.value)
}
ToastUI.kt
@Stable
class ToastUIState {
private val mutex = Mutex()
public var currentData: ToastData? by mutableStateOf(null)
private set
public suspend fun show(
message: String,
icon: ImageVector? = null,
): Unit = mutex.withLock {
try {
return suspendCancellableCoroutine { cont ->
currentData = ToastDataImpl(
message,
icon,
cont,
)
}
} finally {
currentData = null
}
}
public suspend fun show(
toastModel: ToastModel
): Unit = mutex.withLock {
try {
return suspendCancellableCoroutine { cont ->
currentData = ToastDataImpl(
toastModel.message,
null,
cont,
toastModel.type
)
}
} finally {
currentData = null
}
}
@Stable
private class ToastDataImpl(
override val message: String,
override val icon: ImageVector?,
private val continuation: CancellableContinuation<Unit>,
override val type: ToastModel.Type? = ToastModel.Type.Normal,
) : ToastData {
private var elapsed = 0L
private var started = 0L
private var duration = 0L
private val _state = MutableStateFlow<Int?>(null)
override val animationDuration: StateFlow<Int?> = _state.asStateFlow()
override suspend fun run(accessibilityManager: AccessibilityManager?) {
duration = durationTimeout(
hasIcon = icon != null,
accessibilityManager = accessibilityManager,
)
// Accessibility decided to show forever
// Let's await explicit dismiss, do not run animation.
if (duration == Long.MAX_VALUE) {
delay(duration)
return
}
resume()
supervisorScope {
launch {
animationDuration.collectLatest { duration ->
val animationScale = coroutineContext.durationScale
if (duration != null) {
started = System.currentTimeMillis()
// 关闭动画后,只需显示、等待和隐藏即可。
val finalDuration = when (animationScale) {
0f -> duration.toLong()
else -> (duration.toLong() * animationScale).roundToLong()
}
delay(finalDuration)
this@launch.cancel()
} else {
elapsed += System.currentTimeMillis() - started
delay(Long.MAX_VALUE)
}
}
}
}
}
override fun pause() {
_state.value = null
}
override fun resume() {
val remains = (duration - elapsed).toInt()
if (remains > 0) {
_state.value = remains
} else {
dismiss()
}
}
override fun dismiss() {
_state.value = 0
}
override fun dismissed() {
if (continuation.isActive) {
continuation.resume(Unit)
}
}
}
}
@OptIn(ExperimentalAnimationApi::class)
@Composable
public fun ToastUI(
hostState: ToastUIState,
modifier: Modifier = Modifier,
toast: @Composable (ToastData) -> Unit = { Toast(it) },
) {
val accessibilityManager = LocalAccessibilityManager.current
val currentData = hostState.currentData ?: return
//震动
val feedback = LocalHapticFeedback.current
key(currentData) {
var state by remember { mutableStateOf(false) }
val transition = updateTransition(targetState = state, label = "toast")
LaunchedEffect(Unit) {
state = true
currentData.run(accessibilityManager)
state = false
feedback.vibration()
}
transition.AnimatedVisibility(
visible = { it },
modifier = modifier,
enter = fadeIn() + slideInVertically(),
exit = fadeOut() + slideOutVertically(),
) {
toast(currentData)
}
// Await dismiss animation and dismiss the Toast completely.
// This animation workaround instead of nulling the toast data is to prevent
// relaunching another Toast when the dismiss animation has not completed yet.
LaunchedEffect(state, transition.currentState, transition.isRunning) {
if (!state && !transition.currentState && !transition.isRunning) {
currentData.dismissed()
feedback.vibration()
}
}
}
}
internal fun durationTimeout(
hasIcon: Boolean,
accessibilityManager: AccessibilityManager?,
): Long {
val timeout = 3000L
if (accessibilityManager == null) return timeout
return accessibilityManager.calculateRecommendedTimeoutMillis(
originalTimeoutMillis = timeout,
containsIcons = hasIcon,
containsText = true,
containsControls = false,
)
}
internal val CoroutineContext.durationScale: Float
get() {
val scale = this[MotionDurationScale]?.scaleFactor ?: 1f
check(scale >= 0f)
return scale
}
使用方法
val toastState = remember { ToastUIState() }
val scope = rememberCoroutineScope()
Column {
ToastUI(toastState)
Button() {
scope.launch {
toastState.show("hi") //纯文本
toastState.show("hi",Icons.Rounded.Notifications) //带图标
toastState.show(ToastModel(ToastModel("hi", ToastModel.Type.Normal))
toastState.show(ToastModel(ToastModel("hi", ToastModel.Type.Success))
toastState.show(ToastModel(ToastModel("hi", ToastModel.Type.Info))
}
}
}