// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; const int _heartDurationMs = 1300; const int _lastHeartDelayMs = 200; const double _heartPopPortion = 0.18; const double _heartStartScale = 0.7; const double _heartHoldPortion = 0.65; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, travel: 175, drift: 0, size: 44, color: AppColors.doubleTapHeartRed, ), _HeartParticle( delay: 110, travel: 150, drift: -32, size: 34, color: AppColors.doubleTapHeartPink, ), _HeartParticle( delay: _lastHeartDelayMs, travel: 165, drift: 30, size: 38, color: AppColors.doubleTapHeartRed, ), ]; /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); static const int particleCount = 3; static const int singleHeartDurationMs = _heartDurationMs; static const Duration animationDuration = Duration( milliseconds: _lastHeartDelayMs + _heartDurationMs, ); final VoidCallback onCompleted; @override State createState() => _PlayerDoubleTapHeartBurstState(); } class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; bool _completed = false; @override void initState() { super.initState(); final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), )..addStatusListener(_onStatus); _controller.forward(); } void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; widget.onCompleted(); } @override void dispose() { _controller ..removeStatusListener(_onStatus) ..dispose(); super.dispose(); } @override Widget build(BuildContext context) { return IgnorePointer( child: SizedBox.shrink( child: AnimatedBuilder( animation: _controller, builder: (context, _) { final elapsed = _controller.value * _controller.duration!.inMilliseconds; return Stack( clipBehavior: Clip.none, children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], elapsed, index), ], ); }, ), ), ); } Widget _heart(_HeartParticle heart, double elapsed, int index) { final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); final move = Curves.easeOutCubic.transform(progress); final scale = progress < _heartPopPortion ? _heartStartScale + (1 - _heartStartScale) * (progress / _heartPopPortion) : 1.0; final opacity = progress < _heartHoldPortion ? 1.0 : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( left: heart.drift * move - heart.size / 2, top: -heart.size / 2 - heart.travel * move, child: Opacity( opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, color: heart.color, size: heart.size, ), ), ), ); } } class _HeartParticle { const _HeartParticle({ required this.delay, required this.travel, required this.drift, required this.size, required this.color, }); final int delay; final double travel; final double drift; final double size; final Color color; }