collection: action to show moved/copied items

This commit is contained in:
Thibault Deckers 2021-06-09 08:05:35 +09:00
parent fa5f30ea7c
commit 9c8d0215c6
11 changed files with 190 additions and 98 deletions

View file

@ -18,6 +18,8 @@
"@applyButtonLabel": {}, "@applyButtonLabel": {},
"deleteButtonLabel": "DELETE", "deleteButtonLabel": "DELETE",
"@deleteButtonLabel": {}, "@deleteButtonLabel": {},
"showButtonLabel": "SHOW",
"@showButtonLabel": {},
"hideButtonLabel": "HIDE", "hideButtonLabel": "HIDE",
"@hideButtonLabel": {}, "@hideButtonLabel": {},
"continueButtonLabel": "CONTINUE", "continueButtonLabel": "CONTINUE",

View file

@ -7,6 +7,7 @@
"applyButtonLabel": "확인", "applyButtonLabel": "확인",
"deleteButtonLabel": "삭제", "deleteButtonLabel": "삭제",
"showButtonLabel": "보기",
"hideButtonLabel": "숨기기", "hideButtonLabel": "숨기기",
"continueButtonLabel": "다음", "continueButtonLabel": "다음",
"clearTooltip": "초기화", "clearTooltip": "초기화",

View file

@ -1,6 +1,16 @@
import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
class HighlightInfo extends ChangeNotifier { class HighlightInfo extends ChangeNotifier {
final EventBus eventBus = EventBus();
void trackItem<T>(
T item, {
required bool animate,
Object? highlight,
}) =>
eventBus.fire(TrackEvent<T>(item, animate, highlight));
Object? _item; Object? _item;
void set(Object item) { void set(Object item) {
@ -22,3 +32,12 @@ class HighlightInfo extends ChangeNotifier {
@override @override
String toString() => '$runtimeType#${shortHash(this)}{item=$_item}'; String toString() => '$runtimeType#${shortHash(this)}{item=$_item}';
} }
@immutable
class TrackEvent<T> {
final T item;
final bool animate;
final Object? highlight;
const TrackEvent(this.item, this.animate, this.highlight);
}

View file

@ -55,9 +55,10 @@ class Durations {
// delays & refresh intervals // delays & refresh intervals
static const opToastDisplay = Duration(seconds: 3); static const opToastDisplay = Duration(seconds: 3);
static const opToastActionDisplay = Duration(seconds: 5);
static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const infoScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100); static const collectionScrollMonitoringTimerDelay = Duration(milliseconds: 100);
static const collectionScalingCompleteNotificationDelay = Duration(milliseconds: 300); static const highlightJumpDelay = Duration(milliseconds: 400);
static const highlightScrollInitDelay = Duration(milliseconds: 800); static const highlightScrollInitDelay = Duration(milliseconds: 800);
static const videoProgressTimerInterval = Duration(milliseconds: 300); static const videoProgressTimerInterval = Duration(milliseconds: 300);
static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation; static Duration staggeredAnimationDelay = Durations.staggeredAnimation ~/ 6 * timeDilation;

View file

@ -32,6 +32,7 @@ class Themes {
), ),
snackBarTheme: SnackBarThemeData( snackBarTheme: SnackBarThemeData(
backgroundColor: Colors.grey.shade800, backgroundColor: Colors.grey.shade800,
actionTextColor: _accentColor,
contentTextStyle: const TextStyle( contentTextStyle: const TextStyle(
color: Colors.white, color: Colors.white,
), ),

View file

@ -4,7 +4,6 @@ import 'package:aves/app_mode.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/favourite.dart'; import 'package:aves/model/filters/favourite.dart';
import 'package:aves/model/filters/mime.dart'; import 'package:aves/model/filters/mime.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/enums.dart'; import 'package:aves/model/source/enums.dart';
import 'package:aves/ref/mime_types.dart'; import 'package:aves/ref/mime_types.dart';
@ -22,7 +21,7 @@ import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart'; import 'package:aves/widgets/common/behaviour/sloppy_scroll_physics.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/empty.dart'; import 'package:aves/widgets/common/identity/empty.dart';
import 'package:aves/widgets/common/identity/scroll_thumb.dart'; import 'package:aves/widgets/common/identity/scroll_thumb.dart';
@ -131,33 +130,37 @@ class _CollectionSectionedContent extends StatefulWidget {
_CollectionSectionedContentState createState() => _CollectionSectionedContentState(); _CollectionSectionedContentState createState() => _CollectionSectionedContentState();
} }
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> { class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with GridItemTrackerMixin<AvesEntry, _CollectionSectionedContent> {
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@override
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
final ValueNotifier<double> _appBarHeightNotifier = ValueNotifier(0); @override
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable'); final ValueNotifier<double> appBarHeightNotifier = ValueNotifier(0);
@override
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'thumbnail-collection-scrollable');
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scrollView = AnimationLimiter( final scrollView = AnimationLimiter(
child: _CollectionScrollView( child: _CollectionScrollView(
scrollableKey: _scrollableKey, scrollableKey: scrollableKey,
collection: collection, collection: collection,
appBar: CollectionAppBar( appBar: CollectionAppBar(
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
collection: collection, collection: collection,
), ),
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
isScrollingNotifier: widget.isScrollingNotifier, isScrollingNotifier: widget.isScrollingNotifier,
scrollController: scrollController, scrollController: scrollController,
), ),
); );
final scaler = _CollectionScaler( final scaler = _CollectionScaler(
scrollableKey: _scrollableKey, scrollableKey: scrollableKey,
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
child: scrollView, child: scrollView,
); );
@ -166,7 +169,7 @@ class _CollectionSectionedContentState extends State<_CollectionSectionedContent
selectable: isMainMode, selectable: isMainMode,
collection: collection, collection: collection,
scrollController: scrollController, scrollController: scrollController,
appBarHeightNotifier: _appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
child: scaler, child: scaler,
); );
@ -190,7 +193,6 @@ class _CollectionScaler extends StatelessWidget {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing); final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<AvesEntry>( return GridScaleGestureDetector<AvesEntry>(
scrollableKey: scrollableKey, scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
gridBuilder: (center, extent, child) => CustomPaint( gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter( painter: GridPainter(
center: center, center: center,
@ -211,11 +213,6 @@ class _CollectionScaler extends StatelessWidget {
highlightable: false, highlightable: false,
), ),
), ),
getScaledItemTileRect: (context, entry) {
final sectionedListLayout = context.read<SectionedListLayout<AvesEntry>>();
return sectionedListLayout.getTileRect(entry) ?? Rect.zero;
},
onScaled: (entry) => context.read<HighlightInfo>().set(entry),
child: child, child: child,
); );
} }

View file

@ -4,20 +4,27 @@ import 'package:aves/model/actions/collection_actions.dart';
import 'package:aves/model/actions/entry_actions.dart'; import 'package:aves/model/actions/entry_actions.dart';
import 'package:aves/model/actions/move_type.dart'; import 'package:aves/model/actions/move_type.dart';
import 'package:aves/model/entry.dart'; import 'package:aves/model/entry.dart';
import 'package:aves/model/filters/album.dart';
import 'package:aves/model/highlight.dart';
import 'package:aves/model/source/collection_lens.dart'; import 'package:aves/model/source/collection_lens.dart';
import 'package:aves/model/source/collection_source.dart'; import 'package:aves/model/source/collection_source.dart';
import 'package:aves/services/android_app_service.dart'; import 'package:aves/services/android_app_service.dart';
import 'package:aves/services/image_op_events.dart'; import 'package:aves/services/image_op_events.dart';
import 'package:aves/services/services.dart'; import 'package:aves/services/services.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/utils/android_file_utils.dart'; import 'package:aves/utils/android_file_utils.dart';
import 'package:aves/widgets/collection/collection_page.dart';
import 'package:aves/widgets/common/action_mixins/feedback.dart'; import 'package:aves/widgets/common/action_mixins/feedback.dart';
import 'package:aves/widgets/common/action_mixins/permission_aware.dart'; import 'package:aves/widgets/common/action_mixins/permission_aware.dart';
import 'package:aves/widgets/common/action_mixins/size_aware.dart'; import 'package:aves/widgets/common/action_mixins/size_aware.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/dialogs/aves_dialog.dart'; import 'package:aves/widgets/dialogs/aves_dialog.dart';
import 'package:aves/widgets/filter_grids/album_pick.dart'; import 'package:aves/widgets/filter_grids/album_pick.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
import 'package:pedantic/pedantic.dart';
import 'package:provider/provider.dart';
class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin { class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMixin {
final CollectionLens collection; final CollectionLens collection;
@ -114,6 +121,11 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
collection.browse(); collection.browse();
source.resumeMonitoring(); source.resumeMonitoring();
// cleanup
if (moveType == MoveType.move) {
await storageService.deleteEmptyDirectories(selectionDirs);
}
final l10n = context.l10n; final l10n = context.l10n;
final movedCount = movedOps.length; final movedCount = movedOps.length;
if (movedCount < todoCount) { if (movedCount < todoCount) {
@ -121,12 +133,46 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count)); showFeedback(context, copy ? l10n.collectionCopyFailureFeedback(count) : l10n.collectionMoveFailureFeedback(count));
} else { } else {
final count = movedCount; final count = movedCount;
showFeedback(context, copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count)); showFeedback(
} context,
copy ? l10n.collectionCopySuccessFeedback(count) : l10n.collectionMoveSuccessFeedback(count),
// cleanup SnackBarAction(
if (moveType == MoveType.move) { label: context.l10n.showButtonLabel,
await storageService.deleteEmptyDirectories(selectionDirs); onPressed: () async {
final highlightInfo = context.read<HighlightInfo>();
var targetCollection = collection;
if (collection.filters.any((f) => f is AlbumFilter)) {
final filter = AlbumFilter(destinationAlbum, source.getAlbumDisplayName(context, destinationAlbum));
// we could simply add the filter to the current collection
// but navigating makes the change less jarring
targetCollection = CollectionLens(
source: collection.source,
filters: collection.filters,
groupFactor: collection.groupFactor,
sortFactor: collection.sortFactor,
)..addFilter(filter);
unawaited(Navigator.pushReplacement(
context,
MaterialPageRoute(
settings: const RouteSettings(name: CollectionPage.routeName),
builder: (context) {
return CollectionPage(
targetCollection,
);
},
),
));
await Future.delayed(Durations.staggeredAnimationPageTarget);
}
await Future.delayed(Durations.highlightScrollInitDelay);
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, animate: true, highlight: targetEntry);
}
},
),
);
} }
}, },
); );

View file

@ -6,15 +6,16 @@ import 'package:percent_indicator/circular_percent_indicator.dart';
mixin FeedbackMixin { mixin FeedbackMixin {
void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar(); void dismissFeedback(BuildContext context) => ScaffoldMessenger.of(context).hideCurrentSnackBar();
void showFeedback(BuildContext context, String message) { void showFeedback(BuildContext context, String message, [SnackBarAction? action]) {
showFeedbackWithMessenger(ScaffoldMessenger.of(context), message); showFeedbackWithMessenger(ScaffoldMessenger.of(context), message, action);
} }
// provide the messenger if feedback happens as the widget is disposed // provide the messenger if feedback happens as the widget is disposed
void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message) { void showFeedbackWithMessenger(ScaffoldMessengerState messenger, String message, [SnackBarAction? action]) {
messenger.showSnackBar(SnackBar( messenger.showSnackBar(SnackBar(
content: Text(message), content: Text(message),
duration: Durations.opToastDisplay, action: action,
duration: action != null ? Durations.opToastActionDisplay : Durations.opToastDisplay,
)); ));
} }

View file

@ -0,0 +1,73 @@
import 'dart:async';
import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U> {
ValueNotifier<double> get appBarHeightNotifier;
GlobalKey get scrollableKey;
ScrollController get scrollController;
final List<StreamSubscription> _subscriptions = [];
@override
void initState() {
super.initState();
final highlightInfo = context.read<HighlightInfo>();
_subscriptions.add(highlightInfo.eventBus.on<TrackEvent<T>>().listen((e) => _trackItem(
e.item,
animate: e.animate,
highlight: e.highlight,
)));
}
@override
void dispose() {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose();
}
// about scrolling & offset retrieval:
// `Scrollable.ensureVisible` only works on already rendered objects
// `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata`
// `RenderViewport.scrollOffsetOf` is a good alternative
Future<void> _trackItem(T item, {required bool animate, required Object? highlight}) async {
final sectionedListLayout = context.read<SectionedListLayout<T>>();
final tileRect = sectionedListLayout.getTileRect(item);
if (tileRect == null) return;
final scrollableContext = scrollableKey.currentContext!;
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
// most of the time the app bar will be scrolled away after scaling,
// so we compensate for it to center the focal point thumbnail
final appBarHeight = appBarHeightNotifier.value;
final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight;
if (animate) {
if (scrollOffset > 0) {
await scrollController.animateTo(
scrollOffset,
duration: Duration(milliseconds: (scrollOffset / 2).round().clamp(Durations.highlightScrollAnimationMinMillis, Durations.highlightScrollAnimationMaxMillis)),
curve: Curves.easeInOutCubic,
);
}
} else {
final maxScrollExtent = scrollController.position.maxScrollExtent;
scrollController.jumpTo(scrollOffset.clamp(.0, maxScrollExtent));
await Future.delayed(Durations.highlightJumpDelay);
}
if (highlight != null) {
context.read<HighlightInfo>().set(highlight);
}
}
}

View file

@ -1,5 +1,6 @@
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/tile_extent_controller.dart'; import 'package:aves/widgets/common/tile_extent_controller.dart';
@ -17,20 +18,16 @@ class ScalerMetadata<T> {
class GridScaleGestureDetector<T> extends StatefulWidget { class GridScaleGestureDetector<T> extends StatefulWidget {
final GlobalKey scrollableKey; final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier; final Widget Function(Offset center, double extent, Widget child) gridBuilder;
final Widget Function(Offset center, double extent, Widget child)? gridBuilder;
final Widget Function(T item, double extent) scaledBuilder; final Widget Function(T item, double extent) scaledBuilder;
final Rect Function(BuildContext context, T item) getScaledItemTileRect; final Object Function(T item)? highlightItem;
final void Function(T item) onScaled;
final Widget child; final Widget child;
const GridScaleGestureDetector({ const GridScaleGestureDetector({
required this.scrollableKey, required this.scrollableKey,
required this.appBarHeightNotifier, required this.gridBuilder,
this.gridBuilder,
required this.scaledBuilder, required this.scaledBuilder,
required this.getScaledItemTileRect, this.highlightItem,
required this.onScaled,
required this.child, required this.child,
}); });
@ -114,11 +111,9 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
} else { } else {
// scroll to show the focal point thumbnail at its new position // scroll to show the focal point thumbnail at its new position
WidgetsBinding.instance!.addPostFrameCallback((_) { WidgetsBinding.instance!.addPostFrameCallback((_) {
final entry = _metadata!.item; final trackItem = _metadata!.item;
_scrollToItem(entry); final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem;
// warning: posting `onScaled` in the next frame with `addPostFrameCallback` context.read<HighlightInfo>().trackItem(trackItem, animate: false, highlight: highlightItem);
// would trigger only when the scrollable offset actually changes
Future.delayed(Durations.collectionScalingCompleteNotificationDelay).then((_) => widget.onScaled(entry));
_applyingScale = false; _applyingScale = false;
}); });
} }
@ -135,26 +130,6 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
), ),
); );
} }
// about scrolling & offset retrieval:
// `Scrollable.ensureVisible` only works on already rendered objects
// `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata`
// `RenderViewport.scrollOffsetOf` is a good alternative
void _scrollToItem(T item) {
final scrollableContext = widget.scrollableKey.currentContext!;
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
final tileRect = widget.getScaledItemTileRect(context, item);
// most of the time the app bar will be scrolled away after scaling,
// so we compensate for it to center the focal point thumbnail
final appBarHeight = widget.appBarHeightNotifier.value;
final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight;
final controller = PrimaryScrollController.of(context);
if (controller != null) {
final maxScrollExtent = controller.position.maxScrollExtent;
controller.jumpTo(scrollOffset.clamp(.0, maxScrollExtent));
}
}
} }
class ScaleOverlay extends StatefulWidget { class ScaleOverlay extends StatefulWidget {
@ -162,14 +137,14 @@ class ScaleOverlay extends StatefulWidget {
final Offset center; final Offset center;
final double viewportWidth; final double viewportWidth;
final ValueNotifier<double> scaledExtentNotifier; final ValueNotifier<double> scaledExtentNotifier;
final Widget Function(Offset center, double extent, Widget child)? gridBuilder; final Widget Function(Offset center, double extent, Widget child) gridBuilder;
const ScaleOverlay({ const ScaleOverlay({
required this.builder, required this.builder,
required this.center, required this.center,
required this.viewportWidth, required this.viewportWidth,
required this.scaledExtentNotifier, required this.scaledExtentNotifier,
this.gridBuilder, required this.gridBuilder,
}); });
@override @override
@ -243,7 +218,7 @@ class _ScaleOverlayState extends State<ScaleOverlay> {
), ),
], ],
); );
child = widget.gridBuilder?.call(clampedCenter, extent, child) ?? child; child = widget.gridBuilder(clampedCenter, extent, child);
return child; return child;
}, },
), ),

View file

@ -11,6 +11,7 @@ import 'package:aves/widgets/common/basic/insets.dart';
import 'package:aves/widgets/common/behaviour/double_back_pop.dart'; import 'package:aves/widgets/common/behaviour/double_back_pop.dart';
import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/extensions/build_context.dart';
import 'package:aves/widgets/common/extensions/media_query.dart'; import 'package:aves/widgets/common/extensions/media_query.dart';
import 'package:aves/widgets/common/grid/item_tracker.dart';
import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:aves/widgets/common/grid/sliver.dart'; import 'package:aves/widgets/common/grid/sliver.dart';
import 'package:aves/widgets/common/identity/aves_filter_chip.dart'; import 'package:aves/widgets/common/identity/aves_filter_chip.dart';
@ -280,18 +281,21 @@ class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget
_FilterSectionedContentState createState() => _FilterSectionedContentState<T>(); _FilterSectionedContentState createState() => _FilterSectionedContentState<T>();
} }
class _FilterSectionedContentState<T extends CollectionFilter> extends State<_FilterSectionedContent<T>> { class _FilterSectionedContentState<T extends CollectionFilter> extends State<_FilterSectionedContent<T>> with GridItemTrackerMixin<FilterGridItem<T>, _FilterSectionedContent<T>> {
Widget get appBar => widget.appBar; Widget get appBar => widget.appBar;
@override
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier; ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections; Map<ChipSectionKey, List<FilterGridItem<T>>> get visibleFilterSections => widget.visibleFilterSections;
Widget Function() get emptyBuilder => widget.emptyBuilder; Widget Function() get emptyBuilder => widget.emptyBuilder;
@override
ScrollController get scrollController => widget.scrollController; ScrollController get scrollController => widget.scrollController;
final GlobalKey _scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable'); @override
final GlobalKey scrollableKey = GlobalKey(debugLabel: 'filter-grid-page-scrollable');
@override @override
void initState() { void initState() {
@ -303,7 +307,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
Widget build(BuildContext context) { Widget build(BuildContext context) {
final scrollView = AnimationLimiter( final scrollView = AnimationLimiter(
child: _FilterScrollView<T>( child: _FilterScrollView<T>(
scrollableKey: _scrollableKey, scrollableKey: scrollableKey,
appBar: appBar, appBar: appBar,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
sortFactor: widget.sortFactor, sortFactor: widget.sortFactor,
@ -313,7 +317,7 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
); );
final scaler = _FilterScaler<T>( final scaler = _FilterScaler<T>(
scrollableKey: _scrollableKey, scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier, appBarHeightNotifier: appBarHeightNotifier,
child: scrollView, child: scrollView,
); );
@ -328,33 +332,10 @@ class _FilterSectionedContentState<T extends CollectionFilter> extends State<_Fi
final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter); final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter);
if (gridItem != null) { if (gridItem != null) {
await Future.delayed(Durations.highlightScrollInitDelay); await Future.delayed(Durations.highlightScrollInitDelay);
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>(); highlightInfo.trackItem(gridItem, animate: true, highlight: filter);
final tileRect = sectionedListLayout.getTileRect(gridItem);
if (tileRect != null) {
await _scrollToItem(tileRect);
highlightInfo.set(filter);
}
} }
} }
} }
Future<void> _scrollToItem(Rect tileRect) async {
final scrollableContext = _scrollableKey.currentContext!;
final scrollableHeight = (scrollableContext.findRenderObject() as RenderBox).size.height;
// most of the time the app bar will be scrolled away after scaling,
// so we compensate for it to center the focal point thumbnail
final appBarHeight = appBarHeightNotifier.value;
final scrollOffset = tileRect.top + (tileRect.height - scrollableHeight) / 2 + appBarHeight;
if (scrollOffset > 0) {
await scrollController.animateTo(
scrollOffset,
duration: Duration(milliseconds: (scrollOffset / 2).round().clamp(Durations.highlightScrollAnimationMinMillis, Durations.highlightScrollAnimationMaxMillis)),
curve: Curves.easeInOutCubic,
);
}
}
} }
class _FilterScaler<T extends CollectionFilter> extends StatelessWidget { class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
@ -374,7 +355,6 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing); final tileSpacing = context.select<TileExtentController, double>((controller) => controller.spacing);
return GridScaleGestureDetector<FilterGridItem<T>>( return GridScaleGestureDetector<FilterGridItem<T>>(
scrollableKey: scrollableKey, scrollableKey: scrollableKey,
appBarHeightNotifier: appBarHeightNotifier,
gridBuilder: (center, extent, child) => CustomPaint( gridBuilder: (center, extent, child) => CustomPaint(
painter: GridPainter( painter: GridPainter(
center: center, center: center,
@ -396,11 +376,7 @@ class _FilterScaler<T extends CollectionFilter> extends StatelessWidget {
highlightable: false, highlightable: false,
); );
}, },
getScaledItemTileRect: (context, item) { highlightItem: (item) => item.filter,
final sectionedListLayout = context.read<SectionedListLayout<FilterGridItem<T>>>();
return sectionedListLayout.getTileRect(item) ?? Rect.zero;
},
onScaled: (item) => context.read<HighlightInfo>().set(item.filter),
child: child, child: child,
); );
} }