返回资源库

撒花效果

动画
特效
点击按钮查看撒花效果
组件代码
'use client';

import React, { forwardRef, useCallback, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react';

interface ConfettiProps {
  count?: number;
  colors?: string[];
  autoStart?: boolean;
  speed?: number;
}

export interface ConfettiHandle {
  restart: () => void;
}


interface ConfettiParticle {
  x: number;
  y: number;
  r: number;
  d: number;
  color: string;
  tilt: number;
  tiltAngleIncremental: number;
  tiltAngle: number;
  draw: (ctx: CanvasRenderingContext2D) => void;
}

interface ColorManager {
  colorOptions: string[];
  colorIndex: number;
  colorIncrementer: number;
  getColor: () => string;
}

const createParticle = (
  color: string, 
  width: number, 
  height: number, 
  count: number
): ConfettiParticle => {
  const randomFromTo = (from: number, to: number) => {
    return Math.floor(Math.random() * (to - from + 1) + from);
  };
  
  const x = Math.random() * width;
  const y = Math.random() * height - height;
  const r = randomFromTo(5, 15);
  const d = Math.random() * count + 10;
  const tilt = Math.floor(10 * Math.random()) - 10;
  const tiltAngleIncremental = 0.07 * Math.random() + 0.05;
  
  return {
    x,
    y,
    r,
    d,
    color,
    tilt,
    tiltAngleIncremental,
    tiltAngle: 0,
    draw(ctx: CanvasRenderingContext2D) {
      ctx.beginPath();
      ctx.lineWidth = this.r / 2;
      ctx.strokeStyle = this.color;
      ctx.moveTo(this.x + this.tilt + this.r / 4, this.y);
      ctx.lineTo(this.x + this.tilt, this.y + this.tilt + this.r / 4);
      ctx.stroke();
    }
  };
};

const Confetti = forwardRef<ConfettiHandle, ConfettiProps>(({
  count = 300,
  colors = [
    "DodgerBlue",
    "OliveDrab",
    "Gold",
    "pink",
    "SlateBlue",
    "lightblue",
    "Violet",
    "PaleGreen",
    "SteelBlue",
    "SandyBrown",
    "Chocolate",
    "Crimson"
  ],
  autoStart = false,
  speed = 1
}, ref) => {
  const canvasRef = useRef<HTMLCanvasElement>(null);
  const containerRef = useRef<HTMLDivElement>(null);
  const particlesRef = useRef<ConfettiParticle[]>([]);
  const animationHandlerRef = useRef<number>();
  const angleRef = useRef(0);
  const tiltAngleRef = useRef(0);
  
  const [confettiActive, setConfettiActive] = useState(false);
  const [animationComplete, setAnimationComplete] = useState(false);
  
  // 使用useMemo创建颜色管理器,避免重复创建
  const particleColorsRef = useRef<ColorManager>(useMemo(() => ({
    colorOptions: colors,
    colorIndex: 0,
    colorIncrementer: 0,
    getColor: function() {
      if (this.colorIncrementer >= 10) {
        this.colorIncrementer = 0;
        this.colorIndex++;
        if (this.colorIndex >= this.colorOptions.length) {
          this.colorIndex = 0;
        }
      }
      this.colorIncrementer++;
      return this.colorOptions[this.colorIndex];
    }
  }), [colors]));
  
  // 获取容器尺寸 
  const getContainerSize = useCallback(() => {
    if (!containerRef.current) return { width: 400, height: 400 };
    const rect = containerRef.current.getBoundingClientRect();
    return { width: rect.width || 400, height: rect.height || 400 };
  }, []);
  
  // 更新粒子位置 
  const stepParticle = useCallback((particle: ConfettiParticle, index: number) => {
    particle.tiltAngle += particle.tiltAngleIncremental;
    particle.y += ((Math.cos(angleRef.current + particle.d) + 3 + particle.r / 2) / 2) * speed;
    particle.x += Math.sin(angleRef.current) * speed;
    particle.tilt = 15 * Math.sin(particle.tiltAngle - index / 3);
  }, [speed]);
  
  // 重定位粒子
  const repositionParticle = useCallback((
    particle: ConfettiParticle, 
    x: number, 
    y: number, 
    tilt: number
  ) => {
    particle.x = x;
    particle.y = y;
    particle.tilt = tilt;
  }, []);
  
  // 检查是否需要重新定位粒子 
  const checkForReposition = useCallback((
    particle: ConfettiParticle, 
    index: number, 
    width: number, 
    height: number
  ) => {
    if ((particle.x > width + 20 || particle.x < -20 || particle.y > height) && confettiActive) {
      if (index % 5 > 0 || index % 2 === 0) {
        repositionParticle(
          particle,
          Math.random() * width,
          -10,
          Math.floor(10 * Math.random()) - 10
        );
      } else if (Math.sin(angleRef.current) > 0) {
        repositionParticle(
          particle,
          -5,
          Math.random() * height,
          Math.floor(10 * Math.random()) - 10
        );
      } else {
        repositionParticle(
          particle,
          width + 5,
          Math.random() * height,
          Math.floor(10 * Math.random()) - 10
        );
      }
    }
  }, [confettiActive, repositionParticle]);
  
  
  const clearTimers = useCallback(() => {
    if (animationHandlerRef.current) {
      cancelAnimationFrame(animationHandlerRef.current);
      animationHandlerRef.current = undefined;
    }
  }, []);
  
  const stopConfetti = useCallback(() => {
    setAnimationComplete(true);
    setConfettiActive(false);
    
    if (canvasRef.current) {
      const ctx = canvasRef.current.getContext('2d');
      if (ctx) {
        const { width, height } = getContainerSize();
        ctx.clearRect(0, 0, width, height);
      }
    }
    
    clearTimers();
  }, [clearTimers, getContainerSize]);
  
  // 绘制函数 
  const draw = useCallback(() => {
    if (!canvasRef.current) return;
    
    const ctx = canvasRef.current.getContext('2d');
    if (!ctx) return;
    
    const { width, height } = getContainerSize();
    
    ctx.clearRect(0, 0, width, height);
    
    for (let i = 0; i < particlesRef.current.length; i++) {
      particlesRef.current[i].draw(ctx);
    }
    
    update(width, height);
  }, [getContainerSize]);
  
  // 更新所有粒子 
  const update = useCallback((width: number, height: number) => {
    let remainingParticles = 0;
    angleRef.current += 0.01 * speed;
    tiltAngleRef.current += 0.1 * speed;
    
    for (let i = 0; i < particlesRef.current.length; i++) {
      const particle = particlesRef.current[i];
      
      if (animationComplete) return;
      
      if (!confettiActive && particle.y < -15) {
        particle.y = height + 100;
      } else {
        stepParticle(particle, i);
        if (particle.y <= height) {
          remainingParticles++;
        }
        checkForReposition(particle, i, width, height);
      }
    }
    
    // 当所有粒子离开视窗,自动重新开始新的撒花效果,而不是停止
    if (remainingParticles === 0) {
      // 重置粒子和位置
      particlesRef.current = [];
      angleRef.current = 0;
      tiltAngleRef.current = 0;
      
      // 重新初始化新的粒子
      const particleCount = Math.min(count, width > 300 ? count : count / 2);
      for (let i = 0; i < particleCount; i++) {
        const color = particleColorsRef.current.getColor();
        particlesRef.current.push(createParticle(color, width, height, count));
      }
    }
  }, [animationComplete, checkForReposition, confettiActive, count, stepParticle, speed]);
  
  // 初始化撒花效果
  const initializeConfetti = useCallback(() => {
    const { width, height } = getContainerSize();
    
    if (width <= 0 || height <= 0) {
      setTimeout(initializeConfetti, 100); // 如果尺寸无效,稍后重试
      return;
    }
    
    // 根据容器大小调整粒子数量
    const particleCount = Math.min(count, width > 300 ? count : count / 2);
    
    particlesRef.current = [];
    
    for (let i = 0; i < particleCount; i++) {
      const color = particleColorsRef.current.getColor();
      particlesRef.current.push(createParticle(color, width, height, count));
    }
    
    startConfetti();
  }, [count, getContainerSize]);
  
  const startConfetti = useCallback(() => {
    if (!canvasRef.current || !containerRef.current) return;
    
    const { width, height } = getContainerSize();
    if (width <= 0 || height <= 0) {
      setTimeout(startConfetti, 100);
      return;
    }
    
    // 确保canvas尺寸正确设置
    canvasRef.current.width = width;
    canvasRef.current.height = height;
    
    setConfettiActive(true);
    setAnimationComplete(false);
    
    const animate = () => {
      if (animationComplete) {
        return;
      }
      animationHandlerRef.current = requestAnimationFrame(animate);
      draw();
    };
    
    animate();
  }, [animationComplete, draw, getContainerSize]);
  
  // 重启撒花 
  const restartConfetti = useCallback(() => {
    // 先清除所有计时器和动画
    clearTimers();
    
    // 重置所有状态
    setAnimationComplete(false);
    setConfettiActive(false);
    
    // 清除画布
    if (canvasRef.current) {
      const ctx = canvasRef.current.getContext('2d');
      if (ctx) {
        const { width, height } = getContainerSize();
        ctx.clearRect(0, 0, width, height);
      }
    }
    
    // 重置粒子数组
    particlesRef.current = [];
    
    // 重置角度
    angleRef.current = 0;
    tiltAngleRef.current = 0;
    
    // 重置颜色管理器
    particleColorsRef.current.colorIndex = 0;
    particleColorsRef.current.colorIncrementer = 0;
    
    // 延迟一点时间确保状态已重置
    setTimeout(() => {
      setConfettiActive(true);
      setAnimationComplete(false);
      initializeConfetti();
    }, 100);
  }, [clearTimers, getContainerSize, initializeConfetti]);
  
  // 暴露方法给父组件
  useImperativeHandle(ref, () => ({
    restart: restartConfetti
  }), [restartConfetti]);
  
  // 处理容器尺寸变化
  useEffect(() => {
    const handleResize = () => {
      if (canvasRef.current && containerRef.current && !animationComplete) {
        const { width, height } = getContainerSize();
        if (width > 0 && height > 0) {
          canvasRef.current.width = width;
          canvasRef.current.height = height;
        }
      }
    };
    
    window.addEventListener('resize', handleResize);
    
    return () => {
      window.removeEventListener('resize', handleResize);
      clearTimers();
    };
  }, [animationComplete, clearTimers, getContainerSize]);
  
  // 确保在DOM渲染完成后再初始化
  useEffect(() => {
    // 
    const initTimer = setTimeout(() => {
      if (autoStart) {
        restartConfetti();
      }
    }, 300); // 增加延迟确保DOM已完全渲染
    
    return () => {
      clearTimeout(initTimer);
      clearTimers();
      stopConfetti();
    };
  }, [autoStart, clearTimers, restartConfetti, stopConfetti]);
  
  return (
    <div ref={containerRef} className="w-full h-full relative">
      <canvas
        ref={canvasRef}
        id="canvas-strips"
        className="absolute inset-0 pointer-events-none z-[999]"
        style={{ width: '100%', height: '100%' }}
      />
    </div>
  );
});

export default Confetti;