From f1b1688108c4277681837ef54335d55ab4afc834 Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Wed, 9 Jun 2021 18:04:33 +0900 Subject: [PATCH] collection/filter grids: keep items in view when switching device orientation --- lib/model/highlight.dart | 23 +++-- lib/widgets/collection/collection_grid.dart | 2 +- .../collection/entry_set_action_delegate.dart | 2 +- lib/widgets/common/grid/item_tracker.dart | 83 ++++++++++++++++--- lib/widgets/common/scaling.dart | 2 +- .../filter_grids/common/filter_grid_page.dart | 4 +- lib/widgets/viewer/entry_action_delegate.dart | 2 +- 7 files changed, 96 insertions(+), 22 deletions(-) diff --git a/lib/model/highlight.dart b/lib/model/highlight.dart index 46d8a669a..439283b77 100644 --- a/lib/model/highlight.dart +++ b/lib/model/highlight.dart @@ -1,15 +1,22 @@ import 'package:event_bus/event_bus.dart'; import 'package:flutter/foundation.dart'; +import 'package:flutter/widgets.dart'; class HighlightInfo extends ChangeNotifier { final EventBus eventBus = EventBus(); void trackItem( T item, { - required bool animate, - Object? highlight, + Alignment? alignment, + bool? animate, + Object? highlightItem, }) => - eventBus.fire(TrackEvent(item, animate, highlight)); + eventBus.fire(TrackEvent( + item, + alignment ?? Alignment.center, + animate ?? true, + highlightItem, + )); Object? _item; @@ -36,8 +43,14 @@ class HighlightInfo extends ChangeNotifier { @immutable class TrackEvent { final T item; + final Alignment alignment; final bool animate; - final Object? highlight; + final Object? highlightItem; - const TrackEvent(this.item, this.animate, this.highlight); + const TrackEvent( + this.item, + this.alignment, + this.animate, + this.highlightItem, + ); } diff --git a/lib/widgets/collection/collection_grid.dart b/lib/widgets/collection/collection_grid.dart index 1eaf2f30f..e53d4b09b 100644 --- a/lib/widgets/collection/collection_grid.dart +++ b/lib/widgets/collection/collection_grid.dart @@ -130,7 +130,7 @@ class _CollectionSectionedContent extends StatefulWidget { _CollectionSectionedContentState createState() => _CollectionSectionedContentState(); } -class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with GridItemTrackerMixin { +class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin { CollectionLens get collection => widget.collection; @override diff --git a/lib/widgets/collection/entry_set_action_delegate.dart b/lib/widgets/collection/entry_set_action_delegate.dart index 557c2be48..57a51551c 100644 --- a/lib/widgets/collection/entry_set_action_delegate.dart +++ b/lib/widgets/collection/entry_set_action_delegate.dart @@ -168,7 +168,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware 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); + highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); } }, ), diff --git a/lib/widgets/common/grid/item_tracker.dart b/lib/widgets/common/grid/item_tracker.dart index 384611856..ebe534009 100644 --- a/lib/widgets/common/grid/item_tracker.dart +++ b/lib/widgets/common/grid/item_tracker.dart @@ -3,32 +3,56 @@ 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:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -mixin GridItemTrackerMixin on State { +mixin GridItemTrackerMixin on State, WidgetsBindingObserver { ValueNotifier get appBarHeightNotifier; - GlobalKey get scrollableKey; - ScrollController get scrollController; + GlobalKey get scrollableKey; + + Size get scrollableSize { + final scrollableContext = scrollableKey.currentContext!; + return (scrollableContext.findRenderObject() as RenderBox).size; + } + + Orientation get _windowOrientation { + final size = WidgetsBinding.instance!.window.physicalSize; + return size.width > size.height ? Orientation.landscape : Orientation.portrait; + } + final List _subscriptions = []; + // grid section metrics before the app is laid out with the new orientation + late SectionedListLayout _lastSectionedListLayout; + late Size _lastScrollableSize; + late Orientation _lastOrientation; + @override void initState() { super.initState(); final highlightInfo = context.read(); _subscriptions.add(highlightInfo.eventBus.on>().listen((e) => _trackItem( e.item, + alignment: e.alignment, animate: e.animate, - highlight: e.highlight, + highlightItem: e.highlightItem, ))); + WidgetsBinding.instance!.addObserver(this); + WidgetsBinding.instance!.addPostFrameCallback((_) { + _lastSectionedListLayout = context.read>(); + _lastScrollableSize = scrollableSize; + _lastOrientation = _windowOrientation; + }); } @override void dispose() { + WidgetsBinding.instance!.removeObserver(this); _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); @@ -39,18 +63,20 @@ mixin GridItemTrackerMixin on State { // `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 _trackItem(T item, {required bool animate, required Object? highlight}) async { + Future _trackItem( + T item, { + required Alignment alignment, + required bool animate, + required Object? highlightItem, + }) async { final sectionedListLayout = context.read>(); 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; + final scrollOffset = appBarHeight + tileRect.top + (tileRect.height - scrollableSize.height) * ((alignment.y + 1) / 2); if (animate) { if (scrollOffset > 0) { @@ -66,8 +92,43 @@ mixin GridItemTrackerMixin on State { await Future.delayed(Durations.highlightJumpDelay); } - if (highlight != null) { - context.read().set(highlight); + if (highlightItem != null) { + context.read().set(highlightItem); } } + + @override + void didChangeMetrics() { + // the timing of `didChangeMetrics` is unreliable w.r.t. `MediaQuery` update (and following app layout) + // most of the time, this is called before `MediaQuery` is updated, but not all the time + final orientation = _windowOrientation; + if (_lastOrientation != orientation) { + _lastOrientation = orientation; + _onWindowOrientationChange(); + } + } + + void _onWindowOrientationChange() { + final layout = _lastSectionedListLayout; + final halfSize = _lastScrollableSize / 2; + final center = Offset( + halfSize.width, + halfSize.height + scrollController.offset - appBarHeightNotifier.value, + ); + var pivotItem = layout.getItemAt(center) ?? layout.getItemAt(Offset(0, center.dy)); + if (pivotItem == null) { + final pivotSectionKey = layout.getSectionAt(center.dy)?.sectionKey; + if (pivotSectionKey != null) { + pivotItem = layout.sections[pivotSectionKey]?.firstOrNull; + } + } + + WidgetsBinding.instance!.addPostFrameCallback((_) { + if (pivotItem != null) { + context.read().trackItem(pivotItem, animate: false); + } + _lastSectionedListLayout = context.read>(); + _lastScrollableSize = scrollableSize; + }); + } } diff --git a/lib/widgets/common/scaling.dart b/lib/widgets/common/scaling.dart index a20cae9ea..6cf59b501 100644 --- a/lib/widgets/common/scaling.dart +++ b/lib/widgets/common/scaling.dart @@ -113,7 +113,7 @@ class _GridScaleGestureDetectorState extends State().trackItem(trackItem, animate: false, highlight: highlightItem); + context.read().trackItem(trackItem, animate: false, highlightItem: highlightItem); _applyingScale = false; }); } diff --git a/lib/widgets/filter_grids/common/filter_grid_page.dart b/lib/widgets/filter_grids/common/filter_grid_page.dart index 9c0f890f9..3ddc72ab8 100644 --- a/lib/widgets/filter_grids/common/filter_grid_page.dart +++ b/lib/widgets/filter_grids/common/filter_grid_page.dart @@ -281,7 +281,7 @@ class _FilterSectionedContent extends StatefulWidget _FilterSectionedContentState createState() => _FilterSectionedContentState(); } -class _FilterSectionedContentState extends State<_FilterSectionedContent> with GridItemTrackerMixin, _FilterSectionedContent> { +class _FilterSectionedContentState extends State<_FilterSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin, _FilterSectionedContent> { Widget get appBar => widget.appBar; @override @@ -332,7 +332,7 @@ class _FilterSectionedContentState extends State<_Fi final gridItem = visibleFilterSections.values.expand((list) => list).firstWhereOrNull((gridItem) => gridItem.filter == filter); if (gridItem != null) { await Future.delayed(Durations.highlightScrollInitDelay); - highlightInfo.trackItem(gridItem, animate: true, highlight: filter); + highlightInfo.trackItem(gridItem, highlightItem: filter); } } } diff --git a/lib/widgets/viewer/entry_action_delegate.dart b/lib/widgets/viewer/entry_action_delegate.dart index d746a802b..a0c2da8c5 100644 --- a/lib/widgets/viewer/entry_action_delegate.dart +++ b/lib/widgets/viewer/entry_action_delegate.dart @@ -223,7 +223,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix 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); + highlightInfo.trackItem(targetEntry, highlightItem: targetEntry); } }, )