diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 51cf480..c0efa04 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,56 +1,38 @@ // Author: fruit.com.cn +import 'dart:math'; + import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 1900; -const int _lastHeartDelayMs = 260; -const double _heartPopPortion = 0.12; -const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.45; - -const List<_HeartParticle> _hearts = [ - _HeartParticle( - delay: 0, - travel: 120, - drift: 0, - size: 44, - color: AppColors.doubleTapHeartRed, - ), - _HeartParticle( - delay: 140, - travel: 120, - drift: -32, - size: 34, - color: AppColors.doubleTapHeartPink, - ), - _HeartParticle( - delay: _lastHeartDelayMs, - travel: 120, - drift: 30, - size: 38, - color: AppColors.doubleTapHeartRed, - ), -]; - /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { const PlayerDoubleTapHeartBurst({ super.key, required this.burstId, required this.onCompleted, + this.random, }); - static const int particleCount = 3; - static const int singleHeartDurationMs = _heartDurationMs; - static const Duration animationDuration = Duration( - milliseconds: _lastHeartDelayMs + _heartDurationMs, - ); + static const int minParticleCount = 4; + static const int maxParticleCount = 5; final int burstId; final VoidCallback onCompleted; + final Random? random; + + @visibleForTesting + static double opacityForProgress(double progress) => + 1 - Curves.easeIn.transform(progress); + + @visibleForTesting + static double scaleForProgress(double progress) { + return progress < 0.3 + ? 0.55 + (1.15 - 0.55) * (progress / 0.3) + : 1.15 - (1.15 - 0.2) * ((progress - 0.3) / 0.7); + } @override State createState() => @@ -60,6 +42,7 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + late final List<_HeartParticle> _particles; final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @@ -67,13 +50,39 @@ class _PlayerDoubleTapHeartBurstState extends State void initState() { super.initState(); _lifetime.start(); + final random = widget.random ?? Random(); + var delay = 0; + _particles = List.generate( + PlayerDoubleTapHeartBurst.minParticleCount + + random.nextInt( + PlayerDoubleTapHeartBurst.maxParticleCount - + PlayerDoubleTapHeartBurst.minParticleCount + + 1, + ), + (index) { + if (index > 0) delay += 40 + random.nextInt(21); + return _HeartParticle( + delay: delay, + duration: 900 + random.nextInt(201), + travel: 120 + random.nextInt(41), + drift: -30 + random.nextInt(61), + size: (22 + random.nextInt(13)).toDouble(), + color: random.nextBool() + ? AppColors.doubleTapHeartRed + : AppColors.doubleTapHeartPink, + ); + }, + ); + final total = _particles + .map((particle) => particle.delay + particle.duration) + .reduce(max); if (kDebugMode) { debugPrint('[player] phase=heart-burst-init id=${widget.burstId}'); - debugPrint('[player] phase=heart-burst-start id=${widget.burstId}'); + debugPrint( + '[player] phase=heart-burst-start ' + 'id=${widget.burstId} count=${_particles.length}', + ); } - final total = _hearts - .map((heart) => heart.delay + _heartDurationMs) - .reduce((left, right) => left > right ? left : right); _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -122,8 +131,8 @@ class _PlayerDoubleTapHeartBurstState extends State return Stack( clipBehavior: Clip.none, children: [ - for (var index = 0; index < _hearts.length; index++) - _heart(_hearts[index], elapsed, index), + for (var index = 0; index < _particles.length; index++) + _particle(_particles[index], elapsed, index), ], ); }, @@ -132,35 +141,26 @@ class _PlayerDoubleTapHeartBurstState extends State ); } - Widget _heart(_HeartParticle heart, double elapsed, int index) { - final progress = ((elapsed - heart.delay) / _heartDurationMs).clamp( + Widget _particle(_HeartParticle particle, double elapsed, int index) { + final progress = ((elapsed - particle.delay) / particle.duration).clamp( 0.0, 1.0, ); if (progress <= 0) return const SizedBox.shrink(); - final move = Curves.easeOutSine.transform(progress); - final scale = progress < _heartPopPortion - ? _heartStartScale + - (1 - _heartStartScale) * (progress / _heartPopPortion) - : 1.0; - final opacity = progress < _heartHoldPortion - ? 1.0 - : 1 - - Curves.easeInOutSine.transform( - (progress - _heartHoldPortion) / (1 - _heartHoldPortion), - ); + final move = Curves.easeOutCubic.transform(progress); + final scale = PlayerDoubleTapHeartBurst.scaleForProgress(progress); return Positioned( - left: heart.drift * move - heart.size / 2, - top: -heart.size / 2 - heart.travel * move, + left: particle.drift * move - particle.size / 2, + top: -particle.travel * move, child: Opacity( - opacity: opacity.clamp(0.0, 1.0), + opacity: PlayerDoubleTapHeartBurst.opacityForProgress(progress), child: Transform.scale( scale: scale, child: Icon( key: Key('double-tap-heart-$index'), Icons.favorite_rounded, - color: heart.color, - size: heart.size, + color: particle.color, + size: particle.size, ), ), ), @@ -171,6 +171,7 @@ class _PlayerDoubleTapHeartBurstState extends State class _HeartParticle { const _HeartParticle({ required this.delay, + required this.duration, required this.travel, required this.drift, required this.size, @@ -178,8 +179,9 @@ class _HeartParticle { }); final int delay; - final double travel; - final double drift; + final int duration; + final int travel; + final int drift; final double size; final Color color; } diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index f053a83..59e1cbd 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -171,10 +171,6 @@ class _PlayerTapGestureRegionState extends State { debugPrint( '[player] phase=double-tap-like episodeId=${widget.episodeId}', ); - debugPrint( - '[player] phase=heart-burst ' - 'count=${PlayerDoubleTapHeartBurst.particleCount}', - ); } widget.onDoubleTapLike?.call(position); } diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index cc1a097..3841031 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -1,26 +1,34 @@ // Author: fruit.com.cn +import 'dart:math'; + import 'package:flutter/material.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:fruitshorts/pages/player/widgets/player_double_tap_hearts.dart'; void main() { - testWidgets('should_show_three_medium_hearts_when_double_tap_burst_starts', ( + testWidgets('should_show_randomized_heart_particles_when_burst_starts', ( tester, ) async { await tester.pumpWidget( - const MaterialApp( + MaterialApp( home: Scaffold( - body: PlayerDoubleTapHeartBurst(burstId: 1, onCompleted: _noop), + body: PlayerDoubleTapHeartBurst( + burstId: 1, + onCompleted: _noop, + random: Random(42), + ), ), ), ); - await tester.pump(const Duration(milliseconds: 300)); + await tester.pump(const Duration(milliseconds: 400)); - expect(find.byIcon(Icons.favorite_rounded), findsNWidgets(3)); - expect(_size(tester, 0), 44); - expect(_size(tester, 1), 34); - expect(_size(tester, 2), 38); + final hearts = find.byIcon(Icons.favorite_rounded); + final count = hearts.evaluate().length; + expect(count, inInclusiveRange(4, 5)); + for (var index = 0; index < count; index++) { + expect(_size(tester, index), inInclusiveRange(22, 34)); + } expect( tester .widgetList( @@ -34,14 +42,16 @@ void main() { ); }); - test('heart burst stays long and sparse', () { - expect(PlayerDoubleTapHeartBurst.singleHeartDurationMs, 1900); + test('heart particles fade from the start and scale in two phases', () { + expect(PlayerDoubleTapHeartBurst.opacityForProgress(0.5), lessThan(0.7)); + expect(PlayerDoubleTapHeartBurst.scaleForProgress(0), 0.55); expect( - PlayerDoubleTapHeartBurst.animationDuration, - greaterThanOrEqualTo(const Duration(milliseconds: 1800)), + PlayerDoubleTapHeartBurst.scaleForProgress(0.3), + closeTo(1.15, 1e-9), ); - expect(PlayerDoubleTapHeartBurst.particleCount, lessThanOrEqualTo(4)); - expect(PlayerDoubleTapHeartBurst.particleCount, 3); + expect(PlayerDoubleTapHeartBurst.scaleForProgress(1), closeTo(0.2, 1e-9)); + expect(PlayerDoubleTapHeartBurst.minParticleCount, 4); + expect(PlayerDoubleTapHeartBurst.maxParticleCount, 5); }); }