mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-09-29 12:52:13 +02:00
feat(player): animate circular feedback on transport buttons
This commit is contained in:
@@ -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,
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user