Android红包雨动画效果实现 - 可自定义的扩散范围动画组件

在Android应用开发中,动画效果是提升用户体验的重要手段。今天分享一个灵活可配置的红包雨动画组件,支持自定义扩散范围、动画时长、红包数量等参数,适用于各种点击反馈、奖励动画等场景。

效果如下

2025-10-29 11-28-10

功能特点

  • 自动位置获取 - 自动计算源视图和目标视图位置

  • 灵活配置 - 支持自定义扩散范围、红包数量、动画时长

  • 流畅动画 - 包含散开、收拢、缩放、旋转等多种动画效果

  • 完整回调 - 提供动画开始、进行中、结束等状态回调

  • 内存安全 - 自动清理动画资源,避免内存泄漏

动画工具类

java 复制代码
package com.zx.gncs;

import android.animation.Animator;
import android.animation.AnimatorSet;
import android.animation.ObjectAnimator;
import android.content.Context;
import android.os.Handler;
import android.view.View;
import android.view.ViewGroup;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import java.util.Random;

/**
 * 红包动画工具类 - 自动获取按钮当前位置
 * 可以在Activity、Fragment、适配器、弹窗中调用
 */
public class RedPacketAnimation {

    // 默认配置参数 - 缩小扩散范围
    private static final int DEFAULT_MIN_COUNT = 6; // 最小红包数量
    private static final int DEFAULT_MAX_COUNT = 12; // 最大红包数量
    private static final int DEFAULT_SPREAD_DISTANCE = 80; // 默认扩散距离
    private static final int MAX_SPREAD_DISTANCE = 80;// 最大扩散范围
    private static final int DEFAULT_SPREAD_DURATION = 600; // 扩散动画持续时间
    private static final int DEFAULT_GATHER_DURATION = 500; // 收拢动画持续时间
    private static final int DEFAULT_PACKET_SIZE = 35; // 红包大小
    private static final String RED_PACKET_TAG = "red_packet_animation";

    private Context context;
    private ViewGroup container;
    private View sourceView;
    private View targetView;
    private int redPacketResId;
    private Handler animationHandler;
    private Random random;

    // 配置参数
    private int minPacketCount = DEFAULT_MIN_COUNT;
    private int maxPacketCount = DEFAULT_MAX_COUNT;
    private int spreadDistance = DEFAULT_SPREAD_DISTANCE; // 使用缩小后的默认值
    private int spreadDuration = DEFAULT_SPREAD_DURATION;
    private int gatherDuration = DEFAULT_GATHER_DURATION;
    private int packetSize = DEFAULT_PACKET_SIZE;
    private boolean enableTargetAnimation = true;

    // 回调接口
    private AnimationListener animationListener;

    public interface AnimationListener {
        void onAnimationStart();
        void onPacketReceived(int receivedCount, int totalCount);
        void onAnimationEnd();
        void onAnimationError(String error);
    }

    /**
     * 构造方法
     */
    public RedPacketAnimation(Context context, ViewGroup container,
                              View sourceView, View targetView, int redPacketResId) {
        this.context = context;
        this.container = container;
        this.sourceView = sourceView;
        this.targetView = targetView;
        this.redPacketResId = redPacketResId;
        this.animationHandler = new Handler();
        this.random = new Random();
    }

    /**
     * 设置动画监听器
     */
    public void setAnimationListener(AnimationListener listener) {
        this.animationListener = listener;
    }

    /**
     * 设置红包数量范围
     */
    public void setPacketCountRange(int min, int max) {
        this.minPacketCount = min;
        this.maxPacketCount = max;
    }

    /**
     * 设置散开距离 - 限制最大值为120,确保扩散范围不会太大
     */
    public void setSpreadDistance(int distance) {
        this.spreadDistance = Math.min(distance, MAX_SPREAD_DISTANCE); // 限制最大扩散距离
    }

    /**
     * 设置动画时长
     */
    public void setAnimationDuration(int spreadDuration, int gatherDuration) {
        this.spreadDuration = spreadDuration;
        this.gatherDuration = gatherDuration;
    }

    /**
     * 设置红包大小
     */
    public void setPacketSize(int size) {
        this.packetSize = size;
    }

    /**
     * 设置是否启用目标视图动画
     */
    public void setEnableTargetAnimation(boolean enable) {
        this.enableTargetAnimation = enable;
    }

    /**
     * 开始红包动画
     */
    public void startAnimation() {
        if (container == null || sourceView == null || targetView == null) {
            notifyError("动画参数不完整");
            return;
        }

        // 清理之前的动画
        cleanup();

        // 通知动画开始
        if (animationListener != null) {
            animationListener.onAnimationStart();
        }

        // 确保视图已经完成布局后再执行动画
        container.post(new Runnable() {
            @Override
            public void run() {
                performAnimationCycle();
            }
        });
    }

    /**
     * 停止动画并清理资源
     */
    public void stopAnimation() {
        cleanup();
    }

    /**
     * 执行完整的动画周期
     */
    private void performAnimationCycle() {
        try {
            // 确保视图已经测量和布局完成
            if (sourceView.getWidth() == 0 || sourceView.getHeight() == 0) {
                // 如果视图尚未测量完成,延迟执行
                container.postDelayed(new Runnable() {
                    @Override
                    public void run() {
                        performAnimationCycle();
                    }
                }, 50);
                return;
            }

            // 获取视图位置 - 从按钮中心
            int[] sourceLocation = getViewCenterLocation(sourceView);
            int[] targetLocation = getViewCenterLocation(targetView);

            if (sourceLocation == null || targetLocation == null) {
                notifyError("无法获取视图位置");
                return;
            }

            int sourceX = sourceLocation[0];
            int sourceY = sourceLocation[1];
            int targetX = targetLocation[0];
            int targetY = targetLocation[1];

            // 随机生成红包数量
            int packetCount = minPacketCount + random.nextInt(maxPacketCount - minPacketCount + 1);

            final int[] receivedCount = {0};
            final int totalCount = packetCount;

            // 生成红包并执行动画
            for (int i = 0; i < packetCount; i++) {
                final ImageView redPacket = createRedPacketImage();
                container.addView(redPacket);

                // 设置初始位置在源按钮中心
                redPacket.setX(sourceX - redPacket.getWidth() / 2f);
                redPacket.setY(sourceY - redPacket.getHeight() / 2f);

                // 计算散开位置 - 从中心向四周散开,使用更小的范围
                float[] spreadPosition = calculateSpreadPosition(sourceX, sourceY, i, packetCount);

                // 延迟开始动画
                long delay = i * 50 + random.nextInt(100);

                animationHandler.postDelayed(() -> {
                    startSpreadAnimation(redPacket, spreadPosition[0], spreadPosition[1],
                            () -> startGatherAnimation(redPacket, targetX, targetY,
                                    () -> {
                                        receivedCount[0]++;
                                        notifyPacketReceived(receivedCount[0], totalCount);

                                        if (receivedCount[0] >= totalCount) {
                                            notifyAnimationEnd();
                                        }
                                    }));
                }, delay);
            }

        } catch (Exception e) {
            notifyError("动画执行异常: " + e.getMessage());
        }
    }

    /**
     * 开始散开动画 - 从中心向四周散开
     */
    private void startSpreadAnimation(ImageView redPacket, float targetX, float targetY, Runnable onEnd) {
        ObjectAnimator moveX = ObjectAnimator.ofFloat(redPacket, "x", redPacket.getX(), targetX);
        ObjectAnimator moveY = ObjectAnimator.ofFloat(redPacket, "y", redPacket.getY(), targetY);
        ObjectAnimator scaleUpX = ObjectAnimator.ofFloat(redPacket, "scaleX", 0.3f, 1f);
        ObjectAnimator scaleUpY = ObjectAnimator.ofFloat(redPacket, "scaleY", 0.3f, 1f);

        float rotation = random.nextFloat() * 60 - 30;
        ObjectAnimator rotationAnimator = ObjectAnimator.ofFloat(redPacket, "rotation", 0, rotation);

        AnimatorSet spreadSet = new AnimatorSet();
        spreadSet.playTogether(moveX, moveY, scaleUpX, scaleUpY, rotationAnimator);
        spreadSet.setDuration(spreadDuration);

        spreadSet.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {}

            @Override
            public void onAnimationEnd(Animator animation) {
                animationHandler.postDelayed(onEnd, 300);
            }

            @Override
            public void onAnimationCancel(Animator animation) {}

            @Override
            public void onAnimationRepeat(Animator animation) {}
        });

        spreadSet.start();
    }

    /**
     * 开始收拢动画
     */
    private void startGatherAnimation(ImageView redPacket, int targetX, int targetY, Runnable onEnd) {
        float targetCenterX = targetX - redPacket.getWidth() / 2f;
        float targetCenterY = targetY - redPacket.getHeight() / 2f;

        ObjectAnimator gatherX = ObjectAnimator.ofFloat(redPacket, "x", redPacket.getX(), targetCenterX);
        ObjectAnimator gatherY = ObjectAnimator.ofFloat(redPacket, "y", redPacket.getY(), targetCenterY);
        ObjectAnimator scaleDownX = ObjectAnimator.ofFloat(redPacket, "scaleX", 1f, 0.5f);
        ObjectAnimator scaleDownY = ObjectAnimator.ofFloat(redPacket, "scaleY", 1f, 0.5f);
        ObjectAnimator alphaAnimator = ObjectAnimator.ofFloat(redPacket, "alpha", 1f, 0f);
        ObjectAnimator rotationAnimator = ObjectAnimator.ofFloat(redPacket, "rotation",
                redPacket.getRotation(), redPacket.getRotation() + 180);

        AnimatorSet gatherSet = new AnimatorSet();
        gatherSet.playTogether(gatherX, gatherY, scaleDownX, scaleDownY, alphaAnimator, rotationAnimator);
        gatherSet.setDuration(gatherDuration);

        gatherSet.addListener(new Animator.AnimatorListener() {
            @Override
            public void onAnimationStart(Animator animation) {}

            @Override
            public void onAnimationEnd(Animator animation) {
                container.removeView(redPacket);

                // 触发目标视图动画
                if (enableTargetAnimation) {
                    animateTargetView();
                }

                onEnd.run();
            }

            @Override
            public void onAnimationCancel(Animator animation) {
                container.removeView(redPacket);
                onEnd.run();
            }

            @Override
            public void onAnimationRepeat(Animator animation) {}
        });

        gatherSet.start();
    }

    /**
     * 目标视图动画效果
     */
    private void animateTargetView() {
        ObjectAnimator scaleUpX = ObjectAnimator.ofFloat(targetView, "scaleX", 1f, 1.2f);
        ObjectAnimator scaleUpY = ObjectAnimator.ofFloat(targetView, "scaleY", 1f, 1.2f);
        ObjectAnimator scaleDownX = ObjectAnimator.ofFloat(targetView, "scaleX", 1.2f, 1f);
        ObjectAnimator scaleDownY = ObjectAnimator.ofFloat(targetView, "scaleY", 1.2f, 1f);

        AnimatorSet scaleUpSet = new AnimatorSet();
        scaleUpSet.playTogether(scaleUpX, scaleUpY);
        scaleUpSet.setDuration(150);

        AnimatorSet scaleDownSet = new AnimatorSet();
        scaleDownSet.playTogether(scaleDownX, scaleDownY);
        scaleDownSet.setDuration(150);

        AnimatorSet targetAnimation = new AnimatorSet();
        targetAnimation.playSequentially(scaleUpSet, scaleDownSet);
        targetAnimation.start();
    }

    /**
     * 计算散开位置 - 从中心向四周360度均匀散开,使用更小的范围
     */
    private float[] calculateSpreadPosition(int centerX, int centerY, int index, int totalCount) {
        // 计算均匀分布的角度
        double angle;
        if (totalCount <= 12) {
            // 对于少量红包,使用均匀分布
            angle = 2 * Math.PI * index / totalCount;
        } else {
            // 对于更多红包,使用随机角度
            angle = random.nextDouble() * 2 * Math.PI;
        }

        // 随机距离,确保在更小的指定范围内
        float distance = 20 + random.nextFloat() * (spreadDistance - 20); // 最小距离从30减小到20

        // 计算散开位置 - 从中心向四周
        float spreadX = (float) (centerX + distance * Math.cos(angle) - packetSize / 2f);
        float spreadY = (float) (centerY + distance * Math.sin(angle) - packetSize / 2f);

        return new float[]{spreadX, spreadY};
    }

    /**
     * 获取视图中心位置 - 使用getLocationOnScreen确保获取准确位置
     */
    private int[] getViewCenterLocation(View view) {
        int[] location = new int[2];
        try {
            // 使用getLocationOnScreen获取屏幕绝对坐标
            view.getLocationOnScreen(location);

            // 获取容器在屏幕上的位置
            int[] containerLocation = new int[2];
            container.getLocationOnScreen(containerLocation);

            // 计算相对于容器的坐标
            int relativeX = location[0] - containerLocation[0] + view.getWidth() / 2;
            int relativeY = location[1] - containerLocation[1] + view.getHeight() / 2;

            return new int[]{relativeX, relativeY};
        } catch (Exception e) {
            // 备用方案:使用getLocationInWindow
            try {
                view.getLocationInWindow(location);
                return new int[]{
                        location[0] + view.getWidth() / 2,
                        location[1] + view.getHeight() / 2
                };
            } catch (Exception ex) {
                return null;
            }
        }
    }

    /**
     * 创建红包图片
     */
    private ImageView createRedPacketImage() {
        ImageView redPacket = new ImageView(context);
        redPacket.setImageResource(redPacketResId);

        // 根据容器类型设置布局参数
        if (container instanceof RelativeLayout) {
            RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(packetSize, packetSize);
            redPacket.setLayoutParams(params);
        } else {
            ViewGroup.LayoutParams params = new ViewGroup.LayoutParams(packetSize, packetSize);
            redPacket.setLayoutParams(params);
        }

        redPacket.setTag(RED_PACKET_TAG);
        redPacket.setScaleType(ImageView.ScaleType.FIT_XY);
        redPacket.setScaleX(0.3f);
        redPacket.setScaleY(0.3f);
        redPacket.setAlpha(1f);
        return redPacket;
    }

    /**
     * 清理资源
     */
    private void cleanup() {
        if (animationHandler != null) {
            animationHandler.removeCallbacksAndMessages(null);
        }
        if (container != null) {
            // 只移除动态添加的红包视图,保留原有的按钮
            for (int i = container.getChildCount() - 1; i >= 0; i--) {
                View child = container.getChildAt(i);
                // 只移除动态添加的红包,不移除原有的按钮
                if (child.getTag() != null && child.getTag().equals(RED_PACKET_TAG)) {
                    container.removeView(child);
                }
            }
        }
    }

    /**
     * 通知回调方法
     */
    private void notifyError(String error) {
        if (animationListener != null) {
            animationListener.onAnimationError(error);
        }
    }

    private void notifyPacketReceived(int received, int total) {
        if (animationListener != null) {
            animationListener.onPacketReceived(received, total);
        }
    }

    private void notifyAnimationEnd() {
        if (animationListener != null) {
            animationListener.onAnimationEnd();
        }
    }
}

使用

java 复制代码
package com.zx.gncs;

import android.os.Bundle;
import android.util.Log;
import android.view.View;
import android.widget.ImageView;
import android.widget.RelativeLayout;
import android.widget.Toast;
import androidx.appcompat.app.AppCompatActivity;

public class MainActivity extends AppCompatActivity {

    private ImageView btn_send, btn_receive;
    private RelativeLayout container;
    private RedPacketAnimation redPacketAnimation;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);
        setContentView(R.layout.activity_main);

        // 初始化视图
        initViews();

        // 初始化红包动画
        initRedPacketAnimation();

        // 设置点击事件
        setupClickListeners();
    }

    private void initViews() {
        btn_send = findViewById(R.id.btn_send);
        btn_receive = findViewById(R.id.btn_receive);

        container = findViewById(R.id.container);

//        setupButtonPositionTest();
    }

    private void initRedPacketAnimation() {
        // 创建红包动画实例
        redPacketAnimation = new RedPacketAnimation(
                this,
                container,          // 动画容器
                btn_send,           // 发红包按钮 - 红包从这里中心散开
                btn_receive,        // 收红包按钮
                R.mipmap.hongbao    // 红包图片资源
        );

        // 自定义参数(可选)
        redPacketAnimation.setPacketCountRange(8, 12);
        redPacketAnimation.setSpreadDistance(200);
        redPacketAnimation.setAnimationDuration(800, 600);
        redPacketAnimation.setPacketSize(40);
        redPacketAnimation.setEnableTargetAnimation(true);

        // 设置动画监听
        redPacketAnimation.setAnimationListener(new RedPacketAnimation.AnimationListener() {
            @Override
            public void onAnimationStart() {
                Log.d("RedPacket", "红包动画开始");
                // 可以在这里禁用按钮,防止重复点击
                btn_send.setEnabled(false);
            }

            @Override
            public void onPacketReceived(int receivedCount, int totalCount) {
                Log.d("RedPacket", "接收进度: " + receivedCount + "/" + totalCount);
                // 可以在这里更新UI,显示接收进度
            }

            @Override
            public void onAnimationEnd() {
                Log.d("RedPacket", "红包动画结束");
                // 重新启用按钮
                btn_send.setEnabled(true);
                Toast.makeText(MainActivity.this, "红包发送完成!", Toast.LENGTH_SHORT).show();
            }

            @Override
            public void onAnimationError(String error) {
                Log.e("RedPacket", "动画错误: " + error);
                // 重新启用按钮
                btn_send.setEnabled(true);
                Toast.makeText(MainActivity.this, "动画出错: " + error, Toast.LENGTH_SHORT).show();
            }
        });
    }

    private void setupClickListeners() {
        // 点击发红包按钮
        btn_send.setOnClickListener(v -> {
            if (redPacketAnimation != null) {
                redPacketAnimation.startAnimation();
            }
        });

        // 测试移动按钮位置
//        findViewById(R.id.btn_move).setOnClickListener(v -> moveButtonPosition());
    }

    /**
     * 测试按钮位置变化的功能
     */
    private void setupButtonPositionTest() {
        // 添加一个移动按钮的测试按钮
        ImageView btnMove = new ImageView(this);
        btnMove.setImageResource(R.mipmap.hongbao);
        RelativeLayout.LayoutParams params = new RelativeLayout.LayoutParams(50, 50);
        params.addRule(RelativeLayout.CENTER_HORIZONTAL);
        params.addRule(RelativeLayout.CENTER_VERTICAL);
        btnMove.setLayoutParams(params);
        btnMove.setTag("btn_move");
        btnMove.setOnClickListener(v -> moveButtonPosition());
        container.addView(btnMove);
    }

    /**
     * 移动按钮位置测试
     */
    private void moveButtonPosition() {
        // 随机移动btn_send的位置
        RelativeLayout.LayoutParams params = (RelativeLayout.LayoutParams) btn_send.getLayoutParams();

        // 随机设置新的位置
        int randomMargin = (int) (Math.random() * 300);
        params.bottomMargin = randomMargin;

        // 随机设置水平位置
        params.addRule(RelativeLayout.CENTER_HORIZONTAL, 0); // 清除居中规则
        if (Math.random() > 0.5) {
            params.addRule(RelativeLayout.ALIGN_PARENT_LEFT);
            params.leftMargin = randomMargin;
        } else {
            params.addRule(RelativeLayout.ALIGN_PARENT_RIGHT);
            params.rightMargin = randomMargin;
        }

        btn_send.setLayoutParams(params);

        Toast.makeText(this, "按钮位置已改变,现在点击红包测试", Toast.LENGTH_SHORT).show();
    }

    @Override
    protected void onDestroy() {
        super.onDestroy();
        // 停止动画,避免内存泄漏
        if (redPacketAnimation != null) {
            redPacketAnimation.stopAnimation();
        }
    }
}

对应的xml布局

XML 复制代码
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
    xmlns:app="http://schemas.android.com/apk/res-auto"
    xmlns:tools="http://schemas.android.com/tools"
    android:layout_width="match_parent"
    android:layout_height="match_parent"
    android:background="#F5F5F5"
    android:id="@+id/container"
    tools:context=".MainActivity">

    <!-- 收红包按钮(顶部) -->
    <ImageView
        android:id="@+id/btn_receive"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_centerHorizontal="true"
        android:layout_alignParentTop="true"
        android:layout_marginTop="100dp"
        android:background="@mipmap/hongbao"
        android:contentDescription="收红包"
        android:scaleType="fitXY"
        android:clickable="true"
        android:focusable="true" />

    <!-- 发红包按钮(底部) -->
    <ImageView
        android:id="@+id/btn_send"
        android:layout_width="30dp"
        android:layout_height="30dp"
        android:layout_centerHorizontal="true"
        android:layout_alignParentBottom="true"
        android:layout_marginBottom="100dp"
        android:background="@mipmap/hongbao"
        android:contentDescription="发红包"
        android:scaleType="fitXY"
        android:clickable="true"
        android:focusable="true" />

</RelativeLayout>

对应的资源图片

实现原理

核心动画流程

  1. 位置计算 - 使用 getLocationOnScreen() 精确获取视图位置

  2. 红包创建 - 动态创建 ImageView 并设置初始位置

  3. 散开动画 - 从中心向四周360度均匀扩散

  4. 收拢动画 - 向目标位置聚集并消失

  5. 资源清理 - 动画结束后自动移除视图

相关推荐
杨筱毅3 小时前
【Android】【JNI多线程】JNI多线程安全、问题、性能常见卡点
android·jni
散人10243 小时前
Android Service 的一个细节
android·service
安卓蓝牙Vincent3 小时前
《Android BLE ScanSettings 完全解析:从参数到实战》
android
江上清风山间明月3 小时前
LOCAL_STATIC_ANDROID_LIBRARIES的作用
android·静态库·static_android
三少爷的鞋4 小时前
Android 中 `runBlocking` 其实只有一种使用场景
android
应用市场6 小时前
PHP microtime()函数精度问题深度解析与解决方案
android·开发语言·php
沐怡旸8 小时前
【Android】Dalvik 对比 ART
android·面试
消失的旧时光-19438 小时前
Android NDK 完全学习指南:从入门到精通
android
消失的旧时光-19439 小时前
Kotlin 协程实践:深入理解 SupervisorJob、CoroutineScope、Dispatcher 与取消机制
android·开发语言·kotlin