From 810ca5bf9da8a48489986bca1fc357cf53d182ec Mon Sep 17 00:00:00 2001 From: Zhou Weidong <502333560@qq.com> Date: Mon, 26 Oct 2026 18:42:41 +0800 Subject: [PATCH] =?UTF-8?q?fix(player):=20=E5=BB=B6=E9=95=BF=E9=A3=98?= =?UTF-8?q?=E5=BF=83=E5=B9=B6=E6=98=BE=E7=A4=BA=E6=AF=AB=E7=A7=92=E8=AE=A1?= =?UTF-8?q?=E6=97=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../widgets/player_double_tap_hearts.dart | 68 +++++++++++++++-- .../widgets/player_tap_gesture_region.dart | 73 ++++++++++++------- test/player/double_tap_like_test.dart | 25 ++++++- .../player/player_double_tap_hearts_test.dart | 39 +++++++++- 4 files changed, 165 insertions(+), 40 deletions(-) diff --git a/lib/pages/player/widgets/player_double_tap_hearts.dart b/lib/pages/player/widgets/player_double_tap_hearts.dart index 568c86e..8f46837 100644 --- a/lib/pages/player/widgets/player_double_tap_hearts.dart +++ b/lib/pages/player/widgets/player_double_tap_hearts.dart @@ -1,13 +1,14 @@ // Author: fruit.com.cn +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import '../../../core/theme/app_colors.dart'; -const int _heartDurationMs = 680; +const int _heartDurationMs = 1500; const double _heartPopPortion = 0.18; const double _heartStartScale = 0.7; -const double _heartHoldPortion = 0.45; +const double _heartHoldPortion = 0.55; const List<_HeartParticle> _hearts = [ _HeartParticle( @@ -35,9 +36,14 @@ const List<_HeartParticle> _hearts = [ /// 双击点赞时,在点击位置飘出一小簇心。 class PlayerDoubleTapHeartBurst extends StatefulWidget { - const PlayerDoubleTapHeartBurst({super.key, required this.onCompleted}); + const PlayerDoubleTapHeartBurst({ + super.key, + required this.onCompleted, + this.origin = Offset.zero, + }); final VoidCallback onCompleted; + final Offset origin; @override State createState() => @@ -47,14 +53,24 @@ class PlayerDoubleTapHeartBurst extends StatefulWidget { class _PlayerDoubleTapHeartBurstState extends State with SingleTickerProviderStateMixin { late final AnimationController _controller; + final Stopwatch _lifetime = Stopwatch(); bool _completed = false; @override void initState() { super.initState(); + _lifetime.start(); final total = _hearts .map((heart) => heart.delay + _heartDurationMs) .reduce((left, right) => left > right ? left : right); + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-start ' + 'id=${identityHashCode(this)} count=${_hearts.length} ' + 'configuredMs=$total at=${widget.origin.dx.toStringAsFixed(1)},' + '${widget.origin.dy.toStringAsFixed(1)}', + ); + } _controller = AnimationController( vsync: this, duration: Duration(milliseconds: total), @@ -65,11 +81,37 @@ class _PlayerDoubleTapHeartBurstState extends State void _onStatus(AnimationStatus status) { if (status != AnimationStatus.completed || _completed) return; _completed = true; + if (kDebugMode) { + debugPrint( + '[player] phase=heart-burst-complete ' + 'id=${identityHashCode(this)} ' + 'configuredMs=${_controller.duration?.inMilliseconds ?? 0}', + ); + } widget.onCompleted(); } @override void dispose() { + if (kDebugMode) { + final elapsedMs = _completed + ? _controller.duration?.inMilliseconds ?? 0 + : _lifetime.elapsedMilliseconds; + debugPrint( + '[player] phase=heart-burst-dispose ' + 'id=${identityHashCode(this)} elapsedMs=$elapsedMs ' + 'completed=${_completed ? 1 : 0}', + ); + if (!_completed) { + debugPrintStack( + maxFrames: 30, + label: + '[player] heart-burst-premature-dispose ' + 'id=${identityHashCode(this)} mount=tap-gesture-region', + ); + } + } + _lifetime.stop(); _controller ..removeStatusListener(_onStatus) ..dispose(); @@ -86,10 +128,26 @@ class _PlayerDoubleTapHeartBurstState extends State final elapsed = _controller.value * _controller.duration!.inMilliseconds; return Stack( + fit: StackFit.expand, clipBehavior: Clip.none, children: [ for (var index = 0; index < _hearts.length; index++) _heart(_hearts[index], elapsed, index), + if (kDebugMode) + Positioned( + left: widget.origin.dx + 8, + top: widget.origin.dy, + child: IgnorePointer( + child: Text( + '${elapsed.round()}ms', + key: const Key('heart-burst-elapsed-debug'), + style: const TextStyle( + color: Colors.white60, + fontSize: 11, + ), + ), + ), + ), ], ); }, @@ -113,8 +171,8 @@ class _PlayerDoubleTapHeartBurstState extends State ? 1.0 : 1 - ((progress - _heartHoldPortion) / (1 - _heartHoldPortion)); return Positioned( - left: heart.drift * move - heart.size / 2, - top: -heart.size / 2 - heart.travel * move, + left: widget.origin.dx + heart.drift * move - heart.size / 2, + top: widget.origin.dy - heart.size / 2 - heart.travel * move, child: Opacity( opacity: opacity.clamp(0.0, 1.0), child: Transform.scale( diff --git a/lib/pages/player/widgets/player_tap_gesture_region.dart b/lib/pages/player/widgets/player_tap_gesture_region.dart index f3b06a6..d323bb0 100644 --- a/lib/pages/player/widgets/player_tap_gesture_region.dart +++ b/lib/pages/player/widgets/player_tap_gesture_region.dart @@ -2,6 +2,7 @@ import 'dart:async'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'player_double_tap_hearts.dart'; @@ -47,12 +48,18 @@ class _PlayerTapGestureRegionState extends State { int _downSerial = 0; int _tapSeq = 0; int _nextHeartBurstId = 0; - final List<({int id, Offset position})> _heartBursts = []; + final Map _heartOverlayEntries = {}; @override void dispose() { _tapSeq++; _singleTapTimer?.cancel(); + for (final entry in _heartOverlayEntries.values.toList()) { + entry + ..remove() + ..dispose(); + } + _heartOverlayEntries.clear(); super.dispose(); } @@ -78,25 +85,6 @@ class _PlayerTapGestureRegionState extends State { onLongPressEnd: (_) => widget.onInteractionEnd(), onLongPressCancel: widget.onInteractionEnd, ), - if (_heartBursts.isNotEmpty) - Positioned.fill( - child: IgnorePointer( - child: Stack( - clipBehavior: Clip.none, - children: [ - for (final burst in _heartBursts) - Positioned( - left: burst.position.dx, - top: burst.position.dy - 10, - child: PlayerDoubleTapHeartBurst( - key: ValueKey(burst.id), - onCompleted: () => _removeHeartBurst(burst.id), - ), - ), - ], - ), - ), - ), ], ), ), @@ -138,7 +126,7 @@ class _PlayerTapGestureRegionState extends State { void _handleTapUp(TapUpDetails details) { final now = _now(); if (_doubleTapDown || _singleTapBlocked(now)) { - _handleDoubleTapLike(details.localPosition); + _handleDoubleTapLike(details.globalPosition); return; } final serial = _downSerial; @@ -156,19 +144,48 @@ class _PlayerTapGestureRegionState extends State { _singleTapTimer = pending; } - void _handleDoubleTapLike(Offset position) { + void _handleDoubleTapLike(Offset globalPosition) { _blockSingleTapUntil = _now().add(_singleTapDelay); _cancelPendingSingleTap(); if (!widget.doubleTapEnabled || _likedThisPair) return; _likedThisPair = true; - final id = _nextHeartBurstId++; - setState(() => _heartBursts.add((id: id, position: position))); - widget.onDoubleTapLike?.call(position); + _showHeartBurst(globalPosition); + widget.onDoubleTapLike?.call(globalPosition); } - void _removeHeartBurst(int id) { - if (!mounted) return; - setState(() => _heartBursts.removeWhere((burst) => burst.id == id)); + void _showHeartBurst(Offset globalPosition) { + final overlay = Overlay.maybeOf(context, rootOverlay: true); + if (overlay == null) return; + final overlayBox = overlay.context.findRenderObject(); + final origin = overlayBox is RenderBox + ? overlayBox.globalToLocal(globalPosition) + : globalPosition; + final id = _nextHeartBurstId++; + late final OverlayEntry entry; + entry = OverlayEntry( + builder: (context) => Positioned.fill( + child: IgnorePointer( + child: PlayerDoubleTapHeartBurst( + key: ValueKey('heart-overlay-$id'), + origin: origin, + onCompleted: () => _removeHeartOverlay(id), + ), + ), + ), + ); + _heartOverlayEntries[id] = entry; + overlay.insert(entry); + } + + void _removeHeartOverlay(int id) { + final entry = _heartOverlayEntries.remove(id); + if (entry == null) return; + entry + ..remove() + ..dispose(); + if (kDebugMode) { + debugPrint('[player] phase=heart-burst-entry-removed id=$id'); + } } void _handleLongPressStart(LongPressStartDetails details, double width) { diff --git a/test/player/double_tap_like_test.dart b/test/player/double_tap_like_test.dart index f0b63bc..861c988 100644 --- a/test/player/double_tap_like_test.dart +++ b/test/player/double_tap_like_test.dart @@ -110,7 +110,7 @@ void main() { expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); }); - testWidgets('parent rebuild keeps the heart burst state alive', ( + testWidgets('parent rebuild 30 times keeps the heart burst alive', ( tester, ) async { final key = GlobalKey<_HarnessState>(); @@ -135,13 +135,24 @@ void main() { final burstFinder = find.byType(PlayerDoubleTapHeartBurst); final gestureState = tester.state(gestureFinder); final burstState = tester.state(burstFinder); + var elapsedMs = 100; - key.currentState!.rebuild(); - await tester.pump(const Duration(milliseconds: 100)); + for (var index = 0; index < 30; index++) { + key.currentState!.rebuild(); + await tester.pump(const Duration(milliseconds: 16)); + elapsedMs += 16; + } expect(tester.state(gestureFinder), same(gestureState)); expect(tester.state(burstFinder), same(burstState)); expect(find.byKey(const Key('double-tap-heart-0')), findsOneWidget); + + while (burstFinder.evaluate().isNotEmpty && elapsedMs < 2000) { + await tester.pump(const Duration(milliseconds: 16)); + elapsedMs += 16; + } + expect(burstFinder, findsNothing); + expect(elapsedMs, greaterThanOrEqualTo(1500)); }); testWidgets('heart burst is cleaned up when gesture region is disposed', ( @@ -166,6 +177,12 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsOneWidget); + expect( + (tester.getCenter(find.byKey(const Key('double-tap-heart-0'))) - + tapPosition) + .distance, + lessThanOrEqualTo(1), + ); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); @@ -200,6 +217,8 @@ void main() { await tester.pump(const Duration(milliseconds: 1)); expect(find.byType(PlayerDoubleTapHeartBurst), findsNWidgets(2)); + expect(find.byKey(const ValueKey('heart-overlay-0')), findsOneWidget); + expect(find.byKey(const ValueKey('heart-overlay-1')), findsOneWidget); await tester.pumpWidget(const SizedBox.shrink()); await tester.pump(); expect(find.byType(PlayerDoubleTapHeartBurst), findsNothing); diff --git a/test/player/player_double_tap_hearts_test.dart b/test/player/player_double_tap_hearts_test.dart index 4c3a933..bdae85b 100644 --- a/test/player/player_double_tap_hearts_test.dart +++ b/test/player/player_double_tap_hearts_test.dart @@ -19,6 +19,11 @@ void main() { expect(_size(tester, 0), 48); expect(_size(tester, 1), 36); expect(_size(tester, 2), 40); + final elapsedText = tester + .widget(find.byKey(const Key('heart-burst-elapsed-debug'))) + .data!; + final elapsedMs = int.parse(elapsedText.replaceFirst('ms', '')); + expect(elapsedMs, inInclusiveRange(180, 220)); expect( tester .widgetList( @@ -32,7 +37,23 @@ void main() { ); }); - testWidgets('develop heart burst completes after about 770ms', ( + testWidgets('heart burst keeps opacity then fades after 55 percent', ( + tester, + ) async { + await tester.pumpWidget( + const MaterialApp( + home: Scaffold(body: PlayerDoubleTapHeartBurst(onCompleted: _noop)), + ), + ); + + await tester.pump(const Duration(milliseconds: 450)); + expect(_opacity(tester, 0), closeTo(1, 1e-9)); + + await tester.pump(const Duration(milliseconds: 750)); + expect(_opacity(tester, 0), lessThan(0.5)); + }); + + testWidgets('develop heart burst completes after about 1590ms', ( tester, ) async { var completed = false; @@ -45,14 +66,14 @@ void main() { ), ); - while (!completed && elapsedMs < 1200) { + while (!completed && elapsedMs < 2000) { await tester.pump(const Duration(milliseconds: 16)); elapsedMs += 16; } expect(completed, isTrue); - expect(elapsedMs, greaterThanOrEqualTo(752)); - expect(elapsedMs, lessThanOrEqualTo(800)); + expect(elapsedMs, greaterThanOrEqualTo(1584)); + expect(elapsedMs, lessThanOrEqualTo(1616)); }); } @@ -60,4 +81,14 @@ double _size(WidgetTester tester, int index) { return tester.widget(find.byKey(Key('double-tap-heart-$index'))).size!; } +double _opacity(WidgetTester tester, int index) { + final opacity = find + .ancestor( + of: find.byKey(Key('double-tap-heart-$index')), + matching: find.byType(Opacity), + ) + .first; + return tester.widget(opacity).opacity; +} + void _noop() {}