collection/filter grids: keep items in view when switching device orientation
This commit is contained in:
parent
503981deac
commit
f1b1688108
7 changed files with 96 additions and 22 deletions
|
@ -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,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
|
|
|
@ -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;
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
Loading…
Reference in a new issue