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, WidgetsBindingObserver { ValueNotifier get appBarHeightNotifier; 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, 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(); 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 _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; // 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 = appBarHeight + tileRect.top + (tileRect.height - scrollableSize.height) * ((alignment.y + 1) / 2); 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 (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; }); } }