From 42425f6fcf5e2ffc337d34c5c3c0f0fbc899542d Mon Sep 17 00:00:00 2001 From: Thibault Deckers Date: Fri, 20 Aug 2021 16:59:38 +0900 Subject: [PATCH] map: update clusters when idle, fixed cluster entry selection --- lib/theme/durations.dart | 1 + lib/widgets/common/map/geo_map.dart | 55 ++++++++---- lib/widgets/common/map/google/map.dart | 113 +++++++++++------------- lib/widgets/common/map/leaflet/map.dart | 94 +++++++++++--------- lib/widgets/map/map_page.dart | 8 +- 5 files changed, 147 insertions(+), 124 deletions(-) diff --git a/lib/theme/durations.dart b/lib/theme/durations.dart index 25599a4ff..29ae6881c 100644 --- a/lib/theme/durations.dart +++ b/lib/theme/durations.dart @@ -70,6 +70,7 @@ class Durations { static const searchDebounceDelay = Duration(milliseconds: 250); static const contentChangeDebounceDelay = Duration(milliseconds: 1000); static const mapScrollDebounceDelay = Duration(milliseconds: 150); + static const mapIdleDebounceDelay = Duration(milliseconds: 100); // 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 b608546cb..929c5f2d2 100644 --- a/lib/widgets/common/map/geo_map.dart +++ b/lib/widgets/common/map/geo_map.dart @@ -17,6 +17,7 @@ import 'package:aves/widgets/common/map/google/map.dart'; import 'package:aves/widgets/common/map/leaflet/map.dart'; import 'package:aves/widgets/common/map/marker.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; +import 'package:collection/collection.dart'; import 'package:equatable/equatable.dart'; import 'package:fluster/fluster.dart'; import 'package:flutter/foundation.dart'; @@ -83,9 +84,12 @@ class _GeoMapState extends State with TickerProviderStateMixin { final onTap = widget.onMarkerTap; if (onTap == null) return; - final geoEntries = []; final clusterId = geoEntry.clusterId; - if (clusterId != null) { + Set getClusterEntries() { + if (clusterId == null) { + return {geoEntry.entry!}; + } + var points = _defaultMarkerCluster.points(clusterId); if (points.length != geoEntry.pointsSize) { // `Fluster.points()` method does not always return all the points contained in a cluster @@ -94,11 +98,20 @@ class _GeoMapState extends State with TickerProviderStateMixin { points = _slowMarkerCluster!.points(clusterId); assert(points.length == geoEntry.pointsSize, 'got ${points.length}/${geoEntry.pointsSize} for geoEntry=$geoEntry'); } - geoEntries.addAll(points); - } else { - geoEntries.add(geoEntry); + return points.map((geoEntry) => geoEntry.entry!).toSet(); + } + + AvesEntry? markerEntry; + if (clusterId != null) { + final uri = geoEntry.childMarkerId; + markerEntry = entries.firstWhereOrNull((v) => v.uri == uri); + } else { + markerEntry = geoEntry.entry; + } + + if (markerEntry != null) { + onTap(markerEntry, getClusterEntries); } - onTap(geoEntries.map((geoEntry) => geoEntry.entry!).toList()); } return FutureBuilder( @@ -110,7 +123,7 @@ class _GeoMapState extends State with TickerProviderStateMixin { builder: (context, mapStyle, child) { final isGoogleMaps = mapStyle.isGoogleMaps; final progressive = !isGoogleMaps; - Widget _buildMarker(MarkerKey key) => ImageMarker( + Widget _buildMarkerWidget(MarkerKey key) => ImageMarker( key: key, entry: key.entry, count: key.count, @@ -127,9 +140,8 @@ class _GeoMapState extends State with TickerProviderStateMixin { minZoom: 0, maxZoom: 20, style: mapStyle, - markerBuilder: _buildMarker, - markerCluster: _defaultMarkerCluster, - markerEntries: entries, + markerClusterBuilder: _buildMarkerClusters, + markerWidgetBuilder: _buildMarkerWidget, onUserZoomChange: widget.onUserZoomChange, onMarkerTap: _onMarkerTap, ) @@ -140,9 +152,8 @@ class _GeoMapState extends State with TickerProviderStateMixin { minZoom: 2, maxZoom: 16, style: mapStyle, - markerBuilder: _buildMarker, - markerCluster: _defaultMarkerCluster, - markerEntries: entries, + markerClusterBuilder: _buildMarkerClusters, + markerWidgetBuilder: _buildMarkerWidget, markerSize: Size( GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2, GeoMap.markerImageExtent + ImageMarker.outerBorderWidth * 2 + GeoMap.pointerSize.height, @@ -232,6 +243,19 @@ class _GeoMapState extends State with TickerProviderStateMixin { createCluster: (base, lng, lat) => GeoEntry.createCluster(base, lng, lat), ); } + + Map _buildMarkerClusters() { + final bounds = _boundsNotifier.value; + final geoEntries = _defaultMarkerCluster.clusters(bounds.boundingBox, bounds.zoom.round()); + return Map.fromEntries(geoEntries.map((v) { + if (v.isCluster!) { + final uri = v.childMarkerId; + final entry = entries.firstWhere((v) => v.uri == uri); + return MapEntry(MarkerKey(entry, v.pointsSize), v); + } + return MapEntry(MarkerKey(v.entry!, null), v); + })); + } } @immutable @@ -245,6 +269,7 @@ class MarkerKey extends LocalKey with EquatableMixin { const MarkerKey(this.entry, this.count); } -typedef EntryMarkerBuilder = Widget Function(MarkerKey key); +typedef MarkerClusterBuilder = Map Function(); +typedef MarkerWidgetBuilder = Widget Function(MarkerKey key); typedef UserZoomChangeCallback = void Function(double zoom); -typedef MarkerTapCallback = void Function(List entries); +typedef MarkerTapCallback = void Function(AvesEntry markerEntry, Set Function() getClusterEntries); diff --git a/lib/widgets/common/map/google/map.dart b/lib/widgets/common/map/google/map.dart index 3e3eb32c9..858207f5a 100644 --- a/lib/widgets/common/map/google/map.dart +++ b/lib/widgets/common/map/google/map.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'dart:typed_data'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/entry_images.dart'; import 'package:aves/model/settings/enums.dart'; import 'package:aves/utils/change_notifier.dart'; @@ -12,8 +11,6 @@ import 'package:aves/widgets/common/map/geo_entry.dart'; import 'package:aves/widgets/common/map/geo_map.dart'; import 'package:aves/widgets/common/map/google/marker_generator.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; -import 'package:collection/collection.dart'; -import 'package:fluster/fluster.dart'; import 'package:flutter/material.dart'; import 'package:google_maps_flutter/google_maps_flutter.dart'; import 'package:latlong2/latlong.dart' as ll; @@ -24,9 +21,8 @@ class EntryGoogleMap extends StatefulWidget { final bool interactive; final double? minZoom, maxZoom; final EntryMapStyle style; - final EntryMarkerBuilder markerBuilder; - final Fluster markerCluster; - final List markerEntries; + final MarkerClusterBuilder markerClusterBuilder; + final MarkerWidgetBuilder markerWidgetBuilder; final UserZoomChangeCallback? onUserZoomChange; final void Function(GeoEntry geoEntry)? onMarkerTap; @@ -38,9 +34,8 @@ class EntryGoogleMap extends StatefulWidget { this.minZoom, this.maxZoom, required this.style, - required this.markerBuilder, - required this.markerCluster, - required this.markerEntries, + required this.markerClusterBuilder, + required this.markerWidgetBuilder, this.onUserZoomChange, this.onMarkerTap, }) : super(key: key); @@ -52,6 +47,7 @@ class EntryGoogleMap extends StatefulWidget { class _EntryGoogleMapState extends State with WidgetsBindingObserver { GoogleMapController? _googleMapController; final List _subscriptions = []; + Map _geoEntryByMarkerKey = {}; final Map _markerBitmaps = {}; final AChangeNotifier _markerBitmapChangeNotifier = AChangeNotifier(); @@ -66,28 +62,34 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse @override void initState() { super.initState(); + _registerWidget(widget); + } + + @override + void didUpdateWidget(covariant EntryGoogleMap oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + _googleMapController?.dispose(); + super.dispose(); + } + + void _registerWidget(EntryGoogleMap widget) { final avesMapController = widget.controller; if (avesMapController != null) { _subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(_toGoogleLatLng(event.latLng)))); } } - @override - void didUpdateWidget(covariant EntryGoogleMap oldWidget) { - super.didUpdateWidget(oldWidget); - const eq = DeepCollectionEquality(); - if (!eq.equals(widget.markerEntries, oldWidget.markerEntries)) { - _markerBitmaps.clear(); - } - } - - @override - void dispose() { - _googleMapController?.dispose(); + void _unregisterWidget(EntryGoogleMap widget) { _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); - super.dispose(); } @override @@ -107,51 +109,35 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: boundsNotifier, - builder: (context, visibleRegion, child) { - final allEntries = widget.markerEntries; - final geoEntries = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; - final geoEntryByMarkerKey = Map.fromEntries(geoEntries.map((v) { - if (v.isCluster!) { - final uri = v.childMarkerId; - final entry = allEntries.firstWhere((v) => v.uri == uri); - return MapEntry(MarkerKey(entry, v.pointsSize), v); - } - return MapEntry(MarkerKey(v.entry!, null), v); - })); - - return Stack( - children: [ - MarkerGeneratorWidget( - markers: geoEntryByMarkerKey.keys.map(widget.markerBuilder).toList(), - isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), - onRendered: (key, bitmap) { - _markerBitmaps[key] = bitmap; - _markerBitmapChangeNotifier.notifyListeners(); - }, - ), - MapDecorator( - interactive: interactive, - child: _buildMap(geoEntryByMarkerKey), - ), - MapButtonPanel( - boundsNotifier: boundsNotifier, - zoomBy: _zoomBy, - resetRotation: interactive ? _resetRotation : null, - ), - ], - ); - }, + return Stack( + children: [ + MarkerGeneratorWidget( + markers: _geoEntryByMarkerKey.keys.map(widget.markerWidgetBuilder).toList(), + isReadyToRender: (key) => key.entry.isThumbnailReady(extent: GeoMap.markerImageExtent), + onRendered: (key, bitmap) { + _markerBitmaps[key] = bitmap; + _markerBitmapChangeNotifier.notifyListeners(); + }, + ), + MapDecorator( + interactive: interactive, + child: _buildMap(), + ), + MapButtonPanel( + boundsNotifier: boundsNotifier, + zoomBy: _zoomBy, + resetRotation: interactive ? _resetRotation : null, + ), + ], ); } - Widget _buildMap(Map geoEntryByMarkerKey) { + Widget _buildMap() { return AnimatedBuilder( animation: _markerBitmapChangeNotifier, builder: (context, child) { final markers = {}; - geoEntryByMarkerKey.forEach((markerKey, geoEntry) { + _geoEntryByMarkerKey.forEach((markerKey, geoEntry) { final bytes = _markerBitmaps[markerKey]; if (bytes != null) { final point = LatLng(geoEntry.latitude!, geoEntry.longitude!); @@ -195,11 +181,17 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse myLocationButtonEnabled: false, markers: markers, onCameraMove: (position) => _updateVisibleRegion(zoom: position.zoom, rotation: -position.bearing), + onCameraIdle: _updateClusters, ); }, ); } + void _updateClusters() { + if (!mounted) return; + setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); + } + Future _updateVisibleRegion({required double zoom, required double rotation}) async { if (!mounted) return; @@ -226,7 +218,6 @@ class _EntryGoogleMapState extends State with WidgetsBindingObse final controller = _googleMapController; if (controller == null) return; - final bounds = boundsNotifier.value; await controller.animateCamera(CameraUpdate.newCameraPosition(CameraPosition( target: _toGoogleLatLng(bounds.center), zoom: bounds.zoom, diff --git a/lib/widgets/common/map/leaflet/map.dart b/lib/widgets/common/map/leaflet/map.dart index 8384f86f8..508ffd707 100644 --- a/lib/widgets/common/map/leaflet/map.dart +++ b/lib/widgets/common/map/leaflet/map.dart @@ -1,7 +1,8 @@ import 'dart:async'; -import 'package:aves/model/entry.dart'; import 'package:aves/model/settings/enums.dart'; +import 'package:aves/theme/durations.dart'; +import 'package:aves/utils/debouncer.dart'; import 'package:aves/widgets/common/map/buttons.dart'; import 'package:aves/widgets/common/map/controller.dart'; import 'package:aves/widgets/common/map/decorator.dart'; @@ -11,7 +12,6 @@ import 'package:aves/widgets/common/map/latlng_tween.dart'; import 'package:aves/widgets/common/map/leaflet/scale_layer.dart'; import 'package:aves/widgets/common/map/leaflet/tile_layers.dart'; import 'package:aves/widgets/common/map/zoomed_bounds.dart'; -import 'package:fluster/fluster.dart'; import 'package:flutter/material.dart'; import 'package:flutter_map/flutter_map.dart'; import 'package:latlong2/latlong.dart'; @@ -22,9 +22,8 @@ class EntryLeafletMap extends StatefulWidget { final bool interactive; final double minZoom, maxZoom; final EntryMapStyle style; - final EntryMarkerBuilder markerBuilder; - final Fluster markerCluster; - final List markerEntries; + final MarkerClusterBuilder markerClusterBuilder; + final MarkerWidgetBuilder markerWidgetBuilder; final Size markerSize; final UserZoomChangeCallback? onUserZoomChange; final void Function(GeoEntry geoEntry)? onMarkerTap; @@ -37,9 +36,8 @@ class EntryLeafletMap extends StatefulWidget { this.minZoom = 0, this.maxZoom = 22, required this.style, - required this.markerBuilder, - required this.markerCluster, - required this.markerEntries, + required this.markerClusterBuilder, + required this.markerWidgetBuilder, required this.markerSize, this.onUserZoomChange, this.onMarkerTap, @@ -52,6 +50,8 @@ class EntryLeafletMap extends StatefulWidget { class _EntryLeafletMapState extends State with TickerProviderStateMixin { final MapController _leafletMapController = MapController(); final List _subscriptions = []; + Map _geoEntryByMarkerKey = {}; + final Debouncer _debouncer = Debouncer(delay: Durations.mapIdleDebounceDelay); ValueNotifier get boundsNotifier => widget.boundsNotifier; @@ -65,58 +65,59 @@ class _EntryLeafletMapState extends State with TickerProviderSt @override void initState() { super.initState(); + _registerWidget(widget); + WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion()); + } + + @override + void didUpdateWidget(covariant EntryLeafletMap oldWidget) { + super.didUpdateWidget(oldWidget); + _unregisterWidget(oldWidget); + _registerWidget(widget); + } + + @override + void dispose() { + _unregisterWidget(widget); + super.dispose(); + } + + void _registerWidget(EntryLeafletMap widget) { final avesMapController = widget.controller; if (avesMapController != null) { _subscriptions.add(avesMapController.moveEvents.listen((event) => _moveTo(event.latLng))); } _subscriptions.add(_leafletMapController.mapEventStream.listen((event) => _updateVisibleRegion())); - WidgetsBinding.instance!.addPostFrameCallback((_) => _updateVisibleRegion()); + boundsNotifier.addListener(_onBoundsChange); } - @override - void dispose() { + void _unregisterWidget(EntryLeafletMap widget) { + boundsNotifier.removeListener(_onBoundsChange); _subscriptions ..forEach((sub) => sub.cancel()) ..clear(); - super.dispose(); } @override Widget build(BuildContext context) { - return ValueListenableBuilder( - valueListenable: boundsNotifier, - builder: (context, visibleRegion, child) { - final allEntries = widget.markerEntries; - final geoEntries = visibleRegion != null ? widget.markerCluster.clusters(visibleRegion.boundingBox, visibleRegion.zoom.round()) : []; - final geoEntryByMarkerKey = Map.fromEntries(geoEntries.map((v) { - if (v.isCluster!) { - final uri = v.childMarkerId; - final entry = allEntries.firstWhere((v) => v.uri == uri); - return MapEntry(MarkerKey(entry, v.pointsSize), v); - } - return MapEntry(MarkerKey(v.entry!, null), v); - })); - - return Stack( - children: [ - MapDecorator( - interactive: interactive, - child: _buildMap(geoEntryByMarkerKey), - ), - MapButtonPanel( - boundsNotifier: boundsNotifier, - zoomBy: _zoomBy, - resetRotation: interactive ? _resetRotation : null, - ), - ], - ); - }, + return Stack( + children: [ + MapDecorator( + interactive: interactive, + child: _buildMap(), + ), + MapButtonPanel( + boundsNotifier: boundsNotifier, + zoomBy: _zoomBy, + resetRotation: interactive ? _resetRotation : null, + ), + ], ); } - Widget _buildMap(Map geoEntryByMarkerKey) { + Widget _buildMap() { final markerSize = widget.markerSize; - final markers = geoEntryByMarkerKey.entries.map((kv) { + final markers = _geoEntryByMarkerKey.entries.map((kv) { final markerKey = kv.key; final geoEntry = kv.value; final latLng = LatLng(geoEntry.latitude!, geoEntry.longitude!); @@ -124,7 +125,7 @@ class _EntryLeafletMapState extends State with TickerProviderSt point: latLng, builder: (context) => GestureDetector( onTap: () => widget.onMarkerTap?.call(geoEntry), - child: widget.markerBuilder(markerKey), + child: widget.markerWidgetBuilder(markerKey), ), width: markerSize.width, height: markerSize.height, @@ -173,6 +174,13 @@ class _EntryLeafletMapState extends State with TickerProviderSt } } + void _onBoundsChange() => _debouncer(_updateClusters); + + void _updateClusters() { + if (!mounted) return; + setState(() => _geoEntryByMarkerKey = widget.markerClusterBuilder()); + } + void _updateVisibleRegion() { final bounds = _leafletMapController.bounds; if (bounds != null) { diff --git a/lib/widgets/map/map_page.dart b/lib/widgets/map/map_page.dart index 61776eb2d..3dfb0c6d9 100644 --- a/lib/widgets/map/map_page.dart +++ b/lib/widgets/map/map_page.dart @@ -74,14 +74,12 @@ class _MapPageState extends State { entries: entries, interactive: true, isAnimatingNotifier: _isAnimatingNotifier, - onMarkerTap: (markerEntries) { - if (markerEntries.isEmpty) return; - final entry = markerEntries.first; - final index = entries.indexOf(entry); + onMarkerTap: (markerEntry, getClusterEntries) { + final index = entries.indexOf(markerEntry); if (_selectedIndexNotifier.value != index) { _selectedIndexNotifier.value = index; } else { - _moveToEntry(entry); + _moveToEntry(markerEntry); } }, ),