feat(player): animate circular feedback on transport buttons

This commit is contained in:
zarzet
2026-09-26 03:50:52 +07:00
parent 7d7716cd6f
commit 607f9c440b
2 changed files with 92 additions and 2 deletions
+45 -2
View File
@@ -27,8 +27,13 @@ class MornyePlaybackButton extends StatefulWidget {
}
class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
with SingleTickerProviderStateMixin {
with TickerProviderStateMixin {
final _states = WidgetStatesController();
late final _press = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 100),
reverseDuration: const Duration(milliseconds: 320),
);
late final _skip = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 300),
@@ -38,9 +43,32 @@ class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
widget.icon == CupertinoIcons.forward_fill ||
widget.icon == CupertinoIcons.backward_fill;
@override
void initState() {
super.initState();
_states.addListener(_updatePress);
_press.addStatusListener((status) {
if (status == AnimationStatus.completed &&
!_states.value.contains(WidgetState.pressed)) {
_press.reverse();
}
});
}
void _updatePress() {
if (_states.value.contains(WidgetState.pressed)) {
_press.forward();
} else if (_press.isCompleted) {
_press.reverse();
}
// A quick tap still finishes its fade-in before fading back out.
}
@override
void dispose() {
_states.removeListener(_updatePress);
_states.dispose();
_press.dispose();
_skip.dispose();
super.dispose();
}
@@ -83,13 +111,14 @@ class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
final reduceMotion = MediaQuery.disableAnimationsOf(context);
final motion = Duration(milliseconds: reduceMotion ? 120 : 180);
final enabled = widget.onPressed != null && !widget.loading;
return IconButton(
final button = IconButton(
statesController: _states,
tooltip: widget.tooltip,
color: widget.color,
iconSize: widget.iconSize,
padding: widget.padding,
style: ButtonStyle(
shape: const WidgetStatePropertyAll(CircleBorder()),
splashFactory: NoSplash.splashFactory,
overlayColor: WidgetStateProperty.resolveWith((states) {
return states.contains(WidgetState.focused) ||
@@ -100,6 +129,7 @@ class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
),
onPressed: enabled
? () {
_press.forward();
if (_isSkip && !reduceMotion) _skip.forward(from: 0);
widget.onPressed!();
}
@@ -139,5 +169,18 @@ class _MornyePlaybackButtonState extends State<MornyePlaybackButton>
},
),
);
return AnimatedBuilder(
animation: _press,
child: button,
builder: (context, child) => DecoratedBox(
decoration: BoxDecoration(
shape: BoxShape.circle,
color: widget.color.withValues(
alpha: 0.14 * Curves.easeOutCubic.transform(_press.value),
),
),
child: child,
),
);
}
}
+47
View File
@@ -27,6 +27,18 @@ void main() {
),
);
double feedbackOpacity(WidgetTester tester) {
final feedback = tester.widget<DecoratedBox>(
find
.ancestor(
of: find.byType(IconButton),
matching: find.byType(DecoratedBox),
)
.first,
);
return (feedback.decoration as BoxDecoration).color!.a;
}
testWidgets('press feedback keeps the tap target fixed and cancels safely', (
tester,
) async {
@@ -36,15 +48,18 @@ void main() {
final icon = find.byIcon(CupertinoIcons.play_fill);
final bounds = tester.getRect(button);
final iconBounds = tester.getRect(icon);
expect(feedbackOpacity(tester), 0);
final press = await tester.startGesture(bounds.center);
await tester.pump(const Duration(milliseconds: 100));
await tester.pump(const Duration(milliseconds: 200));
expect(tester.getRect(icon).width, lessThan(iconBounds.width));
expect(tester.getRect(button), bounds);
expect(feedbackOpacity(tester), closeTo(0.14, 0.001));
await press.up();
await tester.pumpAndSettle();
expect(calls, 1);
expect(tester.getRect(icon), iconBounds);
expect(feedbackOpacity(tester), 0);
final cancelled = await tester.startGesture(bounds.center);
await tester.pump(const Duration(milliseconds: 100));
@@ -52,8 +67,40 @@ void main() {
await tester.pumpAndSettle();
expect(calls, 1);
expect(tester.getRect(icon), iconBounds);
expect(feedbackOpacity(tester), 0);
});
for (final icon in [
CupertinoIcons.play_fill,
CupertinoIcons.pause_fill,
CupertinoIcons.forward_fill,
CupertinoIcons.backward_fill,
]) {
testWidgets('quick tap on $icon fades a circular highlight in and out', (
tester,
) async {
var calls = 0;
await pumpButton(tester, icon: icon, onPressed: () => calls++);
final button = find.byType(IconButton);
final bounds = tester.getRect(button);
await tester.tap(button);
expect(calls, 1);
await tester.pump();
await tester.pump(const Duration(milliseconds: 50));
final entering = feedbackOpacity(tester);
expect(entering, greaterThan(0));
await tester.pump(const Duration(milliseconds: 60));
final peak = feedbackOpacity(tester);
expect(peak, greaterThan(entering));
await tester.pump(const Duration(milliseconds: 220));
expect(feedbackOpacity(tester), lessThan(peak));
expect(tester.getRect(button), bounds);
await tester.pumpAndSettle();
expect(feedbackOpacity(tester), 0);
expect(calls, 1);
});
}
for (final icon in [
CupertinoIcons.forward_fill,
CupertinoIcons.backward_fill,