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")
}

懒得看的同学 可以直接跳到 源码 和 使用方法处

效果图

Jetpack Compose实现的一个优雅的 Toast 组件——简单易用~-LMLPHP

Jetpack Compose实现的一个优雅的 Toast 组件——简单易用~-LMLPHP

教程

布局

@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 提供的图标基本满足大部分场景的使用
Jetpack Compose实现的一个优雅的 Toast 组件——简单易用~-LMLPHP

接口的实现

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作为参数 实现动态颜色

Jetpack Compose实现的一个优雅的 Toast 组件——简单易用~-LMLPHP

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))
        }
    }
}
06-25 17:56