map: scroll to tapped marker entry
This commit is contained in:
parent
1ecd6b9212
commit
2d1c350772
6 changed files with 90 additions and 71 deletions
|
@ -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);
|
||||||
|
|
|
@ -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 {
|
||||||
|
|
|
@ -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);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
Loading…
Reference in a new issue