mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-05-15 05:10:28 +02:00
feat: add library scroll-to-top and scroll-to-bottom quick buttons
Add a pair of floating quick-scroll buttons on the library tab so long lists become easier to navigate. The buttons sit above the bottom navigation (or the selection toolbar in selection mode), fade in and out based on the active page's scroll metrics, and share their scroll-target keys per filter mode so switching filters does not carry over the previous page's scroll state.
This commit is contained in:
+169
-2
@@ -154,6 +154,9 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_queueLibraryCountsCache = {};
|
||||
final Map<_QueueLibraryPageRequest, _QueueLibraryPageData>
|
||||
_queueLibraryPageDataCache = {};
|
||||
final Map<String, _LibraryScrollTargets> _libraryScrollTargets = {};
|
||||
bool _showLibraryScrollTopButton = false;
|
||||
bool _showLibraryScrollBottomButton = false;
|
||||
|
||||
double _effectiveTextScale() {
|
||||
final textScale = MediaQuery.textScalerOf(context).scale(1.0);
|
||||
@@ -193,6 +196,13 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
_libraryGridScaleStartExtent = null;
|
||||
}
|
||||
|
||||
_LibraryScrollTargets _scrollTargetsForFilter(String filterMode) {
|
||||
return _libraryScrollTargets.putIfAbsent(
|
||||
filterMode,
|
||||
_LibraryScrollTargets.new,
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
@@ -308,11 +318,17 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
required bool hasMoreLibrary,
|
||||
required bool isPageLoading,
|
||||
}) {
|
||||
if (isPageLoading || !hasMoreLibrary || notification.depth != 0) {
|
||||
if (notification.depth != 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
final metrics = notification.metrics;
|
||||
_updateLibraryScrollButtons(filterMode, metrics);
|
||||
|
||||
if (isPageLoading || !hasMoreLibrary) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (metrics.maxScrollExtent <= 0) return false;
|
||||
final threshold = metrics.maxScrollExtent * 0.7;
|
||||
final nearEnd =
|
||||
@@ -324,6 +340,40 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return false;
|
||||
}
|
||||
|
||||
void _updateLibraryScrollButtons(String filterMode, ScrollMetrics metrics) {
|
||||
if (!mounted) return;
|
||||
if (filterMode != ref.read(settingsProvider).historyFilterMode) {
|
||||
return;
|
||||
}
|
||||
|
||||
final showTop =
|
||||
metrics.maxScrollExtent > 0 &&
|
||||
metrics.pixels > metrics.viewportDimension * 0.45;
|
||||
final showBottom =
|
||||
metrics.maxScrollExtent > 0 &&
|
||||
metrics.extentAfter > metrics.viewportDimension * 0.75;
|
||||
|
||||
if (showTop == _showLibraryScrollTopButton &&
|
||||
showBottom == _showLibraryScrollBottomButton) {
|
||||
return;
|
||||
}
|
||||
|
||||
setState(() {
|
||||
_showLibraryScrollTopButton = showTop;
|
||||
_showLibraryScrollBottomButton = showBottom;
|
||||
});
|
||||
}
|
||||
|
||||
void _resetLibraryScrollButtons() {
|
||||
if (!_showLibraryScrollTopButton && !_showLibraryScrollBottomButton) {
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
_showLibraryScrollTopButton = false;
|
||||
_showLibraryScrollBottomButton = false;
|
||||
});
|
||||
}
|
||||
|
||||
void _invalidateFilterContentCache() {
|
||||
_filterContentDataCache.clear();
|
||||
_filterCacheAllHistoryItems = null;
|
||||
@@ -474,6 +524,54 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
return unified;
|
||||
}
|
||||
|
||||
Widget _buildLibraryScrollButtons(
|
||||
BuildContext context,
|
||||
double bottomPadding,
|
||||
) {
|
||||
final visible =
|
||||
_showLibraryScrollTopButton || _showLibraryScrollBottomButton;
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final bottomOffset =
|
||||
bottomPadding +
|
||||
(_isSelectionMode || _isPlaylistSelectionMode ? 104 : 20);
|
||||
|
||||
return Positioned(
|
||||
right: 16,
|
||||
bottom: bottomOffset,
|
||||
child: IgnorePointer(
|
||||
ignoring: !visible,
|
||||
child: AnimatedOpacity(
|
||||
opacity: visible ? 1 : 0,
|
||||
duration: const Duration(milliseconds: 180),
|
||||
curve: Curves.easeOutCubic,
|
||||
child: Column(
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
if (_showLibraryScrollTopButton)
|
||||
_LibraryScrollButton(
|
||||
icon: Icons.keyboard_arrow_up,
|
||||
colorScheme: colorScheme,
|
||||
onPressed: () {
|
||||
unawaited(_scrollLibraryToTop());
|
||||
},
|
||||
),
|
||||
if (_showLibraryScrollTopButton && _showLibraryScrollBottomButton)
|
||||
const SizedBox(height: 8),
|
||||
if (_showLibraryScrollBottomButton)
|
||||
_LibraryScrollButton(
|
||||
icon: Icons.keyboard_arrow_down,
|
||||
colorScheme: colorScheme,
|
||||
onPressed: () {
|
||||
unawaited(_scrollLibraryToBottom());
|
||||
},
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Set<String> _downloadedPathKeys(List<DownloadHistoryItem> historyItems) {
|
||||
if (identical(historyItems, _cachedDownloadedPathKeysSource)) {
|
||||
return _cachedDownloadedPathKeys;
|
||||
@@ -700,6 +798,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
void _onFilterPageChanged(int index) {
|
||||
HapticFeedback.selectionClick();
|
||||
final filterMode = _filterModes[index];
|
||||
_resetLibraryScrollButtons();
|
||||
ref.read(settingsProvider.notifier).setHistoryFilterMode(filterMode);
|
||||
}
|
||||
|
||||
@@ -711,6 +810,30 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scrollLibraryToTop() async {
|
||||
final filterMode = ref.read(settingsProvider).historyFilterMode;
|
||||
final context = _libraryScrollTargets[filterMode]?.topKey.currentContext;
|
||||
if (context == null) return;
|
||||
await Scrollable.ensureVisible(
|
||||
context,
|
||||
duration: const Duration(milliseconds: 450),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: 0,
|
||||
);
|
||||
}
|
||||
|
||||
Future<void> _scrollLibraryToBottom() async {
|
||||
final filterMode = ref.read(settingsProvider).historyFilterMode;
|
||||
final context = _libraryScrollTargets[filterMode]?.bottomKey.currentContext;
|
||||
if (context == null) return;
|
||||
await Scrollable.ensureVisible(
|
||||
context,
|
||||
duration: const Duration(milliseconds: 550),
|
||||
curve: Curves.easeOutCubic,
|
||||
alignment: 1,
|
||||
);
|
||||
}
|
||||
|
||||
void _enterSelectionMode(String itemId) {
|
||||
HapticFeedback.mediumImpact();
|
||||
setState(() {
|
||||
@@ -2799,6 +2922,7 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
),
|
||||
),
|
||||
), // ScrollConfiguration
|
||||
_buildLibraryScrollButtons(context, bottomPadding),
|
||||
],
|
||||
),
|
||||
);
|
||||
@@ -3420,8 +3544,12 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
}
|
||||
}
|
||||
|
||||
final scrollTargets = _scrollTargetsForFilter(filterMode);
|
||||
final content = CustomScrollView(
|
||||
slivers: [
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(key: scrollTargets.topKey, height: 0),
|
||||
),
|
||||
if (totalTrackCount > 0 && filterMode == 'all')
|
||||
SliverToBoxAdapter(
|
||||
child: Padding(
|
||||
@@ -3851,7 +3979,10 @@ class _QueueTabState extends ConsumerState<QueueTab> {
|
||||
(filteredGroupedAlbums.isNotEmpty ||
|
||||
filteredGroupedLocalAlbums.isNotEmpty)))
|
||||
SliverToBoxAdapter(
|
||||
child: SizedBox(height: _isSelectionMode ? 100 : 16),
|
||||
child: SizedBox(
|
||||
key: scrollTargets.bottomKey,
|
||||
height: _isSelectionMode ? 100 : 16,
|
||||
),
|
||||
),
|
||||
],
|
||||
);
|
||||
@@ -6384,6 +6515,42 @@ class _AnimatedLibrarySliverGrid extends StatefulWidget {
|
||||
_AnimatedLibrarySliverGridState();
|
||||
}
|
||||
|
||||
class _LibraryScrollTargets {
|
||||
final GlobalKey topKey = GlobalKey();
|
||||
final GlobalKey bottomKey = GlobalKey();
|
||||
}
|
||||
|
||||
class _LibraryScrollButton extends StatelessWidget {
|
||||
final IconData icon;
|
||||
final ColorScheme colorScheme;
|
||||
final VoidCallback onPressed;
|
||||
|
||||
const _LibraryScrollButton({
|
||||
required this.icon,
|
||||
required this.colorScheme,
|
||||
required this.onPressed,
|
||||
});
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Material(
|
||||
color: colorScheme.surfaceContainerHigh,
|
||||
elevation: 3,
|
||||
shadowColor: colorScheme.shadow.withValues(alpha: 0.18),
|
||||
shape: const CircleBorder(),
|
||||
clipBehavior: Clip.antiAlias,
|
||||
child: InkWell(
|
||||
onTap: onPressed,
|
||||
customBorder: const CircleBorder(),
|
||||
child: SizedBox.square(
|
||||
dimension: 44,
|
||||
child: Icon(icon, color: colorScheme.onSurface),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
class _AnimatedLibrarySliverGridState extends State<_AnimatedLibrarySliverGrid>
|
||||
with SingleTickerProviderStateMixin {
|
||||
late final AnimationController _controller;
|
||||
|
||||
Reference in New Issue
Block a user