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作为参数 完成动态色彩
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))
}
}
}