Flutter开发鸿蒙年味 + 实用实战应用|绿色烟花:电子烟花 + 手持烟花

欢迎加入开源鸿蒙跨平台社区

在这里Flutter开发鸿蒙年味 + 实用实战应用|搭建【多 Tab 应用】基础工程 + 实现【弧形底部导航】构建了多个底部选项卡,在这里Flutter开发鸿蒙年味 + 实用实战应用|春节祝福:列表选卡 + 贴纸拖动 + 截图分享完成了春节祝福选项卡功能。接下来基于 lottie (电子烟花 JSON 动画)与 flutter_animator(手持烟花粒子效果),在「绿色烟花」选项卡中实现两种风格:电子烟花(Lottie 本地资源)、手持烟花(点击位置棍子 + 锥形火花)。

一、📦 库选型与适用场景

1.1 本实战用到的库

库名 核心价值 适用场景
lottie 解析并渲染 After Effects 导出的 JSON 动画,纯 Dart、多端通用 电子烟花:加载本地 lottie_firework.json,无音效循环播放
flutter_animator Animate.css 风格预置动画,FadeOut 等、少样板代码 手持烟花:棍子 + 锥形火花流,点击任意位置在该处显示,金/黄/白/橙配色

为什么选这两种:Lottie 负责「现成 JSON 电子烟花」、flutter_animator 负责「点击即现的手持烟花」粒子效果,两种风格用 Tab 切换展示。

1.2 整体流程概览

📦 库选型
🔧 环境与依赖
📥 集成与资源
🎆 两种风格实现
📤 鸿蒙打包与优化


二、🔧 环境适配

2.1 支持的 Flutter 版本

最低 Flutter / Dart 本教程验证
lottie ^3.3.0 Dart 2.12+ Flutter 3.x + Dart 3.x ✅
flutter_animator ^3.2.2 Flutter >=3 Flutter 3.x ✅

2.2 多端适配

  • lottie :纯 Dart 解析与渲染,Android / iOS / 鸿蒙 / Web / 桌面均可。
  • flutter_animator:纯 Flutter 组件,多端一致。

三、📥 集成步骤

3.1 pubspec.yaml 配置

在项目根目录的 pubspec.yamldependencies: 下增加:

yaml 复制代码
dependencies:
  flutter:
    sdk: flutter
  lottie: ^3.3.0
  flutter_animator: ^3.2.2

flutter: 下声明资源目录:

yaml 复制代码
flutter:
  uses-material-design: true
  assets:
    - assets/

为什么配置 assets :电子烟花使用本地 assets/lottie_firework.json,需在此声明。

3.2 依赖安装

bash 复制代码
flutter pub get

3.3 页面顶部导入

绿色烟花页(lib/pages/firework_page.dart)顶部:

dart 复制代码
import 'dart:math';

import 'package:flutter/material.dart';
import 'package:font_awesome_flutter/font_awesome_flutter.dart';
import 'package:lottie/lottie.dart';
import 'package:flutter_animator/flutter_animator.dart';

四、🎆 两种风格实现与完整代码

整体设计:绿色烟花页 提供两个 Tab------电子烟花 (Lottie)、手持烟花 (点击位置棍子+火花);根据 _styleIndex 切换子视图。

4.1 页面骨架与 Tab 切换(完整代码)

为什么用 StatefulWidget :需要维护当前选中的 Tab 索引 _styleIndex,以及手持烟花侧的点击位置与重播 key。

dart 复制代码
/// 绿色烟花页:电子烟花(Lottie)、手持烟花(粒子)
class FireworkPage extends StatefulWidget {
  const FireworkPage({super.key});

  @override
  State<FireworkPage> createState() => _FireworkPageState();
}

class _FireworkPageState extends State<FireworkPage> {
  /// 当前风格:0 电子烟花(Lottie),1 手持烟花(粒子)
  int _styleIndex = 0;

  @override
  Widget build(BuildContext context) {
    return Container(
      decoration: const BoxDecoration(
        gradient: LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [Color(0xFFE8F5E9), Color(0xFFC8E6C9)],
        ),
      ),
      child: SafeArea(
        child: Column(
          children: [
            _buildHeader(),
            _buildStyleTabs(),
            Expanded(child: _buildContent()),
          ],
        ),
      ),
    );
  }

  Widget _buildHeader() {
    return Padding(
      padding: const EdgeInsets.symmetric(vertical: 16),
      child: Row(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          FaIcon(
            FontAwesomeIcons.wandMagicSparkles,
            size: 28,
            color: const Color(0xFF2E7D32),
          ),
          const SizedBox(width: 10),
          Text(
            '绿色烟花',
            style: Theme.of(context).textTheme.titleLarge?.copyWith(
                  fontWeight: FontWeight.bold,
                  color: const Color(0xFF1B5E20),
                ),
          ),
        ],
      ),
    );
  }

  Widget _buildStyleTabs() {
    const styles = ['电子烟花', '手持烟花'];
    const icons = [
      FontAwesomeIcons.bolt,
      FontAwesomeIcons.wandMagicSparkles,
    ];
    return Padding(
      padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
      child: Row(
        children: List.generate(2, (i) {
          final selected = _styleIndex == i;
          return Expanded(
            child: Padding(
              padding: const EdgeInsets.symmetric(horizontal: 4),
              child: Material(
                color: selected
                    ? const Color(0xFF2E7D32)
                    : Colors.white.withValues(alpha: 0.8),
                borderRadius: BorderRadius.circular(12),
                child: InkWell(
                  onTap: () => setState(() => _styleIndex = i),
                  borderRadius: BorderRadius.circular(12),
                  child: Padding(
                    padding: const EdgeInsets.symmetric(vertical: 12),
                    child: Column(
                      children: [
                        FaIcon(
                          icons[i],
                          size: 22,
                          color: selected
                              ? Colors.white
                              : const Color(0xFF2E7D32),
                        ),
                        const SizedBox(height: 4),
                        Text(
                          styles[i],
                          style: TextStyle(
                            fontSize: 12,
                            fontWeight:
                                selected ? FontWeight.bold : FontWeight.w500,
                            color: selected
                                ? Colors.white
                                : const Color(0xFF1B5E20),
                          ),
                          textAlign: TextAlign.center,
                        ),
                      ],
                    ),
                  ),
                ),
              ),
            ),
          );
        }),
      ),
    );
  }

  Widget _buildContent() {
    switch (_styleIndex) {
      case 0:
        return const _LottieStyleView();
      case 1:
        return const _SparklerStyleView();
      default:
        return const _LottieStyleView();
    }
  }
}

4.2 风格一:电子烟花(Lottie 本地资源)完整代码

为什么用 Lottie.asset:电子烟花使用本地 JSON,无需网络,首帧快、离线可用。

dart 复制代码
/// 电子烟花:Lottie 动画
class _LottieStyleView extends StatelessWidget {
  const _LottieStyleView();

  static const String _lottieAsset = 'assets/lottie_firework.json';

  @override
  Widget build(BuildContext context) {
    return Center(
      child: Column(
        mainAxisAlignment: MainAxisAlignment.center,
        children: [
          SizedBox(
            width: 280,
            height: 280,
            child: Lottie.asset(
              _lottieAsset,
              fit: BoxFit.contain,
              errorBuilder: (_, __, ___) => const Column(
                mainAxisAlignment: MainAxisAlignment.center,
                children: [
                  Icon(Icons.broken_image, size: 48, color: Color(0xFF2E7D32)),
                  SizedBox(height: 8),
                  Text(
                    '未找到 assets/lottie_firework.json',
                    textAlign: TextAlign.center,
                    style: TextStyle(color: Color(0xFF33691E)),
                  ),
                ],
              ),
            ),
          ),
          const SizedBox(height: 16),
          Text(
            '电子烟花,环保又精彩',
            style: Theme.of(context).textTheme.titleMedium?.copyWith(
                  color: const Color(0xFF33691E),
                  fontWeight: FontWeight.w600,
                ),
          ),
          const SizedBox(height: 4),
          Text(
            '少放烟花,清新空气',
            style: Theme.of(context).textTheme.bodyMedium?.copyWith(
                  color: const Color(0xFF558B2F),
                ),
          ),
        ],
      ),
    );
  }
}

资源说明 :将电子烟花 Lottie JSON 放入 assets/lottie_firework.json;可替换为其他电子/烟花风格 JSON。

4.3 风格二:手持烟花(点击位置棍子 + 锥形火花)完整代码

为什么在点击位置显示 :用 TapDownDetails.localPosition 得到点击坐标,棍子顶端放在该点,火花从该点向上锥形扩散,符合「手持烟花」在手中位置燃烧的直觉。

为什么用 offset 而不是 delay :flutter_animator 的 AnimationPreferences 使用 offset 表示动画开始延迟(错峰淡出),duration 表示动画时长。

dart 复制代码
/// 手持烟花:点击位置棍子 + 向上锥形火花流
class _SparklerStyleView extends StatefulWidget {
  const _SparklerStyleView();

  @override
  State<_SparklerStyleView> createState() => _SparklerStyleViewState();
}

class _SparklerStyleViewState extends State<_SparklerStyleView> {
  int _relightKey = 0;
  /// 点击位置,null 时只显示提示
  Offset? _tapPosition;

  static const List<Color> _sparkColors = [
    Color(0xFFFFD54F),
    Color(0xFFFFEB3B),
    Color(0xFFFFF8E1),
    Color(0xFFFFB74D),
    Color(0xFFFFA726),
  ];

  static const int _particleCount = 36;
  static const double _stickWidth = 10;
  static const double _stickHeight = 100;
  static const double _coneLength = 90;
  static const double _particleRadius = 3.5;

  void _onTapDown(TapDownDetails details) {
    setState(() {
      _tapPosition = details.localPosition;
      _relightKey++;
    });
  }

  @override
  Widget build(BuildContext context) {
    return Stack(
      children: [
        GestureDetector(
          behavior: HitTestBehavior.opaque,
          onTapDown: _onTapDown,
          child: Container(
            color: Colors.transparent,
            child: Center(
              child: Text(
                '点击屏幕任意位置点燃手持烟花',
                style: Theme.of(context).textTheme.bodyLarge?.copyWith(
                      color: const Color(0xFF558B2F),
                    ),
              ),
            ),
          ),
        ),
        if (_tapPosition != null) ...[
          Positioned(
            left: _tapPosition!.dx - _stickWidth / 2,
            top: _tapPosition!.dy,
            child: _buildStick(),
          ),
          Positioned(
            left: _tapPosition!.dx - _coneLength,
            top: _tapPosition!.dy - _coneLength,
            width: _coneLength * 2,
            height: _coneLength * 2,
            child: _buildSparks(),
          ),
        ],
      ],
    );
  }

  Widget _buildStick() {
    return Container(
      width: _stickWidth,
      height: _stickHeight,
      decoration: BoxDecoration(
        borderRadius: BorderRadius.circular(_stickWidth / 2),
        gradient: const LinearGradient(
          begin: Alignment.topCenter,
          end: Alignment.bottomCenter,
          colors: [
            Color(0xFF5D4037),
            Color(0xFF3E2723),
          ],
        ),
        boxShadow: [
          BoxShadow(
            color: Colors.black.withValues(alpha: 0.3),
            blurRadius: 4,
            offset: const Offset(0, 2),
          ),
        ],
      ),
    );
  }

  Widget _buildSparks() {
    final random = Random(_relightKey);
    return IgnorePointer(
      child: Stack(
        clipBehavior: Clip.none,
        children: List.generate(_particleCount, (i) {
          final angle = (random.nextDouble() - 0.5) * 0.9 * pi;
          final len = _coneLength * (0.4 + random.nextDouble() * 0.6);
          final dx = sin(angle) * len;
          final dy = -cos(angle) * len;
          final color = _sparkColors[random.nextInt(_sparkColors.length)];
          final radius = _particleRadius * (0.6 + random.nextDouble() * 0.8);
          return Positioned(
            left: _coneLength + dx - radius,
            top: _coneLength + dy - radius,
            child: FadeOut(
              key: ValueKey('$_relightKey-$i'),
              preferences: AnimationPreferences(
                duration: Duration(milliseconds: 500 + random.nextInt(300)),
                offset: Duration(milliseconds: i * 12 + random.nextInt(25)),
              ),
              child: Container(
                width: radius * 2,
                height: radius * 2,
                decoration: BoxDecoration(
                  shape: BoxShape.circle,
                  color: color,
                  boxShadow: [
                    BoxShadow(
                      color: color.withValues(alpha: 0.7),
                      blurRadius: 3,
                      spreadRadius: 0.5,
                    ),
                  ],
                ),
              ),
            ),
          );
        }),
      ),
    );
  }
}

要点简述

  • 棍子_buildStick() 为细长圆角矩形,深棕渐变,长度 _stickHeight,顶端对齐 _tapPositiontop: _tapPosition!.dy),水平居中(left: _tapPosition!.dx - _stickWidth / 2)。
  • 火花 :在以点击为原点的锥形内随机分布,angle 约 ±0.45π 向上,len 在 0.4~1.0 倍 _coneLength;每个粒子用 FadeOut + AnimationPreferences(duration, offset) 错峰淡出;颜色从 _sparkColors 随机取。
  • 再次点击 :更新 _tapPosition_relightKey++,烟花移到新位置并重新播放火花动画。

五、🧩 封装与文件清单

5.1 封装结构

  • FireworkPage :维护 _styleIndex(0/1),build 时切换电子烟花 / 手持烟花。
  • _LottieStyleView:无状态,仅展示 Lottie + 文案。
  • _SparklerStyleView :有状态,维护 _tapPosition_relightKey,点击时在点击处绘制棍子与火花。

5.2 文件清单

文件 说明
lib/pages/firework_page.dart 绿色烟花页完整实现(含上述全部代码)
pubspec.yaml 依赖 lottie、flutter_animator;flutter.assets: assets/
assets/lottie_firework.json 电子烟花 Lottie 动画(需放入 assets/)

手持烟花无需额外资源,纯代码实现。


六、⚡ 工程优化与高频问题

6.1 显示与性能

  • Lottie :使用 Lottie.asset 避免网络延迟,首帧更快。
  • 手持烟花:单次仅一处烟花(棍子 + 一批粒子),粒子数约 36,重绘压力小;每次点击仅更新位置与 key,逻辑简单。

6.2 体积与资源

  • lottie:仅依赖一个 JSON 文件,可按需替换为更小的电子烟花 JSON。
  • flutter_animator:无额外资源。

6.3 高频问题

现象 原因 解决
Target of URI doesn't exist: lottie / flutter_animator 依赖未解析 执行 flutter pub get,必要时重启 IDE
未找到 assets/lottie_firework.json 未添加文件或路径错误 将 JSON 放入项目 assets/ 且 pubspec 已声明 assets:
FadeOut 报错 delay 不存在 API 为 offset 使用 AnimationPreferences(offset: Duration(...), ...)
相关推荐
小镇敲码人3 小时前
剖析CANN框架中Samples仓库:从示例到实战的AI开发指南
c++·人工智能·python·华为·acl·cann
前端不太难4 小时前
HarmonyOS 游戏里,Ability 是如何被重建的
游戏·状态模式·harmonyos
lbb 小魔仙5 小时前
【HarmonyOS实战】React Native 鸿蒙版实战:Calendar 日历组件完全指南
react native·react.js·harmonyos
一只大侠的侠5 小时前
Flutter开源鸿蒙跨平台训练营 Day 3
flutter·开源·harmonyos
盐焗西兰花5 小时前
鸿蒙学习实战之路-Reader Kit自定义字体最佳实践
学习·华为·harmonyos
_waylau5 小时前
鸿蒙架构师修炼之道-架构师的职责是什么?
开发语言·华为·harmonyos·鸿蒙
一只大侠的侠6 小时前
【Harmonyos】Flutter开源鸿蒙跨平台训练营 Day 2 鸿蒙跨平台开发环境搭建与工程实践
flutter·开源·harmonyos
微祎_7 小时前
Flutter for OpenHarmony:构建一个 Flutter 平衡球游戏,深入解析动画控制器、实时物理模拟与手势驱动交互
flutter·游戏·交互
ZH15455891318 小时前
Flutter for OpenHarmony Python学习助手实战:面向对象编程实战的实现
python·学习·flutter