feat(android): better quick scroll/drag to scroll implementation

This commit is contained in:
Kingkor Roy Tirtho 2023-11-13 23:17:16 +06:00
parent 0a6b54da36
commit 2e2c44f0af
13 changed files with 330 additions and 340 deletions

View File

@ -163,6 +163,8 @@ class UserLocalTracks extends HookConsumerWidget {
final searchFocus = useFocusNode();
final isFiltering = useState(false);
final controller = useScrollController();
return Column(
children: [
Padding(
@ -256,7 +258,9 @@ class UserLocalTracks extends HookConsumerWidget {
ref.refresh(localTracksProvider);
},
child: InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
physics: const AlwaysScrollableScrollPhysics(),
itemCount: filteredTracks.length,
itemBuilder: (context, index) {

View File

@ -9,6 +9,7 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/collections/spotube_icons.dart';
import 'package:spotube/components/playlist/playlist_create_dialog.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_playbutton_card.dart';
import 'package:spotube/components/shared/fallbacks/anonymous_fallback.dart';
import 'package:spotube/components/playlist/playlist_card.dart';
@ -81,68 +82,71 @@ class UserPlaylists extends HookConsumerWidget {
return RefreshIndicator(
onRefresh: playlistsQuery.refresh,
child: SafeArea(
child: CustomScrollView(
child: InterScrollbar(
controller: controller,
slivers: [
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(10),
child: SearchBar(
onChanged: (value) => searchText.value = value,
hintText: context.l10n.filter_playlists,
leading: const Icon(SpotubeIcons.filter),
),
),
Row(
children: [
const SizedBox(width: 10),
const PlaylistCreateDialogButton(),
const SizedBox(width: 10),
ElevatedButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
onPressed: () {
GoRouter.of(context).push("/library/generate");
},
child: CustomScrollView(
controller: controller,
slivers: [
SliverToBoxAdapter(
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Padding(
padding: const EdgeInsets.all(10),
child: SearchBar(
onChanged: (value) => searchText.value = value,
hintText: context.l10n.filter_playlists,
leading: const Icon(SpotubeIcons.filter),
),
const SizedBox(width: 10),
],
),
],
),
Row(
children: [
const SizedBox(width: 10),
const PlaylistCreateDialogButton(),
const SizedBox(width: 10),
ElevatedButton.icon(
icon: const Icon(SpotubeIcons.magic),
label: Text(context.l10n.generate_playlist),
onPressed: () {
GoRouter.of(context).push("/library/generate");
},
),
const SizedBox(width: 10),
],
),
],
),
),
),
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
SliverGrid.builder(
itemCount: playlists.length + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
const SliverToBoxAdapter(
child: SizedBox(height: 10),
),
itemBuilder: (context, index) {
if (index == playlists.length) {
if (!playlistsQuery.hasNextPage) {
return const SizedBox.shrink();
SliverGrid.builder(
itemCount: playlists.length + 1,
gridDelegate: SliverGridDelegateWithMaxCrossAxisExtent(
maxCrossAxisExtent: 200,
mainAxisExtent: DesktopTools.platform.isMobile ? 225 : 250,
crossAxisSpacing: 8,
mainAxisSpacing: 8,
),
itemBuilder: (context, index) {
if (index == playlists.length) {
if (!playlistsQuery.hasNextPage) {
return const SizedBox.shrink();
}
return Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: playlistsQuery.fetchNext,
child: const ShimmerPlaybuttonCard(count: 1),
);
}
return Waypoint(
controller: controller,
isGrid: true,
onTouchEdge: playlistsQuery.fetchNext,
child: const ShimmerPlaybuttonCard(count: 1),
);
}
return PlaylistCard(playlists[index]);
},
)
],
return PlaylistCard(playlists[index]);
},
)
],
),
),
),
);

View File

@ -44,6 +44,7 @@ class PlayerQueue extends HookConsumerWidget {
topRight: Radius.circular(10),
);
final theme = Theme.of(context);
final mediaQuery = MediaQuery.of(context);
final headlineColor = theme.textTheme.headlineSmall?.color;
final filteredTracks = useMemoized(
@ -108,171 +109,166 @@ class PlayerQueue extends HookConsumerWidget {
searchText.value = '';
}
},
child: LayoutBuilder(builder: (context, constraints) {
return Column(
children: [
if (!floating)
Container(
height: 5,
width: 100,
margin: const EdgeInsets.only(bottom: 5, top: 2),
decoration: BoxDecoration(
color: headlineColor,
borderRadius: BorderRadius.circular(20),
),
child: Column(
children: [
if (!floating)
Container(
height: 5,
width: 100,
margin: const EdgeInsets.only(bottom: 5, top: 2),
decoration: BoxDecoration(
color: headlineColor,
borderRadius: BorderRadius.circular(20),
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (constraints.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
Text(
context.l10n.tracks_in_queue(tracks.length),
style: TextStyle(
color: headlineColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
Row(
crossAxisAlignment: CrossAxisAlignment.center,
mainAxisAlignment: MainAxisAlignment.center,
children: [
if (mediaQuery.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
Text(
context.l10n.tracks_in_queue(tracks.length),
style: TextStyle(
color: headlineColor,
fontWeight: FontWeight.bold,
fontSize: 18,
),
),
const Spacer(),
],
if (mediaQuery.mdAndUp || isSearching.value)
TextField(
onChanged: (value) {
searchText.value = value;
},
decoration: InputDecoration(
hintText: context.l10n.search,
isDense: true,
prefixIcon: mediaQuery.smAndDown
? IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_outlined,
),
onPressed: () {
isSearching.value = false;
searchText.value = '';
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size.square(20),
),
)
: const Icon(SpotubeIcons.filter),
constraints: BoxConstraints(
maxHeight: 40,
maxWidth: mediaQuery.smAndDown
? mediaQuery.size.width - 40
: 300,
),
),
const Spacer(),
],
if (constraints.mdAndUp || isSearching.value)
TextField(
onChanged: (value) {
searchText.value = value;
},
decoration: InputDecoration(
hintText: context.l10n.search,
isDense: true,
prefixIcon: constraints.smAndDown
? IconButton(
icon: const Icon(
Icons.arrow_back_ios_new_outlined,
),
onPressed: () {
isSearching.value = false;
searchText.value = '';
},
style: IconButton.styleFrom(
padding: EdgeInsets.zero,
minimumSize: const Size.square(20),
),
)
: const Icon(SpotubeIcons.filter),
constraints: BoxConstraints(
maxHeight: 40,
maxWidth: constraints.smAndDown
? constraints.maxWidth - 20
: 300,
)
else
IconButton.filledTonal(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
),
if (mediaQuery.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor:
theme.scaffoldBackgroundColor.withOpacity(0.5),
foregroundColor: theme.textTheme.headlineSmall?.color,
),
child: Row(
children: [
const Icon(SpotubeIcons.playlistRemove),
const SizedBox(width: 5),
Text(context.l10n.clear_all),
],
),
onPressed: () {
playlistNotifier.stop();
Navigator.of(context).pop();
},
),
const SizedBox(width: 10),
],
],
),
const SizedBox(height: 10),
if (!isSearching.value && searchText.value.isEmpty)
Flexible(
child: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
scrollController: controller,
itemCount: tracks.length,
shrinkWrap: true,
buildDefaultDragHandles: false,
itemBuilder: (context, i) {
final track = tracks.elementAt(i);
return AutoScrollTag(
key: ValueKey(i),
controller: controller,
index: i,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
leadingActions: [
ReorderableDragStartListener(
index: i,
child: const Icon(SpotubeIcons.dragHandle),
),
],
),
),
)
else
IconButton.filledTonal(
icon: const Icon(SpotubeIcons.filter),
onPressed: () {
isSearching.value = !isSearching.value;
},
),
if (constraints.mdAndUp || !isSearching.value) ...[
const SizedBox(width: 10),
FilledButton(
style: FilledButton.styleFrom(
backgroundColor:
theme.scaffoldBackgroundColor.withOpacity(0.5),
foregroundColor:
theme.textTheme.headlineSmall?.color,
),
child: Row(
children: [
const Icon(SpotubeIcons.playlistRemove),
const SizedBox(width: 5),
Text(context.l10n.clear_all),
],
),
onPressed: () {
playlistNotifier.stop();
Navigator.of(context).pop();
},
),
const SizedBox(width: 10),
],
],
),
const SizedBox(height: 10),
if (!isSearching.value && searchText.value.isEmpty)
Flexible(
child: InterScrollbar(
);
},
),
)
else
Flexible(
child: InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
child: ReorderableListView.builder(
onReorder: (oldIndex, newIndex) {
playlistNotifier.moveTrack(oldIndex, newIndex);
},
scrollController: controller,
itemCount: tracks.length,
shrinkWrap: true,
buildDefaultDragHandles: false,
itemBuilder: (context, i) {
final track = tracks.elementAt(i);
return AutoScrollTag(
key: ValueKey(i),
controller: controller,
itemCount: filteredTracks.length,
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
child: Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
leadingActions: [
ReorderableDragStartListener(
index: i,
child:
const Icon(SpotubeIcons.dragHandle),
),
],
),
),
);
},
),
),
)
else
Flexible(
child: InterScrollbar(
child: ListView.builder(
itemCount: filteredTracks.length,
itemBuilder: (context, i) {
final track = filteredTracks.elementAt(i);
return Padding(
padding:
const EdgeInsets.symmetric(horizontal: 8.0),
child: TrackTile(
index: i,
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
),
);
},
),
track: track,
onTap: () async {
if (playlist.activeTrack?.id == track.id) {
return;
}
await playlistNotifier.jumpToTrack(track);
},
),
);
},
),
),
],
);
}),
),
],
),
),
),
),

View File

@ -56,6 +56,8 @@ class SiblingTracksSheet extends HookConsumerWidget {
useValueListenable(searchController).text,
);
final controller = useScrollController();
final searchRequest = useMemoized(() async {
if (searchTerm.trim().isEmpty) {
return <YoutubeVideoInfo>[];
@ -204,8 +206,10 @@ class SiblingTracksSheet extends HookConsumerWidget {
transitionBuilder: (child, animation) =>
FadeTransition(opacity: animation, child: child),
child: InterScrollbar(
controller: controller,
child: switch (isSearching.value) {
false => ListView.builder(
controller: controller,
itemCount: siblings.length,
itemBuilder: (context, index) =>
itemBuilder(siblings[index]),
@ -223,7 +227,9 @@ class SiblingTracksSheet extends HookConsumerWidget {
}
return InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
itemCount: snapshot.data!.length,
itemBuilder: (context, index) =>
itemBuilder(snapshot.data![index]),

View File

@ -1,29 +1,16 @@
import 'package:draggable_scrollbar/draggable_scrollbar.dart';
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
class InterScrollbar extends HookWidget {
final Widget child;
final ScrollController? controller;
final bool? thumbVisibility;
final bool? trackVisibility;
final double? thickness;
final Radius? radius;
final bool Function(ScrollNotification)? notificationPredicate;
final bool? interactive;
final ScrollbarOrientation? scrollbarOrientation;
final ScrollController controller;
const InterScrollbar({
super.key,
required this.child,
this.controller,
this.thumbVisibility,
this.trackVisibility,
this.thickness,
this.radius,
this.notificationPredicate,
this.interactive,
this.scrollbarOrientation,
required this.controller,
});
@override
@ -32,38 +19,9 @@ class InterScrollbar extends HookWidget {
if (DesktopTools.platform.isDesktop) return child;
return ScrollbarTheme(
data: theme.scrollbarTheme.copyWith(
crossAxisMargin: 10,
minThumbLength: 80,
thickness: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.hovered) ||
states.contains(MaterialState.dragged) ||
states.contains(MaterialState.pressed)) {
return 40;
}
return 20;
}),
radius: const Radius.circular(20),
thumbColor: MaterialStateProperty.resolveWith((states) {
if (states.contains(MaterialState.hovered) ||
states.contains(MaterialState.dragged)) {
return theme.colorScheme.onSurface.withOpacity(0.5);
}
return theme.colorScheme.onSurface.withOpacity(0.3);
}),
),
child: Scrollbar(
controller: controller,
thumbVisibility: thumbVisibility,
trackVisibility: trackVisibility,
thickness: thickness,
radius: radius,
notificationPredicate: notificationPredicate,
interactive: interactive ?? true,
scrollbarOrientation: scrollbarOrientation,
child: child,
),
return DraggableScrollbar.semicircle(
controller: controller,
child: child,
);
}
}

View File

@ -79,28 +79,25 @@ class GenrePage extends HookConsumerWidget {
const ShimmerCategories()
else
Expanded(
child: InterScrollbar(
child: ListView.builder(
controller: scrollController,
child: ListView.builder(
controller: scrollController,
itemCount: categories.length,
itemBuilder: (context, index) {
return AnimatedSwitcher(
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
duration: const Duration(milliseconds: 300),
child: searchController.text.isEmpty &&
index == categories.length - 1 &&
categoriesQuery.hasNextPage
? const ShimmerCategories()
: CategoryCard(categories[index]),
);
},
),
itemCount: categories.length,
itemBuilder: (context, index) {
return AnimatedSwitcher(
transitionBuilder: (child, animation) {
return FadeTransition(
opacity: animation,
child: child,
);
},
duration: const Duration(milliseconds: 300),
child: searchController.text.isEmpty &&
index == categories.length - 1 &&
categoriesQuery.hasNextPage
? const ShimmerCategories()
: CategoryCard(categories[index]),
);
},
),
),
],

View File

@ -4,7 +4,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotify/spotify.dart';
import 'package:spotube/components/shared/horizontal_playbutton_card_view/horizontal_playbutton_card_view.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/shimmers/shimmer_categories.dart';
import 'package:spotube/extensions/context.dart';
import 'package:spotube/provider/authentication_provider.dart';
@ -47,48 +46,45 @@ class PersonalizedPage extends HookConsumerWidget {
[newReleases.pages],
);
return InterScrollbar(
return ListView(
controller: controller,
child: ListView(
controller: controller,
children: [
if (!featuredPlaylistsQuery.hasPageData &&
!featuredPlaylistsQuery.isLoadingNextPage)
const ShimmerCategories()
else
HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists.toList(),
title: Text(context.l10n.featured),
hasNextPage: featuredPlaylistsQuery.hasNextPage,
onFetchMore: featuredPlaylistsQuery.fetchNext,
),
if (auth != null &&
newReleases.hasPageData &&
userArtistsQuery.hasData &&
!newReleases.isLoadingNextPage)
HorizontalPlaybuttonCardView<Album>(
items: albums,
title: Text(context.l10n.new_releases),
hasNextPage: newReleases.hasNextPage,
onFetchMore: newReleases.fetchNext,
),
...?madeForUser.data?["content"]?["items"]?.map((item) {
final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist")
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
.toList()
.cast<PlaylistSimple>() ??
<PlaylistSimple>[];
if (playlists.isEmpty) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists,
title: Text(item["name"] ?? ""),
hasNextPage: false,
onFetchMore: () {},
);
})
],
),
children: [
if (!featuredPlaylistsQuery.hasPageData &&
!featuredPlaylistsQuery.isLoadingNextPage)
const ShimmerCategories()
else
HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists.toList(),
title: Text(context.l10n.featured),
hasNextPage: featuredPlaylistsQuery.hasNextPage,
onFetchMore: featuredPlaylistsQuery.fetchNext,
),
if (auth != null &&
newReleases.hasPageData &&
userArtistsQuery.hasData &&
!newReleases.isLoadingNextPage)
HorizontalPlaybuttonCardView<Album>(
items: albums,
title: Text(context.l10n.new_releases),
hasNextPage: newReleases.hasNextPage,
onFetchMore: newReleases.fetchNext,
),
...?madeForUser.data?["content"]?["items"]?.map((item) {
final playlists = item["content"]?["items"]
?.where((itemL2) => itemL2["type"] == "playlist")
.map((itemL2) => PlaylistSimple.fromJson(itemL2))
.toList()
.cast<PlaylistSimple>() ??
<PlaylistSimple>[];
if (playlists.isEmpty) return const SizedBox.shrink();
return HorizontalPlaybuttonCardView<PlaylistSimple>(
items: playlists,
title: Text(item["name"] ?? ""),
hasNextPage: false,
onFetchMore: () {},
);
})
],
);
}
}

View File

@ -71,26 +71,32 @@ class SearchPage extends HookConsumerWidget {
searchTerm.isNotEmpty;
final resultWidget = HookBuilder(
builder: (context) => InterScrollbar(
child: SingleChildScrollView(
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SearchTracksSection(query: searchTrack),
SearchPlaylistsSection(query: searchPlaylist),
const SizedBox(height: 20),
SearchArtistsSection(query: searchArtist),
const SizedBox(height: 20),
SearchAlbumsSection(query: searchAlbum),
],
builder: (context) {
final controller = useScrollController();
return InterScrollbar(
controller: controller,
child: SingleChildScrollView(
controller: controller,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: SafeArea(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
SearchTracksSection(query: searchTrack),
SearchPlaylistsSection(query: searchPlaylist),
const SizedBox(height: 20),
SearchArtistsSection(query: searchArtist),
const SizedBox(height: 20),
SearchAlbumsSection(query: searchAlbum),
],
),
),
),
),
),
),
);
},
);
return SafeArea(

View File

@ -15,6 +15,7 @@ class BlackListPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final controller = useScrollController();
final blacklist = ref.watch(BlackListNotifier.provider);
final searchText = useState("");
@ -58,7 +59,9 @@ class BlackListPage extends HookConsumerWidget {
),
),
InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
shrinkWrap: true,
itemCount: filteredBlacklist.length,
itemBuilder: (context, index) {

View File

@ -52,6 +52,7 @@ class LogsPage extends HookWidget {
@override
Widget build(BuildContext context) {
final controller = useScrollController();
final logs = useState<List<({DateTime? date, String body})>>([]);
final rawLogs = useRef<String>("");
final path = useRef<File?>(null);
@ -93,7 +94,9 @@ class LogsPage extends HookWidget {
),
body: SafeArea(
child: InterScrollbar(
controller: controller,
child: ListView.builder(
controller: controller,
itemCount: logs.value.length,
itemBuilder: (context, index) {
final log = logs.value[index];

View File

@ -1,6 +1,7 @@
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:flutter_desktop_tools/flutter_desktop_tools.dart';
import 'package:flutter_hooks/flutter_hooks.dart';
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:spotube/components/shared/inter_scrollbar/inter_scrollbar.dart';
import 'package:spotube/components/shared/page_window_title_bar.dart';
@ -20,6 +21,7 @@ class SettingsPage extends HookConsumerWidget {
@override
Widget build(BuildContext context, ref) {
final controller = useScrollController();
final preferencesNotifier = ref.watch(userPreferencesProvider.notifier);
return SafeArea(
@ -36,7 +38,9 @@ class SettingsPage extends HookConsumerWidget {
child: Container(
constraints: const BoxConstraints(maxWidth: 1366),
child: InterScrollbar(
controller: controller,
child: ListView(
controller: controller,
children: [
const SettingsAccountSection(),
const SettingsLanguageRegionSection(),

View File

@ -465,6 +465,15 @@ packages:
url: "https://pub.dev"
source: hosted
version: "2.1.2"
draggable_scrollbar:
dependency: "direct main"
description:
path: "."
ref: cfd570035bf393de541d32e9b28808b5d7e602df
resolved-ref: cfd570035bf393de541d32e9b28808b5d7e602df
url: "https://github.com/thielepaul/flutter-draggable-scrollbar.git"
source: git
version: "0.1.0"
duration:
dependency: "direct main"
description:

View File

@ -106,6 +106,10 @@ dependencies:
simple_icons: ^7.10.0
audio_service_mpris: ^0.1.0
file_picker: ^6.0.0
draggable_scrollbar:
git:
url: https://github.com/thielepaul/flutter-draggable-scrollbar.git
ref: cfd570035bf393de541d32e9b28808b5d7e602df
dev_dependencies:
build_runner: ^2.3.2