Flutter for OpenHarmony 豪华抽奖应用:从粒子背景到彩带动画的全栈实现

Flutter for OpenHarmony 豪华抽奖应用:从粒子背景到彩带动画的全栈实现

在数字娱乐场景中,抽奖系统 始终是调动用户情绪、增强参与感的利器。而一个真正令人印象深刻的抽奖应用,不仅需要逻辑严谨的随机算法,更依赖于沉浸式的视觉反馈富有张力的动效设计 。本文将深度解析一段完整的 Flutter 抽奖应用代码,带你从零构建一个集 动态粒子背景、弹性缩放动画、滑动删除交互、彩带庆祝特效 于一体的"豪华抽奖"体验。

🌐 加入社区 欢迎加入 开源鸿蒙跨平台开发者社区 ,获取最新资源与技术支持: 👉 开源鸿蒙跨平台开发者社区


完整效果

一、整体架构:多动画协同的复杂状态管理

该应用采用 StatefulWidget + TickerProviderStateMixin 架构,同时驱动 4 个独立的 AnimationController

控制器 用途 持续时间 特点
_spinController 模拟抽奖旋转过程 5 秒(3秒快+2秒慢) 非真实旋转,通过延迟模拟
_particleController 背景粒子流动 16ms 循环 制造星空流动感
_scaleController 获胜者卡片弹性放大 800ms 使用 Curves.elasticOut
_confettiController 彩带动画播放 3 秒 控制彩带生命周期

💡 核心挑战:协调多个动画的触发时机与状态同步,避免视觉混乱。


二、核心流程:三阶段抽奖逻辑

1. 快速闪烁阶段(3秒)

dart 复制代码
for (int i = 0; i < _spinDuration * 20; i++) {
  await Future.delayed(const Duration(milliseconds: 50));
}
  • 目的:制造"高速滚动"假象;
  • 实现 :不实际更新 UI,仅消耗时间(因 _winner = '',卡片显示默认内容);
  • 频率:每 50ms 一次,共 60 次,形成视觉残影效果。

2. 慢速悬念阶段(2秒)

dart 复制代码
for (int i = 0; i < _slowDownDuration * 5; i++) {
  await Future.delayed(const Duration(milliseconds: 200));
}
  • 心理设计:放慢节奏,增加期待感;
  • 技术留白:为最终揭晓做铺垫。

3. 最终揭晓与庆祝

dart 复制代码
setState(() {
  _winner = winner;
  _isSpinning = false;
});
_triggerCelebration(); // 触发光效+彩带
  • 即时反馈 :设置 _winner 后,UI 自动更新为获胜者信息;
  • 情感峰值:同步启动弹性缩放与彩带动画,打造高潮时刻。

⚠️ 注意:整个过程使用 async/await 保证顺序执行,避免竞态条件。


三、视觉盛宴:四大动效系统详解

1. 动态粒子背景(星空流动)

dart 复制代码
class Particle {
  double x, y; // 归一化坐标 [0,1]
  final double speed; // 垂直下落速度
  final Color color;  // 随机主色系,alpha=0.3
}

void paint(Canvas canvas, Size size) {
  particle.y += particle.speed * animationValue;
  if (particle.y > 1) particle.y = 0; // 循环重置
  canvas.drawCircle(Offset(x*size.width, y*size.height), ...);
}
  • 无限循环:粒子从顶部重生,营造永不停歇的宇宙感;
  • 低干扰设计:半透明小圆点,不喧宾夺主。

2. 获胜者高光效果(弹性缩放 + 光晕)

dart 复制代码
// 弹性动画
_scaleAnimation = Tween(begin: 1.0, end: 1.5).animate(
  CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut)
);

// 光晕绘制
Container(
  width: 300 * _scaleAnimation.value,
  decoration: BoxDecoration(
    gradient: RadialGradient(colors: [Colors.amber@0.3, transparent])
  )
)
  • 物理感elasticOut 曲线模拟弹簧回弹;
  • 氛围营造:径向渐变光晕强化"焦点"效果。

3. 彩带庆祝动画(自定义粒子系统)

dart 复制代码
class Confetti {
  double x, y;        // 初始位置(y=-0.1 在屏幕外)
  double speedX, speedY; // 随机抛物线速度
  double rotationSpeed;  // 自旋速度
  Color color;           // 7种鲜艳颜色
}

void paint(Canvas canvas, Size size) {
  final progress = c.y + animationValue * 0.5; // 控制下落进度
  canvas.translate(x*size.width, progress*size.height);
  canvas.rotate(c.rotation + animationValue * c.rotationSpeed * 10);
  canvas.drawRect(...); // 绘制彩色矩形(模拟彩纸)
}
  • 真实感:每个彩带独立运动轨迹 + 自旋;
  • 淡出效果alpha = (1 - progress).clamp(0,1) 实现自然消失;
  • 性能优化 :3秒后自动清空 _confetti 列表,释放内存。

4. 交互反馈动效

  • 添加参与者 :输入框轻微弹性放大(复用 _scaleController);
  • 按钮状态 :抽奖中显示 CircularProgressIndicator,禁用点击;
  • 阴影变化 :获胜时卡片阴影扩散(blurRadius: 40, spreadRadius: 10)。

四、UI/UX 设计亮点

1. 深色主题 + 琥珀强调色

  • 主题配置

    dart 复制代码
    ThemeData(
      brightness: Brightness.dark,
      colorSchemeSeed: Colors.amber, // 自动生成 amber 主色调
      useMaterial3: true,
    )
  • 色彩心理学 :琥珀色(Amber)象征幸运、财富与庆典,契合抽奖场景。

2. 分层布局结构

dart 复制代码
Stack(
  children: [
    ParticlePainter(), // 底层:动态背景
    Column(
      children: [
        Expanded(flex: 2, child: LotteryCard()), // 上区:抽奖展示
        Expanded(flex: 3, child: ControlPanel()), // 下区:控制面板
      ]
    )
  ]
)
  • 视觉重心:上 2 / 下 3 的比例,突出抽奖结果;
  • 毛玻璃效果 :控制面板使用 surface@0.9 半透明背景,层次分明。

3. 参与者列表交互

  • 序号标识CircleAvatar 显示参与顺序;
  • 获胜高亮:名字变金色 + 🎯 图标 + 加粗字体;
  • 滑动删除:左滑显示红色删除背景,符合 Material Design 手势规范;
  • 空状态引导:无参与者时显示友好提示图标与文案。

4. 帮助系统

  • 集成说明 :通过 AppBar 的 auto_awesome 图标打开使用指南;
  • 图文并茂:每个功能配图标与简短描述,降低学习成本。

五、代码工程实践

1. 状态管理清晰

  • 单一数据源_participants 列表集中管理所有参与者;
  • 状态隔离_isSpinning 防止重复抽奖;
  • 副作用处理 :删除参与者时检查是否为当前获胜者,自动清空 _winner

2. 资源安全释放

dart 复制代码
@override
void dispose() {
  _spinController.dispose();
  _particleController.dispose();
  _scaleController.dispose();
  _confettiController.dispose();
  _controller.dispose(); // TextField 控制器
  super.dispose();
}
  • 避免内存泄漏:所有控制器与监听器均正确 dispose。

3. 可扩展性设计

  • 粒子/彩带类解耦ParticleConfetti 为纯数据类,Painter 专注绘制;
  • 动画参数常量化_spinDuration, _slowDownDuration 便于调整节奏;
  • 主题一致性 :全程使用 Theme.of(context).colorScheme 获取颜色,支持动态换肤。

六、性能与体验优化

问题 解决方案
长列表卡顿 使用 ListView.separated 按需构建
动画掉帧 粒子数量限制(50背景 + 100彩带),避免过度绘制
误操作 抽奖中禁用按钮 + 清空确认(虽未实现,但预留空间)
视觉疲劳 动画结束后自动清理彩带,回归简洁界面

七、扩展方向:从 Demo 到产品

  1. 历史记录:保存历次抽奖结果,支持回溯;
  2. 权重抽奖:为不同参与者设置中奖概率;
  3. 音效集成:添加旋转音效、揭晓欢呼声;
  4. 分享功能:生成获胜者海报,一键分享至社交平台;
  5. 多人协作:通过 Firebase 实现实时多人参与抽奖。

结语:用代码编织庆典时刻

这个"豪华抽奖"应用远不止是一个随机选择器------它是一场精心编排的数字仪式 。从背景粒子的静谧流动,到抽奖过程的紧张悬念,再到揭晓瞬间的彩带纷飞,每一个细节都在诉说着同一个故事:技术可以很温暖,代码也能传递喜悦

完整代码

bash 复制代码
import 'dart:math';
import 'package:flutter/material.dart';

void main() => runApp(const LotteryApp());

class LotteryApp extends StatelessWidget {
  const LotteryApp({super.key});

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      title: '豪华抽奖',
      theme: ThemeData(
        useMaterial3: true,
        colorSchemeSeed: Colors.amber,
        brightness: Brightness.dark,
      ),
      home: const LotteryPage(),
    );
  }
}

class LotteryPage extends StatefulWidget {
  const LotteryPage({super.key});

  @override
  State<LotteryPage> createState() => _LotteryPageState();
}

class _LotteryPageState extends State<LotteryPage>
    with TickerProviderStateMixin {
  final List<String> _participants = [];
  final TextEditingController _controller = TextEditingController();

  String _winner = '';
  bool _isSpinning = false;

  late AnimationController _spinController;
  late AnimationController _particleController;
  late AnimationController _scaleController;
  late AnimationController _confettiController;

  late Animation<double> _scaleAnimation;
  late Animation<double> _confettiAnimation;

  final List<Particle> _particles = [];
  final List<Confetti> _confetti = [];
  final Random _random = Random();

  static const int _spinDuration = 3; // 秒
  static const int _slowDownDuration = 2; // 秒

  @override
  void initState() {
    super.initState();

    _spinController = AnimationController(
      duration: const Duration(seconds: _spinDuration + _slowDownDuration),
      vsync: this,
    );

    _particleController = AnimationController(
      duration: const Duration(milliseconds: 16),
      vsync: this,
    )..repeat();

    _scaleController = AnimationController(
      duration: const Duration(milliseconds: 800),
      vsync: this,
    );

    _confettiController = AnimationController(
      duration: const Duration(seconds: 3),
      vsync: this,
    );

    _scaleAnimation = Tween<double>(begin: 1.0, end: 1.5).animate(
      CurvedAnimation(parent: _scaleController, curve: Curves.elasticOut),
    );

    _confettiAnimation = CurvedAnimation(
      parent: _confettiController,
      curve: Curves.easeOut,
    );

    // 初始化背景粒子
    _initBackgroundParticles();
  }

  void _initBackgroundParticles() {
    for (int i = 0; i < 50; i++) {
      _particles.add(Particle.random(_random));
    }
  }

  @override
  void dispose() {
    _spinController.dispose();
    _particleController.dispose();
    _scaleController.dispose();
    _confettiController.dispose();
    _controller.dispose();
    super.dispose();
  }

  void _addParticipant() {
    if (_controller.text.trim().isNotEmpty) {
      setState(() {
        _participants.add(_controller.text.trim());
        _controller.clear();
      });
      _showAddAnimation();
    }
  }

  void _removeParticipant(int index) {
    setState(() {
      _participants.removeAt(index);
      if (_winner == _participants[index]) {
        _winner = '';
      }
    });
  }

  void _showAddAnimation() {
    _scaleController.forward().then((_) {
      _scaleController.reverse();
    });
  }

  Future<void> _drawWinner() async {
    if (_participants.isEmpty || _isSpinning) return;

    setState(() {
      _isSpinning = true;
      _winner = '';
      _confetti.clear();
    });

    // 第一阶段:快速旋转
    _spinController.forward(from: 0);

    // 模拟名字闪烁效果
    for (int i = 0; i < _spinDuration * 20; i++) {
      await Future.delayed(const Duration(milliseconds: 50));
    }

    // 第二阶段:慢速旋转(增加悬念)
    for (int i = 0; i < _slowDownDuration * 5; i++) {
      await Future.delayed(const Duration(milliseconds: 200));
    }

    // 选出获胜者
    final winner = _participants[_random.nextInt(_participants.length)];

    // 最终揭晓
    for (int i = 0; i < 10; i++) {
      await Future.delayed(const Duration(milliseconds: 100));
    }

    // 显示最终结果
    setState(() {
      _winner = winner;
      _isSpinning = false;
    });

    // 触发庆祝动画
    _triggerCelebration();

    // 重置动画控制器
    _spinController.reset();
  }

  void _triggerCelebration() {
    _scaleController.forward().then((_) {
      _scaleController.reverse();
    });

    // 生成彩带
    for (int i = 0; i < 100; i++) {
      _confetti.add(Confetti.random(_random));
    }

    _confettiController.forward(from: 0).then((_) {
      setState(() {
        _confetti.clear();
      });
      _confettiController.reset();
    });
  }

  @override
  Widget build(BuildContext context) {
    return Scaffold(
      appBar: AppBar(
        title: const Text('🎰 豪华抽奖'),
        actions: [
          IconButton(
            icon: const Icon(Icons.auto_awesome),
            onPressed: () => _showHelpDialog(),
            tooltip: '帮助',
          ),
        ],
      ),
      body: Stack(
        children: [
          // 背景粒子
          AnimatedBuilder(
            animation: _particleController,
            builder: (context, child) {
              return CustomPaint(
                painter: ParticlePainter(_particles, _particleController.value),
                size: Size.infinite,
              );
            },
          ),

          // 主要内容
          Column(
            children: [
              // 抽奖展示区
              Expanded(
                flex: 2,
                child: Center(
                  child: AnimatedBuilder(
                    animation:
                        Listenable.merge([_scaleAnimation, _confettiAnimation]),
                    builder: (context, child) {
                      return Stack(
                        alignment: Alignment.center,
                        children: [
                          // 光晕效果
                          if (_winner.isNotEmpty)
                            Container(
                              width: 300 * _scaleAnimation.value,
                              height: 300 * _scaleAnimation.value,
                              decoration: BoxDecoration(
                                shape: BoxShape.circle,
                                gradient: RadialGradient(
                                  colors: [
                                    Colors.amber.withValues(alpha: 0.3),
                                    Colors.transparent,
                                  ],
                                ),
                              ),
                            ),

                          // 彩带层
                          if (_confetti.isNotEmpty)
                            CustomPaint(
                              painter: ConfettiPainter(
                                _confetti,
                                _confettiAnimation.value,
                              ),
                              size: Size.infinite,
                            ),

                          // 抽奖卡片
                          _buildLotteryCard(),
                        ],
                      );
                    },
                  ),
                ),
              ),

              // 控制区
              Expanded(
                flex: 3,
                child: Container(
                  decoration: BoxDecoration(
                    color: Theme.of(context)
                        .colorScheme
                        .surface
                        .withValues(alpha: 0.9),
                    borderRadius:
                        const BorderRadius.vertical(top: Radius.circular(24)),
                  ),
                  padding: const EdgeInsets.all(24),
                  child: Column(
                    children: [
                      // 输入框
                      Row(
                        children: [
                          Expanded(
                            child: TextField(
                              controller: _controller,
                              decoration: InputDecoration(
                                labelText: '输入参与者名字',
                                labelStyle: TextStyle(
                                  color: Theme.of(context).colorScheme.primary,
                                ),
                                filled: true,
                                fillColor: Theme.of(context)
                                    .colorScheme
                                    .surfaceContainerHighest,
                                border: OutlineInputBorder(
                                  borderRadius: BorderRadius.circular(12),
                                  borderSide: BorderSide.none,
                                ),
                                prefixIcon: const Icon(Icons.person),
                                suffixIcon: IconButton(
                                  icon: const Icon(Icons.add_circle),
                                  onPressed: _addParticipant,
                                  color: Theme.of(context).colorScheme.primary,
                                ),
                              ),
                              onSubmitted: (_) => _addParticipant(),
                            ),
                          ),
                        ],
                      ),

                      const SizedBox(height: 16),

                      // 抽奖按钮
                      SizedBox(
                        width: double.infinity,
                        height: 60,
                        child: ElevatedButton(
                          onPressed: _isSpinning ? null : _drawWinner,
                          style: ElevatedButton.styleFrom(
                            backgroundColor:
                                Theme.of(context).colorScheme.primary,
                            foregroundColor:
                                Theme.of(context).colorScheme.onPrimary,
                            shape: RoundedRectangleBorder(
                              borderRadius: BorderRadius.circular(16),
                            ),
                            elevation: _isSpinning ? 8 : 4,
                          ),
                          child: Row(
                            mainAxisAlignment: MainAxisAlignment.center,
                            children: [
                              if (_isSpinning)
                                const SizedBox(
                                  width: 24,
                                  height: 24,
                                  child: CircularProgressIndicator(
                                    strokeWidth: 2,
                                    valueColor:
                                        AlwaysStoppedAnimation(Colors.white),
                                  ),
                                )
                              else
                                const Icon(Icons.casino, size: 28),
                              const SizedBox(width: 12),
                              Text(
                                _isSpinning ? '抽奖中...' : '开始抽奖',
                                style: const TextStyle(
                                  fontSize: 20,
                                  fontWeight: FontWeight.bold,
                                ),
                              ),
                            ],
                          ),
                        ),
                      ),

                      const SizedBox(height: 16),

                      // 参与者列表
                      Row(
                        mainAxisAlignment: MainAxisAlignment.spaceBetween,
                        children: [
                          Text(
                            '参与者 (${_participants.length})',
                            style: Theme.of(context)
                                .textTheme
                                .titleMedium
                                ?.copyWith(
                                  fontWeight: FontWeight.bold,
                                ),
                          ),
                          if (_participants.isNotEmpty)
                            TextButton.icon(
                              onPressed: () {
                                setState(() {
                                  _participants.clear();
                                  _winner = '';
                                });
                              },
                              icon: const Icon(Icons.clear_all, size: 16),
                              label: const Text('清空'),
                              style: TextButton.styleFrom(
                                foregroundColor:
                                    Theme.of(context).colorScheme.error,
                              ),
                            ),
                        ],
                      ),

                      const SizedBox(height: 8),

                      Expanded(
                        child: _participants.isEmpty
                            ? Center(
                                child: Column(
                                  mainAxisAlignment: MainAxisAlignment.center,
                                  children: [
                                    Icon(
                                      Icons.people_outline,
                                      size: 64,
                                      color:
                                          Theme.of(context).colorScheme.outline,
                                    ),
                                    const SizedBox(height: 16),
                                    Text(
                                      '还没有参与者',
                                      style: Theme.of(context)
                                          .textTheme
                                          .bodyLarge
                                          ?.copyWith(
                                            color: Theme.of(context)
                                                .colorScheme
                                                .outline,
                                          ),
                                    ),
                                  ],
                                ),
                              )
                            : ListView.separated(
                                itemCount: _participants.length,
                                separatorBuilder: (_, __) =>
                                    const Divider(height: 1),
                                itemBuilder: (context, index) {
                                  final isWinner =
                                      _winner == _participants[index];
                                  return Dismissible(
                                    key: Key(_participants[index]),
                                    direction: DismissDirection.endToStart,
                                    onDismissed: (_) =>
                                        _removeParticipant(index),
                                    background: Container(
                                      alignment: Alignment.centerRight,
                                      padding: const EdgeInsets.only(right: 16),
                                      color: Theme.of(context)
                                          .colorScheme
                                          .errorContainer,
                                      child: Icon(
                                        Icons.delete_outline,
                                        color:
                                            Theme.of(context).colorScheme.error,
                                      ),
                                    ),
                                    child: ListTile(
                                      leading: CircleAvatar(
                                        backgroundColor: isWinner
                                            ? Colors.amber
                                            : Theme.of(context)
                                                .colorScheme
                                                .primaryContainer,
                                        child: Text(
                                          '${index + 1}',
                                          style: TextStyle(
                                            color: isWinner
                                                ? Colors.black
                                                : Theme.of(context)
                                                    .colorScheme
                                                    .onPrimaryContainer,
                                            fontWeight: FontWeight.bold,
                                          ),
                                        ),
                                      ),
                                      title: Text(
                                        _participants[index],
                                        style: TextStyle(
                                          fontWeight: isWinner
                                              ? FontWeight.bold
                                              : FontWeight.normal,
                                          color: isWinner ? Colors.amber : null,
                                        ),
                                      ),
                                      trailing: isWinner
                                          ? const Icon(Icons.emoji_events,
                                              color: Colors.amber)
                                          : null,
                                    ),
                                  );
                                },
                              ),
                      ),
                    ],
                  ),
                ),
              ),
            ],
          ),
        ],
      ),
    );
  }

  Widget _buildLotteryCard() {
    return Container(
      width: 280,
      height: 280,
      decoration: BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topLeft,
          end: Alignment.bottomRight,
          colors: [
            Theme.of(context).colorScheme.primaryContainer,
            Theme.of(context).colorScheme.secondaryContainer,
          ],
        ),
        borderRadius: BorderRadius.circular(24),
        boxShadow: [
          BoxShadow(
            color: _winner.isNotEmpty
                ? Colors.amber.withValues(alpha: 0.5)
                : Colors.black.withValues(alpha: 0.3),
            blurRadius: _winner.isNotEmpty ? 40 : 20,
            spreadRadius: _winner.isNotEmpty ? 10 : 0,
          ),
        ],
        border: Border.all(
          color: _winner.isNotEmpty ? Colors.amber : Colors.transparent,
          width: _winner.isNotEmpty ? 3 : 0,
        ),
      ),
      child: Center(
        child: Column(
          mainAxisAlignment: MainAxisAlignment.center,
          children: [
            if (_winner.isEmpty) ...[
              Icon(
                Icons.card_giftcard,
                size: 64,
                color: Theme.of(context)
                    .colorScheme
                    .primary
                    .withValues(alpha: 0.7),
              ),
              const SizedBox(height: 16),
              Text(
                '点击下方按钮开始抽奖',
                style: Theme.of(context).textTheme.titleMedium?.copyWith(
                      color: Theme.of(context).colorScheme.onSurfaceVariant,
                    ),
                textAlign: TextAlign.center,
              ),
            ] else ...[
              const Icon(Icons.emoji_events, size: 64, color: Colors.amber),
              const SizedBox(height: 16),
              Text(
                '🎉 恭喜 🎉',
                style: Theme.of(context).textTheme.headlineSmall?.copyWith(
                      color: Colors.amber,
                      fontWeight: FontWeight.bold,
                    ),
              ),
              const SizedBox(height: 8),
              Text(
                _winner,
                style: Theme.of(context).textTheme.displaySmall?.copyWith(
                      fontWeight: FontWeight.bold,
                    ),
                textAlign: TextAlign.center,
              ),
            ],
          ],
        ),
      ),
    );
  }

  void _showHelpDialog() {
    showDialog(
      context: context,
      builder: (context) => AlertDialog(
        title: const Text('使用说明'),
        content: const SingleChildScrollView(
          child: Column(
            crossAxisAlignment: CrossAxisAlignment.start,
            mainAxisSize: MainAxisSize.min,
            children: [
              ListTile(
                leading: Icon(Icons.add_circle, color: Colors.amber),
                title: Text('添加参与者'),
                subtitle: Text('输入名字后点击添加图标或按回车'),
              ),
              ListTile(
                leading: Icon(Icons.casino, color: Colors.amber),
                title: Text('开始抽奖'),
                subtitle: Text('点击开始抽奖按钮,享受动画效果'),
              ),
              ListTile(
                leading: Icon(Icons.delete_outline, color: Colors.red),
                title: Text('删除参与者'),
                subtitle: Text('向左滑动参与者卡片即可删除'),
              ),
              ListTile(
                leading: Icon(Icons.auto_awesome, color: Colors.purple),
                title: Text('庆祝动画'),
                subtitle: Text('抽奖结果揭晓时会有彩带特效'),
              ),
            ],
          ),
        ),
        actions: [
          TextButton(
            onPressed: () => Navigator.pop(context),
            child: const Text('知道了'),
          ),
        ],
      ),
    );
  }
}

// 粒子类
class Particle {
  double x;
  double y;
  final double size;
  final double speed;
  final double angle;
  final Color color;

  Particle.random(Random random)
      : x = random.nextDouble(),
        y = random.nextDouble(),
        size = random.nextDouble() * 3 + 1,
        speed = random.nextDouble() * 0.002 + 0.001,
        angle = random.nextDouble() * pi * 2,
        color = Colors.primaries[random.nextInt(Colors.primaries.length)]
            .withValues(alpha: 0.3);
}

// 粒子绘制器
class ParticlePainter extends CustomPainter {
  final List<Particle> particles;
  final double animationValue;

  ParticlePainter(this.particles, this.animationValue);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..style = PaintingStyle.fill;

    for (var particle in particles) {
      particle.y += particle.speed * animationValue;
      if (particle.y > 1) particle.y = 0;

      final x = particle.x * size.width;
      final y = particle.y * size.height;
      paint.color = particle.color;
      canvas.drawCircle(Offset(x, y), particle.size, paint);
    }
  }

  @override
  bool shouldRepaint(ParticlePainter oldDelegate) => true;
}

// 彩带类
class Confetti {
  final double x;
  final double y;
  final double size;
  final double rotation;
  final double speedX;
  final double speedY;
  final double rotationSpeed;
  final Color color;

  Confetti.random(Random random)
      : x = random.nextDouble(),
        y = -0.1,
        size = random.nextDouble() * 10 + 5,
        rotation = random.nextDouble() * pi * 2,
        speedX = (random.nextDouble() - 0.5) * 0.01,
        speedY = random.nextDouble() * 0.01 + 0.005,
        rotationSpeed = (random.nextDouble() - 0.5) * 0.2,
        color = [
          Colors.red,
          Colors.blue,
          Colors.green,
          Colors.yellow,
          Colors.purple,
          Colors.orange,
          Colors.pink,
        ][random.nextInt(7)]
            .withValues(alpha: 0.8);
}

// 彩带绘制器
class ConfettiPainter extends CustomPainter {
  final List<Confetti> confetti;
  final double animationValue;

  ConfettiPainter(this.confetti, this.animationValue);

  @override
  void paint(Canvas canvas, Size size) {
    final paint = Paint()..style = PaintingStyle.fill;

    for (var c in confetti) {
      final progress = c.y + animationValue * 0.5;
      final x = c.x * size.width;
      final y = progress * size.height;

      canvas.save();
      canvas.translate(x, y);
      canvas.rotate(c.rotation + animationValue * c.rotationSpeed * 10);

      paint.color = c.color.withValues(alpha: (1 - progress).clamp(0, 1));
      canvas.drawRect(
        Rect.fromCenter(
          center: Offset.zero,
          width: c.size * 2,
          height: c.size,
        ),
        paint,
      );

      canvas.restore();
    }
  }

  @override
  bool shouldRepaint(ConfettiPainter oldDelegate) => true;
}
相关推荐
近津薪荼2 小时前
dfs专题——二叉树的深搜3(二叉树剪枝)
c++·学习·算法·深度优先
云和数据.ChenGuang2 小时前
python 面向对象基础入门
开发语言·前端·python·django·flask
啊阿狸不会拉杆2 小时前
《机器学习导论》第 2 章-监督学习
数据结构·人工智能·python·学习·算法·机器学习·监督学习
知识分享小能手2 小时前
SQL Server 2019入门学习教程,从入门到精通,SQL Server 2019 数据表的操作 —语法详解与实战案例(3)
数据库·学习·sqlserver
铅笔侠_小龙虾2 小时前
浅谈 Vue & React & Flutter 框架
vue.js·flutter·react.js
Hill_HUIL2 小时前
学习日志25-OSPF协议工作原理
学习
We་ct2 小时前
LeetCode 202. 快乐数:题解+思路拆解
前端·算法·leetcode·typescript
HWL56792 小时前
控制浏览器如何预先加载视频资源
java·服务器·前端
HWL56792 小时前
在网页中实现WebM格式视频自动循环播放
前端·css·html·excel·音视频