自定义Toast工具类AToast:打造优雅的Android提示体验

自定义Toast工具类AToast:打造优雅的Android提示体验

概述

在Android开发中,Toast是常用的用户提示工具,但系统自带的Toast功能有限,样式单调。今天我们来介绍一款功能强大的自定义Toast工具类——AToast,它不仅支持多种动画效果,还能完全自定义布局,为你的应用增添独特的视觉体验。

主要特性

1. 多种动画效果

AToast内置了四种精美的动画效果:

  • BOTTOM_BOUNCE: 底部弹出,带超调回弹效果
  • TOP_BOUNCE: 顶部弹出,带超调回弹效果
  • CENTER_SCALE: 中心缩放,带弹性效果
  • FADE_IN_OUT: 淡入淡出效果

2. 灵活的显示位置

支持任意位置显示,可以设置重力、X/Y偏移量,满足不同场景需求。

3. 内存安全

通过Context映射管理Toast实例,自动处理生命周期,避免内存泄漏。

4. 完全自定义

不仅可以使用默认样式,还能传入任意自定义View,实现完全个性化的Toast。

核心设计

单例模式与静态方法

class AToast private constructor() {
    companion object {
        fun show(context: Context, message: CharSequence, ...)
        fun show(context: Context, view: View, config: ToastConfig)
        fun dismiss(context: Context)
        fun dismissAll()
    }
}

这种设计使得调用非常简洁,无需创建实例即可使用。

配置化设计

通过ToastConfig数据类封装所有配置项:

data class ToastConfig(
    val duration: Long = 2000L,
    val gravity: Int = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
    val xOffset: Int = 0,
    val yOffset: Int = 100,
    val animationType: AnimationType = AnimationType.BOTTOM_BOUNCE
)

使用示例

基本使用

// 显示默认样式的Toast
AToast.show(context, "操作成功")

// 显示顶部Toast,持续3秒
AToast.show(context, "新消息", 3000L, AToast.AnimationType.TOP_BOUNCE)

// 显示中心缩放Toast
AToast.show(context, "加载中", type = AToast.AnimationType.CENTER_SCALE)

自定义布局

val customView = LayoutInflater.from(context).inflate(R.layout.custom_toast, null)
val config = AToast.ToastConfig(
    duration = 3000L,
    gravity = Gravity.TOP or Gravity.RIGHT,
    xOffset = 20,
    yOffset = 100,
    animationType = AToast.AnimationType.FADE_IN_OUT
)
AToast.show(context, customView, config)

生命周期管理

// 移除特定Context的所有Toast
override fun onDestroy() {
    super.onDestroy()
    AToast.dismiss(context)
}

// 移除所有Toast
fun clearAllToasts() {
    AToast.dismissAll()
}

动画实现原理

底部弹跳动画

private fun applyBottomBounceAnimation(view: View, isEnter: Boolean, onEnd: (() -> Unit)?) {
    val translationY = if (isEnter) {
        view.translationY = view.height.toFloat()
        ObjectAnimator.ofFloat(view, "translationY", view.height.toFloat(), 0f)
    } else {
        ObjectAnimator.ofFloat(view, "translationY", 0f, view.height.toFloat())
    }
    
    translationY.apply {
        duration = if (isEnter) 500L else 300L
        interpolator = if (isEnter) OvershootInterpolator(1.5f) 
                       else AccelerateDecelerateInterpolator()
        // ... 动画监听
    }
}

中心缩放动画

使用AnimatorSet同时控制X和Y轴的缩放,配合BounceInterpolator实现弹性效果。

默认样式实现

ToastLayoutCreator负责创建默认的系统Toast样式:

  • 半透明黑色圆角背景
  • 白色文字带阴影
  • 自适应宽度(最小30dp,最大300dp)
  • 自动省略长文本

最佳实践

1. 统一管理

建议在基类Activity中统一管理Toast的显示和移除,避免Toast在不当的时机显示。

2. 动画选择

  • 成功提示:使用BOTTOM_BOUNCECENTER_SCALE
  • 错误提示:使用FADE_IN_OUT
  • 重要通知:使用TOP_BOUNCE吸引用户注意

3. 时长设置

  • 短消息:1500-2000ms
  • 中等长度:2000-3000ms
  • 长消息:3000-4000ms

4. 内存优化

AToast使用WeakReference模式管理Toast实例,但仍需注意:

  • 在Activity销毁时调用dismiss()
  • 避免在频繁调用的地方创建大量Toast

扩展建议

如果需要进一步扩展功能,可以考虑:

  1. 队列管理: 实现Toast队列,避免多个Toast重叠
  2. 优先级系统: 为不同重要性的Toast设置优先级
  3. 图标支持: 在默认样式中支持图标显示
  4. 主题适配: 根据应用主题自动适配Toast样式

总结

AToast工具类通过简洁的API、丰富的动画效果和灵活的自定义能力,极大地提升了Android应用中Toast的用户体验。其良好的内存管理和生命周期处理,让开发者可以放心使用,专注于业务逻辑的实现。

无论是简单的文本提示,还是复杂的自定义布局,AToast都能完美胜任,是提升应用交互体验的绝佳选择。


希望这个工具类能为你的Android开发带来便利!如果有任何问题或建议,欢迎在评论区留言讨论。

import android.animation.Animator
import android.animation.AnimatorListenerAdapter
import android.animation.ObjectAnimator
import android.content.Context
import android.graphics.Color
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import android.util.TypedValue
import android.view.Gravity
import android.view.View
import android.view.ViewGroup
import android.view.WindowManager
import android.view.animation.AccelerateDecelerateInterpolator
import android.view.animation.BounceInterpolator
import android.view.animation.OvershootInterpolator
import android.widget.LinearLayout
import android.widget.TextView
import androidx.core.graphics.toColorInt

/**
 * 自定义Toast工具类
 */
class AToast private constructor() {

    companion object {
        // 使用弱引用包装器来避免内存泄漏
        private val toastMap = mutableMapOf<Context, CustomToast?>()

        /**
         * 显示Toast
         */
        fun show(context: Context,
                 message: CharSequence,
                 duration: Long = 2000L,
                 type: AnimationType = AnimationType.BOTTOM_BOUNCE) {
            toastMap[context] = CustomToast(context).apply {
                setText(message)
                setDuration(duration)
                when (type) {
                    AnimationType.BOTTOM_BOUNCE -> {
                        setAnimationType(AnimationType.BOTTOM_BOUNCE)
                        setGravity(Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL, 0, 100)
                    }
                    AnimationType.TOP_BOUNCE -> {
                        setAnimationType(AnimationType.TOP_BOUNCE)
                        setGravity(Gravity.TOP or Gravity.CENTER_HORIZONTAL, 0, 100)
                    }
                    AnimationType.CENTER_SCALE -> {
                        setAnimationType(AnimationType.CENTER_SCALE)
                        setGravity(Gravity.CENTER, 0, 0)
                    }
                    else -> {
                        setAnimationType(AnimationType.FADE_IN_OUT)
                        setGravity(Gravity.CENTER, 0, 0)
                    }
                }
                show()
            }
        }

        /**
         * 显示自定义布局Toast
         */
        fun show(
            context: Context,
            view: View,
            config: ToastConfig
        ) {
            toastMap[context] = CustomToast(context).apply {
                setCustomView(view)
                setDuration(config.duration)
                setAnimationType(config.animationType)
                setGravity(config.gravity, config.xOffset, config.yOffset)
                show()
            }
        }

        /**
         * 移除指定Context的所有Toast
         */
        fun dismiss(context: Context) {
            toastMap[context]?.dismiss()
            toastMap.remove(context)
        }

        /**
         * 移除所有Toast
         */
        fun dismissAll() {
            toastMap.values.forEach { it?.dismiss() }
            toastMap.clear()
        }
    }

    /**
     * 非静态内部类,持有对Context的引用但通过生命周期管理
     */
    class CustomToast(private val context: Context) {

        private var windowManager: WindowManager? = null
        private var toastView: View? = null
        private val handler = Handler(Looper.getMainLooper())
        private var removeRunnable: Runnable? = null

        private var duration: Long = 2000L
        private var gravity: Int = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL
        private var xOffset: Int = 0
        private var yOffset: Int = 100
        private var animationType: AnimationType = AnimationType.BOTTOM_BOUNCE

        private val layoutParams = WindowManager.LayoutParams().apply {
            flags = WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
                    WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE or
                    WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON
            format = android.graphics.PixelFormat.TRANSLUCENT
            gravity = this@CustomToast.gravity
            x = xOffset
            y = yOffset
            width = WindowManager.LayoutParams.WRAP_CONTENT
            height = WindowManager.LayoutParams.WRAP_CONTENT
        }

        /**
         * 设置文本
         */
        fun setText(text: CharSequence): CustomToast {
            toastView = ToastLayoutCreator.createCompleteToastView(context, text)
            return this
        }

        /**
         * 设置自定义视图
         */
        fun setCustomView(view: View): CustomToast {
            toastView = view
            return this
        }

        /**
         * 设置显示时长
         */
        fun setDuration(duration: Long): CustomToast {
            this.duration = duration
            return this
        }

        /**
         * 设置动画类型
         */
        fun setAnimationType(type: AnimationType): CustomToast {
            this.animationType = type
            return this
        }

        /**
         * 设置位置
         */
        fun setGravity(gravity: Int, xOffset: Int, yOffset: Int): CustomToast {
            this.gravity = gravity
            this.xOffset = xOffset
            this.yOffset = yOffset
            layoutParams.gravity = gravity
            layoutParams.x = xOffset
            layoutParams.y = yOffset
            return this
        }

        /**
         * 显示Toast
         */
        fun show() {
            val view = toastView ?: return

            windowManager = context.getSystemService(Context.WINDOW_SERVICE) as WindowManager

            // 应用进入动画
            applyAnimation(view, animationType, true)

            // 添加到窗口
            windowManager?.addView(view, layoutParams)

            // 设置定时移除
            removeRunnable = Runnable {
                applyAnimation(view, animationType, false) {
                    dismiss()
                }
            }
            handler.postDelayed(removeRunnable!!, duration)
        }

        /**
         * 移除Toast
         */
        fun dismiss() {
            removeRunnable?.let { handler.removeCallbacks(it) }
            removeRunnable = null

            toastView?.let { view ->
                windowManager?.removeView(view)
                toastView = null
            }
            windowManager = null
        }

        /**
         * 应用动画
         */
        private fun applyAnimation(
            view: View,
            animationType: AnimationType,
            isEnter: Boolean,
            onEnd: (() -> Unit)? = null
        ) {
            when (animationType) {
                AnimationType.BOTTOM_BOUNCE -> applyBottomBounceAnimation(view, isEnter, onEnd)
                AnimationType.TOP_BOUNCE -> applyTopBounceAnimation(view, isEnter, onEnd)
                AnimationType.CENTER_SCALE -> applyCenterScaleAnimation(view, isEnter, onEnd)
                AnimationType.FADE_IN_OUT -> applyFadeAnimation(view, isEnter, onEnd)
            }
        }

        private fun applyBottomBounceAnimation(view: View, isEnter: Boolean, onEnd: (() -> Unit)?) {
            val translationY = if (isEnter) {
                view.translationY = view.height.toFloat()
                ObjectAnimator.ofFloat(view, "translationY", view.height.toFloat(), 0f)
            } else {
                ObjectAnimator.ofFloat(view, "translationY", 0f, view.height.toFloat())
            }

            translationY.apply {
                duration = if (isEnter) 500L else 300L
                interpolator =
                    if (isEnter) OvershootInterpolator(1.5f) else AccelerateDecelerateInterpolator()
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        onEnd?.invoke()
                    }
                })
                start()
            }
        }

        private fun applyTopBounceAnimation(view: View, isEnter: Boolean, onEnd: (() -> Unit)?) {
            val translationY = if (isEnter) {
                view.translationY = -view.height.toFloat()
                ObjectAnimator.ofFloat(view, "translationY", -view.height.toFloat(), 0f)
            } else {
                ObjectAnimator.ofFloat(view, "translationY", 0f, -view.height.toFloat())
            }

            translationY.apply {
                duration = if (isEnter) 500L else 300L
                interpolator =
                    if (isEnter) OvershootInterpolator(1.5f) else AccelerateDecelerateInterpolator()
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        onEnd?.invoke()
                    }
                })
                start()
            }
        }

        private fun applyCenterScaleAnimation(view: View, isEnter: Boolean, onEnd: (() -> Unit)?) {
            val scaleX = if (isEnter) {
                view.scaleX = 0f
                view.scaleY = 0f
                ObjectAnimator.ofFloat(view, "scaleX", 0f, 1f)
            } else {
                ObjectAnimator.ofFloat(view, "scaleX", 1f, 0f)
            }

            val scaleY = if (isEnter) {
                ObjectAnimator.ofFloat(view, "scaleY", 0f, 1f)
            } else {
                ObjectAnimator.ofFloat(view, "scaleY", 1f, 0f)
            }

            val animatorSet = android.animation.AnimatorSet()
            animatorSet.playTogether(scaleX, scaleY)
            animatorSet.apply {
                duration = if (isEnter) 400L else 300L
                interpolator =
                    if (isEnter) BounceInterpolator() else AccelerateDecelerateInterpolator()
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        onEnd?.invoke()
                    }
                })
                start()
            }
        }

        private fun applyFadeAnimation(view: View, isEnter: Boolean, onEnd: (() -> Unit)?) {
            val alpha = if (isEnter) {
                view.alpha = 0f
                ObjectAnimator.ofFloat(view, "alpha", 0f, 1f)
            } else {
                ObjectAnimator.ofFloat(view, "alpha", 1f, 0f)
            }

            alpha.apply {
                duration = 300L
                interpolator = AccelerateDecelerateInterpolator()
                addListener(object : AnimatorListenerAdapter() {
                    override fun onAnimationEnd(animation: Animator) {
                        onEnd?.invoke()
                    }
                })
                start()
            }
        }
    }

    /**
     * 动画类型
     */
    enum class AnimationType {
        BOTTOM_BOUNCE,    // 底部弹出,回弹效果
        TOP_BOUNCE,       // 顶部弹出,回弹效果
        CENTER_SCALE,     // 中心缩放效果
        FADE_IN_OUT,      // 淡入淡出
    }

    /**
     * Toast配置
     */
    data class ToastConfig(
        val duration: Long = 2000L,
        val gravity: Int = Gravity.BOTTOM or Gravity.CENTER_HORIZONTAL,
        val xOffset: Int = 0,
        val yOffset: Int = 100,
        val animationType: AnimationType = AnimationType.BOTTOM_BOUNCE
    )


    /**
     * 动态创建系统Toast样式的布局
     */
    object ToastLayoutCreator {

        /**
         * 创建Toast的TextView
         */
        private fun createToastTextView(context: Context, message: CharSequence): TextView {
            return TextView(context).apply {
                text = message

                layoutParams = LinearLayout.LayoutParams(
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT
                ).apply {
                    weight = 1f
                    minWidth = dpToPx(context, 30)
                    maxWidth = dpToPx(context, 300)
                    ellipsize = TextUtils.TruncateAt.END
                    gravity = Gravity.CENTER_HORIZONTAL
                }

                // 设置文本外观
                setDefaultToastTextAppearance()

                // 设置文字颜色
                setTextColor(Color.WHITE)

                // 设置阴影
                setShadowLayer(2.75f, 0f, 0f, "#BB000000".toColorInt())

                // 设置内边距
                val padding = dpToPx(context, 32)
                setPadding(padding, padding / 2, padding, padding / 2)
            }
        }

        /**
         * 创建默认Toast背景
         */
        private fun createDefaultToastBackground(context: Context): android.graphics.drawable.Drawable {
            val radius = dpToPx(context, 100).toFloat()

            return android.graphics.drawable.GradientDrawable().apply {
                shape = android.graphics.drawable.GradientDrawable.RECTANGLE
                cornerRadius = radius
                setColor("#99000000".toColorInt()) // 半透明黑色
            }
        }

        /**
         * 设置默认Toast文本样式
         */
        private fun TextView.setDefaultToastTextAppearance() {
            textSize = 12f // SP
            gravity = Gravity.CENTER
            includeFontPadding = false
        }

        /**
         * dp转px
         */
        private fun dpToPx(context: Context, dp: Int): Int {
            return TypedValue.applyDimension(
                TypedValue.COMPLEX_UNIT_DIP,
                dp.toFloat(),
                context.resources.displayMetrics
            ).toInt()
        }

        /**
         * 创建完整的系统样式Toast视图
         */
        fun createCompleteToastView(
            context: Context,
            message: CharSequence,
        ): View {
            // 创建外层容器(模拟Toast的根布局)
            val container = LinearLayout(context).apply {
                layoutParams = ViewGroup.LayoutParams(
                    ViewGroup.LayoutParams.WRAP_CONTENT,
                    ViewGroup.LayoutParams.WRAP_CONTENT
                )
                if (message.length < 15) {
                    background = createDefaultToastBackground(context)
                }
                orientation = LinearLayout.VERTICAL
                setPadding(0, 0, 0, 0)
            }

            // 创建Toast内容布局
            val toastView = createToastTextView(context, message)

            // 添加到容器
            container.addView(toastView)

            return container
        }
    }
}
posted @ 2026-02-03 08:48  良韬  阅读(4)  评论(0)    收藏  举报