diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index b76217d9a..25599a4ff 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -69,6 +69,7 @@ class Durations { static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const searchDebounceDelay = Duration(milliseconds: 250); static const contentChangeDebounceDelay = Duration(milliseconds: 1000); + static const mapScrollDebounceDelay = Duration(milliseconds: 150); // app life static const lastVersionCheckInterval = Duration(days: 7); diff --git a/lib/widgets/common/map/geo_map.dart b/lib/widgets/common/map/geo_map.dart index 635949167..b608546cb 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -92,7 +92,7 @@ class _GeoMapState extends State with TickerProviderStateMixin { // the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length)); points = _slowMarkerCluster!.points(clusterId); - assert(points.length == geoEntry.pointsSize); + assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); } geoEntries.addAll(points); } else { diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 876c1cfa1..56d00a5a9 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -201,6 +201,8 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse } Future _updateVisibleRegion({required double zoom, required double rotation}) async { + if (!mounted) return; + final bounds = await _googleMapController?.getVisibleRegion(); if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) { boundsNotifier.value = ZoomedBounds( @@ -215,7 +217,6 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse // the visible region is sometimes uninitialized when queried right after creation, // so we query it again next frame WidgetsBinding.instance!.addPostFrameCallback((_) { - if (!mounted) return; _updateVisibleRegion(zoom: zoom, rotation: rotation); }); } diff --git a/lib/widgets/common/thumbnail/scroller.dart b/lib/widgets/common/thumbnail/scroller.dart index 035e66ec2..dc13d0698 100644 --- a/lib/widgets/common/thumbnail/scroller.dart +++ b/lib/widgets/common/thumbnail/scroller.dart @@ -11,16 +11,14 @@ class ThumbnailScroller extends StatefulWidget { final double availableWidth; final int entryCount; final AvesEntry? Function(int index) entryBuilder; - final int? initialIndex; - final void Function(int index) onIndexChange; + final ValueNotifier indexNotifier; const ThumbnailScroller({ Key? key, required this.availableWidth, required this.entryCount, required this.entryBuilder, - required this.initialIndex, - required this.onIndexChange, + required this.indexNotifier, }) : super(key: key); @override @@ -30,46 +28,48 @@ class ThumbnailScroller extends StatefulWidget { class _ThumbnailScrollerState extends State { final _cancellableNotifier = ValueNotifier(true); late ScrollController _scrollController; - bool _syncScroll = true; - final ValueNotifier _currentIndexNotifier = ValueNotifier(-1); + bool _isAnimating = false, _isScrolling = false; static const double extent = 48; static const double separatorWidth = 2; int get entryCount => widget.entryCount; + ValueNotifier get indexNotifier => widget.indexNotifier; + @override void initState() { super.initState(); - _registerWidget(); + _registerWidget(widget); } @override void didUpdateWidget(covariant ThumbnailScroller oldWidget) { super.didUpdateWidget(oldWidget); - if (oldWidget.initialIndex != widget.initialIndex) { - _unregisterWidget(); - _registerWidget(); + if (oldWidget.indexNotifier != widget.indexNotifier) { + _unregisterWidget(oldWidget); + _registerWidget(widget); } } @override void dispose() { - _unregisterWidget(); + _unregisterWidget(widget); super.dispose(); } - void _registerWidget() { - _currentIndexNotifier.value = widget.initialIndex ?? 0; - final scrollOffset = indexToScrollOffset(_currentIndexNotifier.value); + void _registerWidget(ThumbnailScroller widget) { + final scrollOffset = indexToScrollOffset(indexNotifier.value ?? 0); _scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController.addListener(_onScrollChange); + widget.indexNotifier.addListener(_onIndexChange); } - void _unregisterWidget() { + void _unregisterWidget(ThumbnailScroller widget) { _scrollController.removeListener(_onScrollChange); _scrollController.dispose(); + widget.indexNotifier.removeListener(_onIndexChange); } @override @@ -98,7 +98,7 @@ class _ThumbnailScrollerState extends State { return Stack( children: [ GestureDetector( - onTap: () => _goTo(page), + onTap: () => indexNotifier.value = page, child: DecoratedThumbnail( entry: pageEntry, tileExtent: extent, @@ -112,8 +112,8 @@ class _ThumbnailScrollerState extends State { ), ), IgnorePointer( - child: ValueListenableBuilder( - valueListenable: _currentIndexNotifier, + child: ValueListenableBuilder( + valueListenable: indexNotifier, builder: (context, currentIndex, child) { return AnimatedContainer( color: currentIndex == page ? Colors.transparent : Colors.black45, @@ -135,27 +135,40 @@ class _ThumbnailScrollerState extends State { } Future _goTo(int index) async { - _syncScroll = false; - setCurrentIndex(index); - await _scrollController.animateTo( - indexToScrollOffset(index), - duration: Durations.thumbnailScrollerScrollAnimation, - curve: Curves.easeOutCubic, - ); - _syncScroll = true; - } + final targetOffset = indexToScrollOffset(index); + final offsetDelta = (targetOffset - _scrollController.offset).abs(); - void _onScrollChange() { - if (_syncScroll) { - setCurrentIndex(scrollOffsetToIndex(_scrollController.offset)); + if (offsetDelta > widget.availableWidth * 2) { + _scrollController.jumpTo(targetOffset); + } else { + _isAnimating = true; + await _scrollController.animateTo( + targetOffset, + duration: Durations.thumbnailScrollerScrollAnimation, + curve: Curves.easeOutCubic, + ); + _isAnimating = false; } } - void setCurrentIndex(int index) { - if (_currentIndexNotifier.value == index) return; + void _onScrollChange() { + if (!_isAnimating) { + final index = scrollOffsetToIndex(_scrollController.offset); + if (indexNotifier.value != index) { + _isScrolling = true; + indexNotifier.value = index; + } + } + } - _currentIndexNotifier.value = index; - widget.onIndexChange(index); + void _onIndexChange() { + if (!_isScrolling && !_isAnimating) { + final index = indexNotifier.value; + if (index != null) { + _goTo(index); + } + } + _isScrolling = false; } double indexToScrollOffset(int index) => index * (extent + separatorWidth); diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 823d63a74..c94a9a868 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -2,12 +2,12 @@ import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/settings.dart'; import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/extensions/build_context.dart'; import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/providers/media_query_data_provider.dart'; import 'package:aves/widgets/common/thumbnail/scroller.dart'; -import 'package:collection/collection.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -30,7 +30,8 @@ class MapPage extends StatefulWidget { class _MapPageState extends State { final AvesMapController _mapController = AvesMapController(); late final ValueNotifier _isAnimatingNotifier; - int _selectedIndex = 0; + final ValueNotifier _selectedIndexNotifier = ValueNotifier(0); + final Debouncer _debouncer = Debouncer(delay: Durations.mapScrollDebounceDelay); List get entries => widget.entries; @@ -46,11 +47,13 @@ class _MapPageState extends State { } else { _isAnimatingNotifier = ValueNotifier(false); } + _selectedIndexNotifier.addListener(_onThumbnailIndexChange); } @override void dispose() { _mapController.dispose(); + _selectedIndexNotifier.removeListener(_onThumbnailIndexChange); super.dispose(); } @@ -71,8 +74,10 @@ class _MapPageState extends State { entries: entries, interactive: true, isAnimatingNotifier: _isAnimatingNotifier, - onMarkerTap: (entries) { - debugPrint('TLAD count=${entries.length} entry=${entries.firstOrNull?.bestTitle}'); + onMarkerTap: (markerEntries) { + if (markerEntries.isEmpty) return; + final entry = markerEntries.first; + _selectedIndexNotifier.value = entries.indexOf(entry); }, ), ), @@ -84,13 +89,7 @@ class _MapPageState extends State { availableWidth: mqWidth, entryCount: entries.length, entryBuilder: (index) => entries[index], - // TODO TLAD provide notifier instead - initialIndex: _selectedIndex, - onIndexChange: (index) { - _selectedIndex = index; - // TODO TLAD debounce move - _mapController.moveTo(widget.entries[_selectedIndex].latLng!); - }, + indexNotifier: _selectedIndexNotifier, ); }, ), @@ -100,4 +99,9 @@ class _MapPageState extends State { ), ); } + + void _onThumbnailIndexChange() { + final position = widget.entries[_selectedIndexNotifier.value].latLng!; + _debouncer(() => _mapController.moveTo(position)); + } } diff --git a/lib/widgets/viewer/overlay/bottom/multipage.dart b/lib/widgets/viewer/overlay/bottom/multipage.dart index 5943af6ba..5630a7fe1 100644 --- a/lib/widgets/viewer/overlay/bottom/multipage.dart +++ b/lib/widgets/viewer/overlay/bottom/multipage.dart @@ -20,14 +20,14 @@ class MultiPageOverlay extends StatefulWidget { } class _MultiPageOverlayState extends State { - int? _initControllerPage; + int? _previousPage; MultiPageController get controller => widget.controller; @override void initState() { super.initState(); - _registerWidget(); + _registerWidget(widget); } @override @@ -35,24 +35,23 @@ class _MultiPageOverlayState extends State { super.didUpdateWidget(oldWidget); if (oldWidget.controller != controller) { - _registerWidget(); + _unregisterWidget(oldWidget); + _registerWidget(widget); } } - void _registerWidget() { - _initControllerPage = controller.page; - if (_initControllerPage == null) { - _correctDefaultPageScroll(); - } + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); } - // correct scroll offset to match default page - // if default page was unknown when the scroll controller was created - void _correctDefaultPageScroll() async { - await controller.infoStream.first; - if (_initControllerPage == null) { - setState(() => _initControllerPage = controller.page); - } + void _registerWidget(MultiPageOverlay widget) { + widget.controller.pageNotifier.addListener(_onPageChange); + } + + void _unregisterWidget(MultiPageOverlay widget) { + widget.controller.pageNotifier.removeListener(_onPageChange); } @override @@ -66,19 +65,20 @@ class _MultiPageOverlayState extends State { availableWidth: widget.availableWidth, entryCount: multiPageInfo?.pageCount ?? 0, entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page), - initialIndex: _initControllerPage, - onIndexChange: _setPage, + indexNotifier: controller.pageNotifier, ); }, ); } - void _setPage(int newPage) { - final oldPage = controller.page; - if (oldPage == newPage) return; - - final oldPageEntry = controller.info!.getPageEntryByIndex(oldPage); - controller.page = newPage; - context.read().reset(oldPageEntry); + void _onPageChange() { + if (_previousPage != null) { + final info = controller.info; + if (info != null) { + final oldPageEntry = info.getPageEntryByIndex(_previousPage); + context.read().reset(oldPageEntry); + } + } + _previousPage = controller.page; } }