mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-21 23:11:45 +02:00
feat(lyrics): add staggered spring motion to visible rows
This commit is contained in:
1 parent
dad84eb342
commit
60c3e4d857
3 files changed
+189
-3
No files matched your search
@@ -6,7 +6,8 @@ import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/cupertino.dart' show CupertinoIcons;
|
||||
import 'package:flutter/foundation.dart' show ValueListenable, listEquals;
|
||||
import 'package:flutter/rendering.dart' show OverflowBoxFit, ScrollDirection;
|
||||
import 'package:flutter/rendering.dart'
|
||||
show OverflowBoxFit, RenderAbstractViewport, ScrollDirection;
|
||||
import 'package:flutter/services.dart' show SystemUiOverlayStyle;
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:share_plus/share_plus.dart';
|
||||
@@ -40,6 +41,7 @@ import 'package:spotiflac_android/widgets/expressive_button.dart';
|
||||
import 'package:spotiflac_android/widgets/expressive_icon_button.dart';
|
||||
import 'package:spotiflac_android/widgets/aligned_lyric_pronunciation.dart';
|
||||
import 'package:spotiflac_android/widgets/lyric_supplement_transition.dart';
|
||||
import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_quality_badges.dart';
|
||||
import 'package:spotiflac_android/widgets/audio_output_button.dart';
|
||||
import 'package:spotiflac_android/widgets/lyric_gap_indicator.dart';
|
||||
@@ -3094,7 +3096,7 @@ class _SyncedLyricsView extends ConsumerStatefulWidget {
|
||||
}
|
||||
|
||||
class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
with SingleTickerProviderStateMixin {
|
||||
with TickerProviderStateMixin {
|
||||
final ScrollController _scroll = ScrollController();
|
||||
ProviderSubscription<Duration>? _positionSubscription;
|
||||
ProviderSubscription<bool>? _playingSubscription;
|
||||
@@ -3127,6 +3129,32 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
List<double>? _layoutRowVisibility;
|
||||
List<double>? _targetLineExtents;
|
||||
bool _preserveRevealAnchor = false;
|
||||
late final AnimationController _scrollMotion = AnimationController(
|
||||
vsync: this,
|
||||
duration: LyricScrollMotion.duration,
|
||||
value: 1,
|
||||
);
|
||||
double _scrollMotionDistance = 0;
|
||||
int _scrollMotionFocus = 0;
|
||||
|
||||
void _stopScrollMotion() {
|
||||
_scrollMotion.stop();
|
||||
_scrollMotion.value = 1;
|
||||
}
|
||||
|
||||
void _startScrollMotion(int row, double distance) {
|
||||
if (distance.abs() < 0.5 ||
|
||||
widget.seekPreview.value != null ||
|
||||
MediaQuery.disableAnimationsOf(context)) {
|
||||
_stopScrollMotion();
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_scrollMotionDistance = distance;
|
||||
_scrollMotionFocus = row;
|
||||
});
|
||||
_scrollMotion.forward(from: 0);
|
||||
}
|
||||
|
||||
void _revealTick() {
|
||||
if (mounted) setState(() {});
|
||||
@@ -3212,6 +3240,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
}
|
||||
|
||||
void _resetLineKeys() {
|
||||
_stopScrollMotion();
|
||||
_hasStarted = false;
|
||||
_lineExtents = null;
|
||||
_lineLayoutKey = null;
|
||||
@@ -3239,7 +3268,10 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
_positionSubscription = null;
|
||||
_playingSubscription = null;
|
||||
_loadingSubscription = null;
|
||||
if (!widget.isActive) return;
|
||||
if (!widget.isActive) {
|
||||
_stopScrollMotion();
|
||||
return;
|
||||
}
|
||||
_userScrollIdleTimer?.cancel();
|
||||
_userScrolling = false;
|
||||
|
||||
@@ -3400,6 +3432,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
_userScrollIdleTimer?.cancel();
|
||||
_scroll.dispose();
|
||||
_rowReveal.dispose();
|
||||
_scrollMotion.dispose();
|
||||
super.dispose();
|
||||
}
|
||||
|
||||
@@ -3486,6 +3519,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
final duration = immediate || MediaQuery.disableAnimationsOf(context)
|
||||
? Duration.zero
|
||||
: Duration(milliseconds: widget.seekPreview.value != null ? 220 : 380);
|
||||
if (duration == Duration.zero) _stopScrollMotion();
|
||||
final extents = _targetLineExtents;
|
||||
if (context.isMornye && extents != null && row < extents.length) {
|
||||
final position = _scroll.position;
|
||||
@@ -3497,6 +3531,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
if (duration == Duration.zero) {
|
||||
_scroll.jumpTo(offset);
|
||||
} else {
|
||||
_startScrollMotion(row, offset - position.pixels);
|
||||
await _scroll.animateTo(
|
||||
offset,
|
||||
duration: duration,
|
||||
@@ -3508,6 +3543,16 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
if (index < _lineKeys.length) {
|
||||
final lineContext = _lineKeys[index].currentContext;
|
||||
if (lineContext != null) {
|
||||
final object = lineContext.findRenderObject();
|
||||
final viewport = object == null
|
||||
? null
|
||||
: RenderAbstractViewport.maybeOf(object);
|
||||
if (duration != Duration.zero && viewport != null) {
|
||||
_startScrollMotion(
|
||||
row,
|
||||
viewport.getOffsetToReveal(object!, 0.5).offset - _scroll.offset,
|
||||
);
|
||||
}
|
||||
await Scrollable.ensureVisible(
|
||||
lineContext,
|
||||
alignment: 0.5,
|
||||
@@ -3531,6 +3576,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
if (duration == Duration.zero) {
|
||||
_scroll.jumpTo(clamped.toDouble());
|
||||
} else {
|
||||
_startScrollMotion(row, clamped - position.pixels);
|
||||
await _scroll.animateTo(
|
||||
clamped.toDouble(),
|
||||
duration: duration,
|
||||
@@ -3602,6 +3648,7 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
(notification is UserScrollNotification &&
|
||||
notification.direction != ScrollDirection.idle);
|
||||
if (dragging) {
|
||||
_stopScrollMotion();
|
||||
_userScrolling = true;
|
||||
_userScrollIdleTimer?.cancel();
|
||||
} else if (notification is ScrollEndNotification && _userScrolling) {
|
||||
@@ -3880,6 +3927,13 @@ class _SyncedLyricsViewState extends ConsumerState<_SyncedLyricsView>
|
||||
);
|
||||
}
|
||||
|
||||
content = LyricScrollMotion(
|
||||
progress: _scrollMotion,
|
||||
distance: _scrollMotionDistance,
|
||||
rowsAfterFocus: row - _scrollMotionFocus,
|
||||
enabled: !isActive,
|
||||
child: content,
|
||||
);
|
||||
return _revealRow(
|
||||
index,
|
||||
reveal,
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/physics.dart';
|
||||
|
||||
/// One shared clock adds a small, staggered spring to upcoming visible rows.
|
||||
/// Only painting moves: lyric timings and list extents stay fixed.
|
||||
class LyricScrollMotion extends StatelessWidget {
|
||||
const LyricScrollMotion({
|
||||
super.key,
|
||||
required this.progress,
|
||||
required this.distance,
|
||||
required this.rowsAfterFocus,
|
||||
required this.child,
|
||||
this.enabled = true,
|
||||
});
|
||||
|
||||
final Animation<double> progress;
|
||||
final double distance;
|
||||
final int rowsAfterFocus;
|
||||
final Widget child;
|
||||
final bool enabled;
|
||||
|
||||
static const duration = Duration(milliseconds: 700);
|
||||
static final _spring = SpringSimulation(
|
||||
SpringDescription.withDampingRatio(mass: 1, stiffness: 180, ratio: 0.82),
|
||||
0,
|
||||
1,
|
||||
0,
|
||||
);
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
if (!enabled ||
|
||||
rowsAfterFocus <= 0 ||
|
||||
distance == 0 ||
|
||||
MediaQuery.disableAnimationsOf(context)) {
|
||||
return child;
|
||||
}
|
||||
return AnimatedBuilder(
|
||||
animation: progress,
|
||||
child: child,
|
||||
builder: (context, child) {
|
||||
final t = progress.value * 0.7;
|
||||
final delay = rowsAfterFocus.clamp(1, 6) * 0.035;
|
||||
final scroll = Curves.easeOutCubic.transform((t / 0.38).clamp(0, 1));
|
||||
final following = t <= delay ? 0.0 : _spring.x(t - delay);
|
||||
final offset = progress.value >= 1
|
||||
? 0.0
|
||||
: distance.clamp(-96, 96) * (scroll - following);
|
||||
return Transform.translate(
|
||||
offset: Offset(0, offset),
|
||||
transformHitTests: true,
|
||||
child: child,
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/widgets/lyric_scroll_motion.dart';
|
||||
|
||||
void main() {
|
||||
testWidgets(
|
||||
'later lyric rows trail earlier rows and settle to their layout',
|
||||
(tester) async {
|
||||
final clock = AnimationController(
|
||||
vsync: tester,
|
||||
duration: LyricScrollMotion.duration,
|
||||
);
|
||||
addTearDown(clock.dispose);
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: Column(
|
||||
children: [
|
||||
for (final row in [1, 3])
|
||||
LyricScrollMotion(
|
||||
progress: clock,
|
||||
distance: 64,
|
||||
rowsAfterFocus: row,
|
||||
child: Text('Row $row'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
);
|
||||
final first = tester.getTopLeft(find.text('Row 1')).dy;
|
||||
final third = tester.getTopLeft(find.text('Row 3')).dy;
|
||||
clock.forward();
|
||||
await tester.pump();
|
||||
await tester.pump(const Duration(milliseconds: 100));
|
||||
final firstLag = tester.getTopLeft(find.text('Row 1')).dy - first;
|
||||
final thirdLag = tester.getTopLeft(find.text('Row 3')).dy - third;
|
||||
expect(thirdLag, greaterThan(firstLag));
|
||||
expect(thirdLag, greaterThan(0));
|
||||
await tester.pumpAndSettle();
|
||||
expect(tester.getTopLeft(find.text('Row 1')).dy, first);
|
||||
expect(tester.getTopLeft(find.text('Row 3')).dy, third);
|
||||
expect(tester.takeException(), isNull);
|
||||
},
|
||||
);
|
||||
|
||||
testWidgets('reduced motion and the focused lyric never acquire visual lag', (
|
||||
tester,
|
||||
) async {
|
||||
for (final reduced in [false, true]) {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
home: MediaQuery(
|
||||
data: MediaQueryData(disableAnimations: reduced),
|
||||
child: Column(
|
||||
children: [
|
||||
for (final row in [0, 3])
|
||||
LyricScrollMotion(
|
||||
progress: const AlwaysStoppedAnimation(0.3),
|
||||
distance: 64,
|
||||
rowsAfterFocus: row,
|
||||
child: Text('Row $row'),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
final first = find.descendant(
|
||||
of: find.byType(LyricScrollMotion).first,
|
||||
matching: find.byType(Transform),
|
||||
);
|
||||
expect(first, findsNothing);
|
||||
if (reduced) expect(find.byType(Transform), findsNothing);
|
||||
}
|
||||
});
|
||||
}
|
||||
Reference in new issue
Block a user