Toast 是Android中常见的轻量级提示\
本文将介绍如何使用Compose技术实现一个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()
}
}
}
}
定义了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
}
}
}
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,
)
}
在Surface(color = WordsFairyTheme.colors.dialogBackground)
换成你喜欢的AppColor,也可以 定义一个color作为参数 实现动态颜色
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)
}
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))
}
}
}
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。
原创声明:本文系作者授权腾讯云开发者社区发表,未经许可,不得转载。
如有侵权,请联系 cloudcommunity@tencent.com 删除。