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

6,044 阅读6分钟

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

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

效果图

ezgif.com-video-to-gif.gif

ezgif.com-video-to-gif.gif

教程

布局

@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 提供的图标基本满足大部分场景的使用 Screenshot_2023-06-24-22-55-51-430_com.steleot.jetpackcompose.playground-edit.jpg

接口的实现

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

ezgif.com-video-to-gif.gif

import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.EaseOut
import androidx.compose.animation.core.calculateTargetValue
import androidx.compose.animation.core.tween
import androidx.compose.animation.splineBasedDecay
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.verticalDrag
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.rounded.Check
import androidx.compose.material.icons.rounded.Info
import androidx.compose.material.icons.rounded.Notifications
import androidx.compose.material.icons.rounded.Warning
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalContentColor
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.composed
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.input.pointer.positionChange
import androidx.compose.ui.input.pointer.util.VelocityTracker
import androidx.compose.ui.platform.AccessibilityManager
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.dp
import com.wordsfairy.note.ui.theme.AppColor
import com.wordsfairy.note.ui.theme.WordsFairyTheme
import com.wordsfairy.note.ui.widgets.Title
import kotlin.math.absoluteValue
import kotlin.math.roundToInt
import kotlinx.coroutines.CoroutineStart
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.launch

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

ToastUIState.kt

import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.updateTransition
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.slideInVertically
import androidx.compose.animation.slideOutVertically
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.Stable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.key
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.MotionDurationScale
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.platform.AccessibilityManager
import androidx.compose.ui.platform.LocalAccessibilityManager
import androidx.compose.ui.platform.LocalHapticFeedback
import com.wordsfairy.note.constants.EventBus
import com.wordsfairy.note.ext.flowbus.postEventValue
import com.wordsfairy.note.ui.common.vibration
import kotlin.coroutines.resume
import kotlin.math.roundToLong
import kotlinx.coroutines.CancellableContinuation
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.launch
import kotlinx.coroutines.supervisorScope
import kotlinx.coroutines.suspendCancellableCoroutine
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlin.coroutines.CoroutineContext
@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))
        }
    }
}