Flutter成就解锁彩纸动画的鸿蒙化适配与实战指南
📅 写作时间:2026-04-29
🏷️ 标签:
FlutterOpenHarmony成就系统动画效果
🌟 开篇引导
欢迎加入开源鸿蒙跨平台社区:https://openharmonycrossplatform.csdn.net
嗨喽铁汁们!👋 我是上海某本科大学计算机专业的大一学生,最近在用Flutter for OpenHarmony开发健康运动App。
你们有没有这种感觉:App里的成就系统,做了跟没做一样,解锁了连个提示都没有,完全没有"成就感"!😭
所以我决定做一个超有仪式感的成就解锁动画!解锁成就的时候,满屏彩纸飘落,配合弹窗卡片,那感觉...一个字:爽!今天就来给各位铁汁们详细讲讲怎么实现!
📱 一、功能引入:为什么要做成就解锁动画?
1.1 解决什么问题?
没有动画的成就系统:
- 😤 解锁了完全没感觉
- 😤 用户不知道发生了什么
- 😤 没有分享的欲望
- 😤 成就徽章积灰了也没人点
有动画的成就系统:
- 🎉 满满的仪式感!
- 🎊 满屏彩纸庆祝
- 🏆 让人想截图分享
- 🔥 解锁更多成就的动力!
1.2 动画效果设计
| 效果 | 说明 | 触发时机 |
|---|---|---|
| 🎊 彩纸飘落 | confetti动画 | 成就解锁时 |
| 🏆 徽章展示 | 成就卡片弹窗 | 成就解锁时 |
| ⭐ 闪光效果 | 背景闪烁 | 大成就解锁 |
| 🔔 音效提示 | 庆祝音效 | 成就解锁时 |
💻 二、完整代码实现
2.1 成就数据模型
dart
// lib/models/achievement_model.dart
import 'package:equatable/equatable.dart';
/// 成就类型
enum AchievementType {
first, // 🏅 首次类
streak, // 🔥 连续类
milestone, // 🎯 里程碑类
special, // ⭐ 特殊类
}
/// 成就模型
class Achievement extends Equatable {
final String id; // 唯一ID
final String name; // 成就名称
final String icon; // 图标
final String description; // 描述
final AchievementType type; // 类型
final int? targetValue; // 目标值
final int currentValue; // 当前进度
final bool isUnlocked; // 是否已解锁
final DateTime? unlockedAt; // 解锁时间
final int order; // 显示顺序
const Achievement({
required this.id,
required this.name,
required this.icon,
required this.description,
required this.type,
this.targetValue,
this.currentValue = 0,
this.isUnlocked = false,
this.unlockedAt,
this.order = 0,
});
/// 完成百分比
double get progress {
if (targetValue == null || targetValue == 0) return 0;
return (currentValue / targetValue!).clamp(0.0, 1.0);
}
/// 获取类型名称
String get typeName {
switch (type) {
case AchievementType.first: return '首次';
case AchievementType.streak: return '连续';
case AchievementType.milestone: return '里程碑';
case AchievementType.special: return '特殊';
}
}
/// 转换为Map
Map<String, dynamic> toMap() {
return {
'id': id,
'name': name,
'icon': icon,
'description': description,
'type': type.name,
'target_value': targetValue,
'current_value': currentValue,
'is_unlocked': isUnlocked ? 1 : 0,
'unlocked_at': unlockedAt?.toIso8601String(),
'order_num': order,
};
}
/// 从Map恢复
factory Achievement.fromMap(Map<String, dynamic> map) {
return Achievement(
id: map['id'] as String,
name: map['name'] as String,
icon: map['icon'] as String,
description: map['description'] as String,
type: AchievementType.values.firstWhere(
(e) => e.name == map['type'],
orElse: () => AchievementType.special,
),
targetValue: map['target_value'] as int?,
currentValue: map['current_value'] as int? ?? 0,
isUnlocked: map['is_unlocked'] == 1,
unlockedAt: map['unlocked_at'] != null
? DateTime.parse(map['unlocked_at'] as String)
: null,
order: map['order_num'] as int? ?? 0,
);
}
@override
List<Object?> get props => [id, name, icon, description, type, targetValue, currentValue, isUnlocked, unlockedAt, order];
}
2.2 成就解锁动画组件
dart
// lib/widgets/achievement_unlock_dialog.dart
import 'package:flutter/material.dart';
import 'package:confetti/confetti.dart';
import '../models/achievement_model.dart';
/// 成就解锁弹窗
/// 带有彩纸动画效果
class AchievementUnlockDialog extends StatefulWidget {
/// 要解锁的成就
final Achievement achievement;
/// 关闭回调
final VoidCallback onClose;
/// 是否是连续解锁(多个成就)
final bool isSequential;
/// 下一个成就(如果有)
final Achievement? nextAchievement;
const AchievementUnlockDialog({
super.key,
required this.achievement,
required this.onClose,
this.isSequential = false,
this.nextAchievement,
});
@override
State<AchievementUnlockDialog> createState() => _AchievementUnlockDialogState();
}
class _AchievementUnlockDialogState extends State<AchievementUnlockDialog>
with TickerProviderStateMixin {
/// 彩纸控制器
late ConfettiController _confettiController;
/// 缩放动画控制器
late AnimationController _scaleController;
late Animation<double> _scaleAnimation;
/// 闪烁动画控制器
late AnimationController _glowController;
/// 是否显示内容
bool _showContent = false;
/// 旋转角度
double _rotation = 0;
@override
void initState() {
super.initState();
// 初始化彩纸控制器
_confettiController = ConfettiController(duration: const Duration(seconds: 3));
// 初始化缩放动画
_scaleController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 600),
);
_scaleAnimation = CurvedAnimation(
parent: _scaleController,
curve: Curves.elasticOut,
);
// 初始化发光动画
_glowController = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 1500),
)..repeat(reverse: true);
// 开始动画序列
_startAnimationSequence();
}
@override
void dispose() {
_confettiController.dispose();
_scaleController.dispose();
_glowController.dispose();
super.dispose();
}
/// 开始动画序列
Future<void> _startAnimationSequence() async {
// 1. 等待一小段时间
await Future.delayed(const Duration(milliseconds: 200));
// 2. 开始彩纸动画
_confettiController.play();
// 3. 显示内容
if (mounted) {
setState(() => _showContent = true);
}
// 4. 开始缩放动画
_scaleController.forward();
}
@override
Widget build(BuildContext context) {
return Material(
color: Colors.black.withOpacity(0.7),
child: Stack(
children: [
// ===== 1. 彩纸动画层 =====
Positioned.fill(
child: _buildConfetti(),
),
// ===== 2. 内容层 =====
Center(
child: _showContent
? _buildContent()
: const SizedBox.shrink(),
),
],
),
);
}
/// 彩纸动画
Widget _buildConfetti() {
return ConfettiWidget(
confettiController: _confettiController,
blastDirectionality: BlastDirectionality.explosive,
shouldLoop: false,
colors: _getConfettiColors(),
numberOfParticles: 50,
gravity: 0.2,
emissionFrequency: 0.05,
minimumSize: const Size(10, 10),
maximumSize: const Size(20, 20),
rotate: true,
shimmer: true,
);
}
/// 获取彩纸颜色
List<Color> _getConfettiColors() {
// 根据成就类型选择颜色
switch (widget.achievement.type) {
case AchievementType.first:
return const [
Color(0xFFFFD700), // 金色
Color(0xFFFFA500), // 橙色
Color(0xFFFFE066), // 浅金
];
case AchievementType.streak:
return const [
Color(0xFFFF6B6B), // 红色
Color(0xFFFF8E53), // 橙红
Color(0xFFFFE66D), // 黄色
];
case AchievementType.milestone:
return const [
Color(0xFF4ECDC4), // 青色
Color(0xFF95E1D3), // 浅青
Color(0xFF00B4D8), // 蓝色
];
case AchievementType.special:
return const [
Color(0xFFAA96DA), // 紫色
Color(0xFFFCBF49), // 金黄
Color(0xFFEF476F), // 粉色
];
}
}
/// 内容区域
Widget _buildContent() {
return ScaleTransition(
scale: _scaleAnimation,
child: Container(
margin: const EdgeInsets.symmetric(horizontal: 32),
padding: const EdgeInsets.all(24),
decoration: _buildBoxDecoration(),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
// 装饰光环
_buildGlowRing(),
const SizedBox(height: 20),
// 解锁标题
const Text(
'🎉 成就解锁!',
style: TextStyle(
fontSize: 18,
color: Colors.white70,
),
),
const SizedBox(height: 16),
// 成就图标
_buildAchievementIcon(),
const SizedBox(height: 16),
// 成就名称
Text(
widget.achievement.name,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 28,
fontWeight: FontWeight.bold,
color: Colors.white,
),
),
const SizedBox(height: 12),
// 成就描述
Text(
widget.achievement.description,
textAlign: TextAlign.center,
style: const TextStyle(
fontSize: 14,
color: Colors.white70,
),
),
const SizedBox(height: 24),
// 解锁时间
if (widget.achievement.unlockedAt != null)
Text(
'解锁于 ${_formatDate(widget.achievement.unlockedAt!)}',
style: const TextStyle(
fontSize: 12,
color: Colors.white54,
),
),
const SizedBox(height: 24),
// 关闭按钮
_buildCloseButton(),
],
),
),
);
}
/// 背景装饰
BoxDecoration _buildBoxDecoration() {
return BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topLeft,
end: Alignment.bottomRight,
colors: [
_getAccentColor().withOpacity(0.8),
_getAccentColor().withOpacity(0.4),
],
),
borderRadius: BorderRadius.circular(24),
border: Border.all(
color: Colors.white.withOpacity(0.3),
width: 2,
),
boxShadow: [
BoxShadow(
color: _getAccentColor().withOpacity(0.5),
blurRadius: 30,
spreadRadius: 5,
),
],
);
}
/// 发光光环
Widget _buildGlowRing() {
return AnimatedBuilder(
animation: _glowController,
builder: (context, child) {
return Container(
width: 120,
height: 120,
decoration: BoxDecoration(
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _getAccentColor().withOpacity(0.3 + _glowController.value * 0.3),
blurRadius: 20 + _glowController.value * 10,
spreadRadius: 5 + _glowController.value * 5,
),
],
),
);
},
);
}
/// 成就图标
Widget _buildAchievementIcon() {
return AnimatedBuilder(
animation: _glowController,
builder: (context, child) {
return Transform.rotate(
angle: _rotation,
child: Container(
width: 80,
height: 80,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: _getAccentColor().withOpacity(0.5),
blurRadius: 15,
spreadRadius: 2,
),
],
),
child: Center(
child: Text(
widget.achievement.icon,
style: const TextStyle(fontSize: 40),
),
),
),
);
},
);
}
/// 关闭按钮
Widget _buildCloseButton() {
return Row(
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (widget.nextAchievement != null) ...[
TextButton(
onPressed: () {
// 显示下一个成就
// TODO: 实现连续解锁
},
child: const Text(
'下一个 →',
style: TextStyle(color: Colors.white70),
),
),
const SizedBox(width: 16),
],
ElevatedButton(
onPressed: widget.onClose,
style: ElevatedButton.styleFrom(
backgroundColor: Colors.white,
foregroundColor: _getAccentColor(),
padding: const EdgeInsets.symmetric(horizontal: 32, vertical: 12),
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(24),
),
),
child: const Text(
'太棒了!',
style: TextStyle(fontWeight: FontWeight.bold),
),
),
],
);
}
/// 获取主题色
Color _getAccentColor() {
switch (widget.achievement.type) {
case AchievementType.first:
return const Color(0xFFFFD700);
case AchievementType.streak:
return const Color(0xFFFF6B6B);
case AchievementType.milestone:
return const Color(0xFF4ECDC4);
case AchievementType.special:
return const Color(0xFFAA96DA);
}
}
/// 格式化日期
String _formatDate(DateTime date) {
return '${date.month}月${date.day}日 ${date.hour}:${date.minute.toString().padLeft(2, '0')}';
}
}
2.3 成就服务
dart
// lib/services/achievement_service.dart
import '../models/achievement_model.dart';
import 'database_service.dart';
/// 成就服务
class AchievementService {
static final AchievementService _instance = AchievementService._internal();
static AchievementService get instance => _instance;
AchievementService._internal();
final DatabaseService _databaseService = DatabaseService.instance;
/// 获取所有成就
Future<List<Achievement>> getAllAchievements() async {
final db = await _databaseService.database;
final List<Map<String, dynamic>> maps = await db.query(
'achievements',
orderBy: 'order_num ASC',
);
return maps.map((map) => Achievement.fromMap(map)).toList();
}
/// 获取已解锁的成就
Future<List<Achievement>> getUnlockedAchievements() async {
final db = await _databaseService.database;
final List<Map<String, dynamic>> maps = await db.query(
'achievements',
where: 'is_unlocked = ?',
whereArgs: [1],
orderBy: 'unlocked_at DESC',
);
return maps.map((map) => Achievement.fromMap(map)).toList();
}
/// 解锁成就
Future<Achievement?> unlockAchievement(String id) async {
final db = await _databaseService.database;
// 检查是否已解锁
final existing = await db.query(
'achievements',
where: 'id = ?',
whereArgs: [id],
);
if (existing.isEmpty) return null;
if (existing.first['is_unlocked'] == 1) return null; // 已解锁
// 更新为已解锁
await db.update(
'achievements',
{
'is_unlocked': 1,
'unlocked_at': DateTime.now().toIso8601String(),
},
where: 'id = ?',
whereArgs: [id],
);
// 返回更新后的成就
final updated = await db.query(
'achievements',
where: 'id = ?',
whereArgs: [id],
);
return Achievement.fromMap(updated.first);
}
/// 更新成就进度
Future<void> updateProgress(String id, int currentValue) async {
final db = await _databaseService.database;
await db.update(
'achievements',
{'current_value': currentValue},
where: 'id = ?',
whereArgs: [id],
);
}
/// 检查是否可以解锁
Future<Achievement?> checkAndUnlock(String id) async {
final achievements = await getAllAchievements();
final achievement = achievements.firstWhere(
(a) => a.id == id,
orElse: () => throw Exception('成就不存在'),
);
// 检查是否达到解锁条件
if (achievement.targetValue != null &&
achievement.currentValue >= achievement.targetValue! &&
!achievement.isUnlocked) {
return await unlockAchievement(id);
}
return null;
}
}
/// 预设成就数据
class PresetAchievements {
static List<Achievement> get all => [
const Achievement(
id: 'first_workout',
name: '初次打卡',
icon: '🎯',
description: '完成第一次运动!',
type: AchievementType.first,
order: 1,
),
const Achievement(
id: 'first_week',
name: '坚持一周',
icon: '🔥',
description: '连续运动7天!',
type: AchievementType.streak,
targetValue: 7,
currentValue: 0,
order: 2,
),
const Achievement(
id: 'steps_10k',
name: '万步达人',
icon: '👟',
description: '单日步数突破10000!',
type: AchievementType.milestone,
targetValue: 10000,
currentValue: 0,
order: 3,
),
const Achievement(
id: 'streak_30',
name: '一个月坚持',
icon: '🏆',
description: '连续运动30天!',
type: AchievementType.streak,
targetValue: 30,
currentValue: 0,
order: 4,
),
const Achievement(
id: 'early_bird',
name: '早起达人',
icon: '🌅',
description: '连续5天7点前打卡!',
type: AchievementType.special,
targetValue: 5,
currentValue: 0,
order: 5,
),
];
}
😤 三、开发踩坑与挫折
3.1 坑一:彩纸动画位置不对!
问题描述 :
彩纸从屏幕中间爆发,而不是从顶部落下!
解决方案:
dart
// ❌ 错误:使用 explosive 会从中心爆发
ConfettiWidget(
blastDirectionality: BlastDirectionality.explosive,
blastDirection: 3.14 / 2, // 向下
)
// ✅ 正确:使用 directional 从顶部落下
ConfettiWidget(
blastDirectionality: BlastDirectionality.directional,
blastDirection: 3.14 / 2, // 向下(弧度)
minSpeed: 100,
maxSpeed: 200,
)
3.2 坑二:动画结束后内存泄漏!
问题描述 :
多次解锁成就后,应用越来越卡!
解决方案:
dart
// ✅ 一定要在dispose中释放
@override
void dispose() {
_confettiController.dispose();
_scaleController.dispose();
_glowController.dispose();
super.dispose();
}
📱 四、最终实现效果

| 成就类型 | 动画效果 | 状态 |
|---|---|---|
| 首次类 | 金色彩纸 | ✅ |
| 连续类 | 火焰色调 | ✅ |
| 里程碑 | 青蓝色调 | ✅ |
| 特殊类 | 彩虹色 | ✅ |
好了!成就解锁彩纸动画就讲到这里!
**如果觉得有帮助,请一键三连!**🙏
📅 发布日期:2026-04-29
✍️ 作者:上海某本科大学大一学生
🏷️ 标签:Flutter / OpenHarmony / confetti / 成就系统
往期推荐:
- 「Flutter confetti彩纸动画的鸿蒙化适配与实战指南」
- 「Flutter三方库flutter_bloc的鸿蒙化适配与实战指南」