Aves_mio0.88/lib/widgets/common/grid/item_tracker.dart
2026-10-03 12:51:15 +02:00

311 lines
8.3 KiB
Dart

import 'dart:async';
import 'dart:math';
import 'dart:ui' show FlutterView;
import 'package:aves/model/highlight.dart';
import 'package:aves/theme/durations.dart';
import 'package:aves/widgets/common/grid/sections/list_layout.dart';
import 'package:aves_model/aves_model.dart';
import 'package:collection/collection.dart';
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
class GridItemTracker<T> extends StatefulWidget {
final GlobalKey scrollableKey;
final ValueNotifier<double> appBarHeightNotifier;
final TileLayout tileLayout;
final ScrollController scrollController;
final Widget child;
const GridItemTracker({
super.key,
required this.scrollableKey,
required this.appBarHeightNotifier,
required this.tileLayout,
required this.scrollController,
required this.child,
});
@override
State<GridItemTracker<T>> createState() => _GridItemTrackerState<T>();
}
class _GridItemTrackerState<T> extends State<GridItemTracker<T>> with WidgetsBindingObserver {
ValueNotifier<double> get appBarHeightNotifier => widget.appBarHeightNotifier;
ScrollController get scrollController => widget.scrollController;
final Set<StreamSubscription> _subscriptions = {};
FlutterView? _view;
ModalRoute<dynamic>? _route;
HighlightInfo? _highlightInfo;
SectionedListLayout<T>? _sectionedListLayout;
bool _disposed = false;
bool _hasSavedLayoutMetrics = false;
late SectionedListLayout<T> _lastSectionedListLayout;
late Size _lastScrollableSize;
Orientation _lastOrientation = Orientation.portrait;
@override
Widget build(BuildContext context) {
return widget.child;
}
Size? get scrollableSize {
final scrollableContext = widget.scrollableKey.currentContext;
if (scrollableContext == null) return null;
try {
final renderObject = scrollableContext.findRenderObject();
if (renderObject is! RenderBox) return null;
if (!renderObject.hasSize) return null;
return renderObject.size;
} catch (e, st) {
debugPrint('GridItemTracker scrollableSize ignored: $e');
debugPrint('$st');
return null;
}
}
bool get _canUseTracker {
if (_disposed || !mounted) return false;
final route = _route;
if (route != null && !route.isCurrent) return false;
return true;
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
_view = View.maybeOf(context);
_route = ModalRoute.of(context);
try {
final highlightInfo = Provider.of<HighlightInfo>(
context,
listen: false,
);
if (!identical(_highlightInfo, highlightInfo)) {
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
_highlightInfo = highlightInfo;
_subscriptions.add(
highlightInfo.eventBus.on<TrackEvent<T>>().listen(_trackItem),
);
}
} catch (e, st) {
debugPrint('GridItemTracker HighlightInfo lookup ignored: $e');
debugPrint('$st');
}
try {
_sectionedListLayout = Provider.of<SectionedListLayout<T>>(
context,
listen: false,
);
} catch (e, st) {
debugPrint('GridItemTracker SectionedListLayout lookup ignored: $e');
debugPrint('$st');
}
_saveLayoutMetrics();
}
@override
void initState() {
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void didUpdateWidget(covariant GridItemTracker<T> oldWidget) {
super.didUpdateWidget(oldWidget);
if (!_canUseTracker) return;
if (oldWidget.tileLayout != widget.tileLayout) {
_onLayoutChanged();
}
_saveLayoutMetrics();
}
@override
void didChangeMetrics() {
if (!_canUseTracker) return;
final view = _view;
if (view == null) return;
final size = view.physicalSize;
final orientation = size.width > size.height ? Orientation.landscape : Orientation.portrait;
if (_lastOrientation == orientation) return;
_lastOrientation = orientation;
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_canUseTracker) return;
if (widget.scrollableKey.currentContext == null) return;
try {
_onLayoutChanged();
_saveLayoutMetrics();
} catch (e, st) {
debugPrint('GridItemTracker didChangeMetrics ignored: $e');
debugPrint('$st');
}
});
}
@override
void dispose() {
_disposed = true;
WidgetsBinding.instance.removeObserver(this);
_view = null;
_route = null;
_highlightInfo = null;
_sectionedListLayout = null;
_subscriptions
..forEach((sub) => sub.cancel())
..clear();
super.dispose();
}
Future<void> _trackItem(TrackEvent event) async {
if (!_canUseTracker) return;
final sectionedListLayout = _sectionedListLayout;
if (sectionedListLayout == null) return;
try {
final tileRect = sectionedListLayout.getTileRect(event.item);
if (tileRect == null) return;
final currentScrollableSize = scrollableSize;
if (currentScrollableSize == null) return;
if (scrollController.positions.length != 1) return;
final viewportRect = Offset(0, scrollController.offset) & currentScrollableSize;
final itemVisibility = max(0, tileRect.intersect(viewportRect).height) / tileRect.height;
if (!event.predicate(itemVisibility)) return;
double scrollOffset = tileRect.top + (tileRect.height - currentScrollableSize.height) * ((event.alignment.y + 1) / 2);
scrollOffset += appBarHeightNotifier.value;
scrollOffset = scrollOffset.clamp(0, scrollController.position.maxScrollExtent);
if (scrollOffset > 0) {
if (event.animate) {
await scrollController.animateTo(
scrollOffset,
duration: Duration(
milliseconds: (scrollOffset / 2).round().clamp(
ADurations.highlightScrollAnimationMinMillis,
ADurations.highlightScrollAnimationMaxMillis,
),
),
curve: Curves.easeInOutCubic,
);
} else {
scrollController.jumpTo(scrollOffset);
await Future.delayed(ADurations.highlightJumpDelay);
}
}
if (!_canUseTracker) return;
final highlightItem = event.highlightItem;
if (highlightItem != null) {
_highlightInfo?.set(highlightItem);
}
} catch (e, st) {
debugPrint('GridItemTracker _trackItem ignored: $e');
debugPrint('$st');
}
}
Future<void> _saveLayoutMetrics() async {
await Future.delayed(const Duration(milliseconds: 500));
if (!_canUseTracker) return;
if (widget.scrollableKey.currentContext == null) return;
final currentScrollableSize = scrollableSize;
if (currentScrollableSize == null) return;
final sectionedListLayout = _sectionedListLayout;
if (sectionedListLayout == null) return;
try {
_lastSectionedListLayout = sectionedListLayout;
_lastScrollableSize = currentScrollableSize;
_hasSavedLayoutMetrics = true;
} catch (e, st) {
debugPrint('GridItemTracker _saveLayoutMetrics ignored: $e');
debugPrint('$st');
}
}
void _onLayoutChanged() {
if (!_canUseTracker) return;
if (!_hasSavedLayoutMetrics) return;
if (scrollController.positions.length != 1) return;
if (scrollController.offset == 0) return;
try {
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;
}
}
if (pivotItem != null) {
WidgetsBinding.instance.addPostFrameCallback((_) {
if (!_canUseTracker) return;
try {
_highlightInfo?.trackItem(
pivotItem,
animate: false,
);
} catch (e, st) {
debugPrint('GridItemTracker _onLayoutChanged ignored: $e');
debugPrint('$st');
}
});
}
} catch (e, st) {
debugPrint('GridItemTracker _onLayoutChanged ignored: $e');
debugPrint('$st');
}
}
}