collection/filter grids: keep items in view when switching device orientation

This commit is contained in:
Thibault Deckers 2021-06-09 18:04:33 +09:00
parent 503981deac
commit f1b1688108
7 changed files with 96 additions and 22 deletions

View file

@ -1,15 +1,22 @@
import 'package:event_bus/event_bus.dart'; import 'package:event_bus/event_bus.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart';
class HighlightInfo extends ChangeNotifier { class HighlightInfo extends ChangeNotifier {
final EventBus eventBus = EventBus(); final EventBus eventBus = EventBus();
void trackItem<T>( void trackItem<T>(
T item, { T item, {
required bool animate, Alignment? alignment,
Object? highlight, bool? animate,
Object? highlightItem,
}) => }) =>
eventBus.fire(TrackEvent<T>(item, animate, highlight)); eventBus.fire(TrackEvent<T>(
item,
alignment ?? Alignment.center,
animate ?? true,
highlightItem,
));
Object? _item; Object? _item;
@ -36,8 +43,14 @@ class HighlightInfo extends ChangeNotifier {
@immutable @immutable
class TrackEvent<T> { class TrackEvent<T> {
final T item; final T item;
final Alignment alignment;
final bool animate; 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,
);
} }

View file

@ -130,7 +130,7 @@ class _CollectionSectionedContent extends StatefulWidget {
_CollectionSectionedContentState createState() => _CollectionSectionedContentState(); _CollectionSectionedContentState createState() => _CollectionSectionedContentState();
} }
class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with GridItemTrackerMixin<AvesEntry, _CollectionSectionedContent> { class _CollectionSectionedContentState extends State<_CollectionSectionedContent> with WidgetsBindingObserver, GridItemTrackerMixin<AvesEntry, _CollectionSectionedContent> {
CollectionLens get collection => widget.collection; CollectionLens get collection => widget.collection;
@override @override

View file

@ -168,7 +168,7 @@ class EntrySetActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAware
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
if (targetEntry != null) { if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, animate: true, highlight: targetEntry); highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
} }
}, },
), ),

View file

@ -3,32 +3,56 @@ import 'dart:async';
import 'package:aves/model/highlight.dart'; import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.dart'; import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/section_layout.dart'; import 'package:aves/widgets/common/grid/section_layout.dart';
import 'package:collection/collection.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U> { mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U>, WidgetsBindingObserver {
ValueNotifier<double> get appBarHeightNotifier; ValueNotifier<double> get appBarHeightNotifier;
GlobalKey get scrollableKey;
ScrollController get scrollController; 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<StreamSubscription> _subscriptions = []; final List<StreamSubscription> _subscriptions = [];
// grid section metrics before the app is laid out with the new orientation
late SectionedListLayout<T> _lastSectionedListLayout;
late Size _lastScrollableSize;
late Orientation _lastOrientation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
final highlightInfo = context.read<HighlightInfo>(); final highlightInfo = context.read<HighlightInfo>();
_subscriptions.add(highlightInfo.eventBus.on<TrackEvent<T>>().listen((e) => _trackItem( _subscriptions.add(highlightInfo.eventBus.on<TrackEvent<T>>().listen((e) => _trackItem(
e.item, e.item,
alignment: e.alignment,
animate: e.animate, animate: e.animate,
highlight: e.highlight, highlightItem: e.highlightItem,
))); )));
WidgetsBinding.instance!.addObserver(this);
WidgetsBinding.instance!.addPostFrameCallback((_) {
_lastSectionedListLayout = context.read<SectionedListLayout<T>>();
_lastScrollableSize = scrollableSize;
_lastOrientation = _windowOrientation;
});
} }
@override @override
void dispose() { void dispose() {
WidgetsBinding.instance!.removeObserver(this);
_subscriptions _subscriptions
..forEach((sub) => sub.cancel()) ..forEach((sub) => sub.cancel())
..clear(); ..clear();
@ -39,18 +63,20 @@ mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U> {
// `Scrollable.ensureVisible` only works on already rendered objects // `Scrollable.ensureVisible` only works on already rendered objects
// `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata` // `RenderViewport.showOnScreen` can find any `RenderSliver`, but not always a `RenderMetadata`
// `RenderViewport.scrollOffsetOf` is a good alternative // `RenderViewport.scrollOffsetOf` is a good alternative
Future<void> _trackItem(T item, {required bool animate, required Object? highlight}) async { Future<void> _trackItem(
T item, {
required Alignment alignment,
required bool animate,
required Object? highlightItem,
}) async {
final sectionedListLayout = context.read<SectionedListLayout<T>>(); final sectionedListLayout = context.read<SectionedListLayout<T>>();
final tileRect = sectionedListLayout.getTileRect(item); final tileRect = sectionedListLayout.getTileRect(item);
if (tileRect == null) return; 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, // most of the time the app bar will be scrolled away after scaling,
// so we compensate for it to center the focal point thumbnail // so we compensate for it to center the focal point thumbnail
final appBarHeight = appBarHeightNotifier.value; 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 (animate) {
if (scrollOffset > 0) { if (scrollOffset > 0) {
@ -66,8 +92,43 @@ mixin GridItemTrackerMixin<T, U extends StatefulWidget> on State<U> {
await Future.delayed(Durations.highlightJumpDelay); await Future.delayed(Durations.highlightJumpDelay);
} }
if (highlight != null) { if (highlightItem != null) {
context.read<HighlightInfo>().set(highlight); context.read<HighlightInfo>().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<HighlightInfo>().trackItem(pivotItem, animate: false);
}
_lastSectionedListLayout = context.read<SectionedListLayout<T>>();
_lastScrollableSize = scrollableSize;
});
}
} }

View file

@ -113,7 +113,7 @@ class _GridScaleGestureDetectorState<T> extends State<GridScaleGestureDetector<T
WidgetsBinding.instance!.addPostFrameCallback((_) { WidgetsBinding.instance!.addPostFrameCallback((_) {
final trackItem = _metadata!.item; final trackItem = _metadata!.item;
final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem; final highlightItem = widget.highlightItem?.call(trackItem) ?? trackItem;
context.read<HighlightInfo>().trackItem(trackItem, animate: false, highlight: highlightItem); context.read<HighlightInfo>().trackItem(trackItem, animate: false, highlightItem: highlightItem);
_applyingScale = false; _applyingScale = false;
}); });
} }

View file

@ -281,7 +281,7 @@ class _FilterSectionedContent<T extends CollectionFilter> extends StatefulWidget
_FilterSectionedContentState createState() => _FilterSectionedContentState<T>(); _FilterSectionedContentState createState() => _FilterSectionedContentState<T>();
} }
class _FilterSectionedContentState<T extends CollectionFilter> extends State<_FilterSectionedContent<T>> with GridItemTrackerMixin<FilterGridItem<T>, _FilterSectionedContent<T>> { class _FilterSectionedContentState<T extends CollectionFilter> extends State<_FilterSectionedContent<T>> with WidgetsBindingObserver, GridItemTrackerMixin<FilterGridItem<T>, _FilterSectionedContent<T>> {
Widget get appBar => widget.appBar; Widget get appBar => widget.appBar;
@override @override
@ -332,7 +332,7 @@ 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);
highlightInfo.trackItem(gridItem, animate: true, highlight: filter); highlightInfo.trackItem(gridItem, highlightItem: filter);
} }
} }
} }

View file

@ -223,7 +223,7 @@ class EntryActionDelegate with FeedbackMixin, PermissionAwareMixin, SizeAwareMix
final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet(); final newUris = movedOps.map((v) => v.newFields['uri'] as String?).toSet();
final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri)); final targetEntry = targetCollection.sortedEntries.firstWhereOrNull((entry) => newUris.contains(entry.uri));
if (targetEntry != null) { if (targetEntry != null) {
highlightInfo.trackItem(targetEntry, animate: true, highlight: targetEntry); highlightInfo.trackItem(targetEntry, highlightItem: targetEntry);
} }
}, },
) )