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. 资源清理 - 动画结束后自动移除视图

相关推荐
张拭心12 小时前
Android 17 来了!新特性介绍与适配建议
android·前端
Kapaseker15 小时前
Compose 进阶—巧用 GraphicsLayer
android·kotlin
黄林晴15 小时前
Android17 为什么重写 MessageQueue
android
阿巴斯甜1 天前
Android 报错:Zip file '/Users/lyy/develop/repoAndroidLapp/l-app-android-ble/app/bu
android
Kapaseker2 天前
实战 Compose 中的 IntrinsicSize
android·kotlin
xq95272 天前
Andorid Google 登录接入文档
android
黄林晴2 天前
告别 Modifier 地狱,Compose 样式系统要变天了
android·android jetpack
冬奇Lab2 天前
Android触摸事件分发、手势识别与输入优化实战
android·源码阅读
城东米粉儿2 天前
Android MediaPlayer 笔记
android
Jony_2 天前
Android 启动优化方案
android