map: scroll to tapped marker entry

This commit is contained in:
Thibault Deckers 2021-08-19 19:30:52 +09:00
parent 1ecd6b9212
commit 2d1c350772
6 changed files with 90 additions and 71 deletions

View file

@ -69,6 +69,7 @@ class Durations {
static const softKeyboardDisplayDelay = Duration(milliseconds: 300); static const softKeyboardDisplayDelay = Duration(milliseconds: 300);
static const searchDebounceDelay = Duration(milliseconds: 250); static const searchDebounceDelay = Duration(milliseconds: 250);
static const contentChangeDebounceDelay = Duration(milliseconds: 1000); static const contentChangeDebounceDelay = Duration(milliseconds: 1000);
static const mapScrollDebounceDelay = Duration(milliseconds: 150);
// app life // app life
static const lastVersionCheckInterval = Duration(days: 7); static const lastVersionCheckInterval = Duration(days: 7);

View file

@ -92,7 +92,7 @@ class _GeoMapState extends State<GeoMap> with TickerProviderStateMixin {
// the higher `nodeSize` is, the higher the chance to get all the points (i.e. as many as the cluster `pointsSize`) // 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)); _slowMarkerCluster ??= _buildFluster(nodeSize: smallestPowerOf2(widget.entries.length));
points = _slowMarkerCluster!.points(clusterId); 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); geoEntries.addAll(points);
} else { } else {

View file

@ -201,6 +201,8 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
} }
Future<void> _updateVisibleRegion({required double zoom, required double rotation}) async { Future<void> _updateVisibleRegion({required double zoom, required double rotation}) async {
if (!mounted) return;
final bounds = await _googleMapController?.getVisibleRegion(); final bounds = await _googleMapController?.getVisibleRegion();
if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) { if (bounds != null && (bounds.northeast != uninitializedLatLng || bounds.southwest != uninitializedLatLng)) {
boundsNotifier.value = ZoomedBounds( boundsNotifier.value = ZoomedBounds(
@ -215,7 +217,6 @@ class _EntryGoogleMapState extends State<EntryGoogleMap> with WidgetsBindingObse
// the visible region is sometimes uninitialized when queried right after creation, // the visible region is sometimes uninitialized when queried right after creation,
// so we query it again next frame // so we query it again next frame
WidgetsBinding.instance!.addPostFrameCallback((_) { WidgetsBinding.instance!.addPostFrameCallback((_) {
if (!mounted) return;
_updateVisibleRegion(zoom: zoom, rotation: rotation); _updateVisibleRegion(zoom: zoom, rotation: rotation);
}); });
} }

View file

@ -11,16 +11,14 @@ class ThumbnailScroller extends StatefulWidget {
final double availableWidth; final double availableWidth;
final int entryCount; final int entryCount;
final AvesEntry? Function(int index) entryBuilder; final AvesEntry? Function(int index) entryBuilder;
final int? initialIndex; final ValueNotifier<int?> indexNotifier;
final void Function(int index) onIndexChange;
const ThumbnailScroller({ const ThumbnailScroller({
Key? key, Key? key,
required this.availableWidth, required this.availableWidth,
required this.entryCount, required this.entryCount,
required this.entryBuilder, required this.entryBuilder,
required this.initialIndex, required this.indexNotifier,
required this.onIndexChange,
}) : super(key: key); }) : super(key: key);
@override @override
@ -30,46 +28,48 @@ class ThumbnailScroller extends StatefulWidget {
class _ThumbnailScrollerState extends State<ThumbnailScroller> { class _ThumbnailScrollerState extends State<ThumbnailScroller> {
final _cancellableNotifier = ValueNotifier(true); final _cancellableNotifier = ValueNotifier(true);
late ScrollController _scrollController; late ScrollController _scrollController;
bool _syncScroll = true; bool _isAnimating = false, _isScrolling = false;
final ValueNotifier<int> _currentIndexNotifier = ValueNotifier(-1);
static const double extent = 48; static const double extent = 48;
static const double separatorWidth = 2; static const double separatorWidth = 2;
int get entryCount => widget.entryCount; int get entryCount => widget.entryCount;
ValueNotifier<int?> get indexNotifier => widget.indexNotifier;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_registerWidget(); _registerWidget(widget);
} }
@override @override
void didUpdateWidget(covariant ThumbnailScroller oldWidget) { void didUpdateWidget(covariant ThumbnailScroller oldWidget) {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.initialIndex != widget.initialIndex) { if (oldWidget.indexNotifier != widget.indexNotifier) {
_unregisterWidget(); _unregisterWidget(oldWidget);
_registerWidget(); _registerWidget(widget);
} }
} }
@override @override
void dispose() { void dispose() {
_unregisterWidget(); _unregisterWidget(widget);
super.dispose(); super.dispose();
} }
void _registerWidget() { void _registerWidget(ThumbnailScroller widget) {
_currentIndexNotifier.value = widget.initialIndex ?? 0; final scrollOffset = indexToScrollOffset(indexNotifier.value ?? 0);
final scrollOffset = indexToScrollOffset(_currentIndexNotifier.value);
_scrollController = ScrollController(initialScrollOffset: scrollOffset); _scrollController = ScrollController(initialScrollOffset: scrollOffset);
_scrollController.addListener(_onScrollChange); _scrollController.addListener(_onScrollChange);
widget.indexNotifier.addListener(_onIndexChange);
} }
void _unregisterWidget() { void _unregisterWidget(ThumbnailScroller widget) {
_scrollController.removeListener(_onScrollChange); _scrollController.removeListener(_onScrollChange);
_scrollController.dispose(); _scrollController.dispose();
widget.indexNotifier.removeListener(_onIndexChange);
} }
@override @override
@ -98,7 +98,7 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
return Stack( return Stack(
children: [ children: [
GestureDetector( GestureDetector(
onTap: () => _goTo(page), onTap: () => indexNotifier.value = page,
child: DecoratedThumbnail( child: DecoratedThumbnail(
entry: pageEntry, entry: pageEntry,
tileExtent: extent, tileExtent: extent,
@ -112,8 +112,8 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
), ),
), ),
IgnorePointer( IgnorePointer(
child: ValueListenableBuilder<int>( child: ValueListenableBuilder<int?>(
valueListenable: _currentIndexNotifier, valueListenable: indexNotifier,
builder: (context, currentIndex, child) { builder: (context, currentIndex, child) {
return AnimatedContainer( return AnimatedContainer(
color: currentIndex == page ? Colors.transparent : Colors.black45, color: currentIndex == page ? Colors.transparent : Colors.black45,
@ -135,27 +135,40 @@ class _ThumbnailScrollerState extends State<ThumbnailScroller> {
} }
Future<void> _goTo(int index) async { Future<void> _goTo(int index) async {
_syncScroll = false; final targetOffset = indexToScrollOffset(index);
setCurrentIndex(index); final offsetDelta = (targetOffset - _scrollController.offset).abs();
if (offsetDelta > widget.availableWidth * 2) {
_scrollController.jumpTo(targetOffset);
} else {
_isAnimating = true;
await _scrollController.animateTo( await _scrollController.animateTo(
indexToScrollOffset(index), targetOffset,
duration: Durations.thumbnailScrollerScrollAnimation, duration: Durations.thumbnailScrollerScrollAnimation,
curve: Curves.easeOutCubic, curve: Curves.easeOutCubic,
); );
_syncScroll = true; _isAnimating = false;
}
} }
void _onScrollChange() { void _onScrollChange() {
if (_syncScroll) { if (!_isAnimating) {
setCurrentIndex(scrollOffsetToIndex(_scrollController.offset)); final index = scrollOffsetToIndex(_scrollController.offset);
if (indexNotifier.value != index) {
_isScrolling = true;
indexNotifier.value = index;
}
} }
} }
void setCurrentIndex(int index) { void _onIndexChange() {
if (_currentIndexNotifier.value == index) return; if (!_isScrolling && !_isAnimating) {
final index = indexNotifier.value;
_currentIndexNotifier.value = index; if (index != null) {
widget.onIndexChange(index); _goTo(index);
}
}
_isScrolling = false;
} }
double indexToScrollOffset(int index) => index * (extent + separatorWidth); double indexToScrollOffset(int index) => index * (extent + separatorWidth);

View file

@ -2,12 +2,12 @@ import 'package:aves/model/entry.dart';
import 'package:aves/model/settings/map_style.dart'; import 'package:aves/model/settings/map_style.dart';
import 'package:aves/model/settings/settings.dart'; import 'package:aves/model/settings/settings.dart';
import 'package:aves/theme/durations.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/extensions/build_context.dart';
import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/controller.dart';
import 'package:aves/widgets/common/map/geo_map.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/providers/media_query_data_provider.dart';
import 'package:aves/widgets/common/thumbnail/scroller.dart'; import 'package:aves/widgets/common/thumbnail/scroller.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:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
@ -30,7 +30,8 @@ class MapPage extends StatefulWidget {
class _MapPageState extends State<MapPage> { class _MapPageState extends State<MapPage> {
final AvesMapController _mapController = AvesMapController(); final AvesMapController _mapController = AvesMapController();
late final ValueNotifier<bool> _isAnimatingNotifier; late final ValueNotifier<bool> _isAnimatingNotifier;
int _selectedIndex = 0; final ValueNotifier<int> _selectedIndexNotifier = ValueNotifier(0);
final Debouncer _debouncer = Debouncer(delay: Durations.mapScrollDebounceDelay);
List<AvesEntry> get entries => widget.entries; List<AvesEntry> get entries => widget.entries;
@ -46,11 +47,13 @@ class _MapPageState extends State<MapPage> {
} else { } else {
_isAnimatingNotifier = ValueNotifier(false); _isAnimatingNotifier = ValueNotifier(false);
} }
_selectedIndexNotifier.addListener(_onThumbnailIndexChange);
} }
@override @override
void dispose() { void dispose() {
_mapController.dispose(); _mapController.dispose();
_selectedIndexNotifier.removeListener(_onThumbnailIndexChange);
super.dispose(); super.dispose();
} }
@ -71,8 +74,10 @@ class _MapPageState extends State<MapPage> {
entries: entries, entries: entries,
interactive: true, interactive: true,
isAnimatingNotifier: _isAnimatingNotifier, isAnimatingNotifier: _isAnimatingNotifier,
onMarkerTap: (entries) { onMarkerTap: (markerEntries) {
debugPrint('TLAD count=${entries.length} entry=${entries.firstOrNull?.bestTitle}'); if (markerEntries.isEmpty) return;
final entry = markerEntries.first;
_selectedIndexNotifier.value = entries.indexOf(entry);
}, },
), ),
), ),
@ -84,13 +89,7 @@ class _MapPageState extends State<MapPage> {
availableWidth: mqWidth, availableWidth: mqWidth,
entryCount: entries.length, entryCount: entries.length,
entryBuilder: (index) => entries[index], entryBuilder: (index) => entries[index],
// TODO TLAD provide notifier instead indexNotifier: _selectedIndexNotifier,
initialIndex: _selectedIndex,
onIndexChange: (index) {
_selectedIndex = index;
// TODO TLAD debounce move
_mapController.moveTo(widget.entries[_selectedIndex].latLng!);
},
); );
}, },
), ),
@ -100,4 +99,9 @@ class _MapPageState extends State<MapPage> {
), ),
); );
} }
void _onThumbnailIndexChange() {
final position = widget.entries[_selectedIndexNotifier.value].latLng!;
_debouncer(() => _mapController.moveTo(position));
}
} }

View file

@ -20,14 +20,14 @@ class MultiPageOverlay extends StatefulWidget {
} }
class _MultiPageOverlayState extends State<MultiPageOverlay> { class _MultiPageOverlayState extends State<MultiPageOverlay> {
int? _initControllerPage; int? _previousPage;
MultiPageController get controller => widget.controller; MultiPageController get controller => widget.controller;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_registerWidget(); _registerWidget(widget);
} }
@override @override
@ -35,24 +35,23 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
super.didUpdateWidget(oldWidget); super.didUpdateWidget(oldWidget);
if (oldWidget.controller != controller) { if (oldWidget.controller != controller) {
_registerWidget(); _unregisterWidget(oldWidget);
_registerWidget(widget);
} }
} }
void _registerWidget() { @override
_initControllerPage = controller.page; void dispose() {
if (_initControllerPage == null) { _unregisterWidget(widget);
_correctDefaultPageScroll(); super.dispose();
}
} }
// correct scroll offset to match default page void _registerWidget(MultiPageOverlay widget) {
// if default page was unknown when the scroll controller was created widget.controller.pageNotifier.addListener(_onPageChange);
void _correctDefaultPageScroll() async {
await controller.infoStream.first;
if (_initControllerPage == null) {
setState(() => _initControllerPage = controller.page);
} }
void _unregisterWidget(MultiPageOverlay widget) {
widget.controller.pageNotifier.removeListener(_onPageChange);
} }
@override @override
@ -66,19 +65,20 @@ class _MultiPageOverlayState extends State<MultiPageOverlay> {
availableWidth: widget.availableWidth, availableWidth: widget.availableWidth,
entryCount: multiPageInfo?.pageCount ?? 0, entryCount: multiPageInfo?.pageCount ?? 0,
entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page), entryBuilder: (page) => multiPageInfo?.getPageEntryByIndex(page),
initialIndex: _initControllerPage, indexNotifier: controller.pageNotifier,
onIndexChange: _setPage,
); );
}, },
); );
} }
void _setPage(int newPage) { void _onPageChange() {
final oldPage = controller.page; if (_previousPage != null) {
if (oldPage == newPage) return; final info = controller.info;
if (info != null) {
final oldPageEntry = controller.info!.getPageEntryByIndex(oldPage); final oldPageEntry = info.getPageEntryByIndex(_previousPage);
controller.page = newPage;
context.read<ViewStateConductor>().reset(oldPageEntry); context.read<ViewStateConductor>().reset(oldPageEntry);
} }
} }
_previousPage = controller.page;
}
}