// Author: fruit.com.cn import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; const int _heartDurationMs = 680; const double _heartPopPortion = 0.18; const double _heartStartScale = 0.7; const double _heartHoldPortion = 0.45; const List<_HeartParticle> _hearts = [ _HeartParticle( delay: 0, travel: 108, drift: 0, size: 48, color: AppColors.doubleTapHeartRed, ), _HeartParticle( delay: 50, travel: 96, drift: -28, size: 36, color: AppColors.doubleTapHeartPink, ), _HeartParticle( delay: 90, travel: 118, drift: 26, size: 40, color: AppColors.doubleTapHeartRed, ), ]; /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); 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; }