From ec92d7d5be35fa4c39c4fc884f0d856001d37f81 Mon Sep 17 00:00:00 2001 From: Joonas Kerttula Date: Tue, 6 Aug 2024 23:00:25 +0300 Subject: [PATCH] [google_maps_flutter] Add marker clustering support - iOS implementation (#6186) This PR introduces support for marker clustering for iOS platform An example usabe is available in the example application at ./packages/google_maps_flutter/google_maps_flutter_ios/example/ios12 on the page `Manage clustering` This is prequel PR for: https://github.com/flutter/packages/pull/4319 and sequel PR for: https://github.com/flutter/packages/pull/6158 Containing only changes to `google_maps_flutter_ios` package. Follow up PR will hold the app-facing plugin implementation. Linked issue: https://github.com/flutter/flutter/issues/26863 --- .../google_maps_flutter_ios/CHANGELOG.md | 4 + .../integration_test/google_maps_test.dart | 102 +++++++ .../ios/Runner.xcodeproj/project.pbxproj | 14 +- .../FGMClusterManagersControllerTests.m | 129 ++++++++ .../example/ios14/lib/main.dart | 2 + .../example/ios15/lib/main.dart | 2 + .../maps_example_dart/lib/clustering.dart | 278 ++++++++++++++++++ .../lib/example_google_map.dart | 32 ++ .../fake_google_maps_flutter_platform.dart | 18 ++ .../Classes/FGMClusterManagersController.h | 61 ++++ .../Classes/FGMClusterManagersController.m | 135 +++++++++ .../ios/Classes/FGMMarkerUserData.h | 37 +++ .../ios/Classes/FGMMarkerUserData.m | 34 +++ .../ios/Classes/FLTGoogleMapJSONConversions.h | 4 + .../ios/Classes/FLTGoogleMapJSONConversions.m | 18 ++ .../ios/Classes/FLTGoogleMapsPlugin.h | 2 + .../ios/Classes/GoogleMapController.h | 2 + .../ios/Classes/GoogleMapController.m | 62 +++- .../ios/Classes/GoogleMapMarkerController.h | 9 +- .../ios/Classes/GoogleMapMarkerController.m | 119 +++++--- .../google_maps_flutter_ios-umbrella.h | 1 + .../ios/Classes/messages.g.h | 35 +++ .../ios/Classes/messages.g.m | 194 ++++++++++-- .../ios/google_maps_flutter_ios.podspec | 2 +- .../lib/src/google_map_inspector_ios.dart | 14 + .../lib/src/google_maps_flutter_ios.dart | 49 +++ .../lib/src/messages.g.dart | 198 +++++++++++-- .../lib/src/utils/cluster_manager.dart | 19 ++ .../pigeons/messages.dart | 42 +++ .../google_maps_flutter_ios/pubspec.yaml | 2 +- .../test/cluster_manager_utils_test.dart | 44 +++ .../google_maps_flutter_ios_test.mocks.dart | 17 ++ 32 files changed, 1585 insertions(+), 96 deletions(-) create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FGMClusterManagersControllerTests.m create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/clustering.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.h create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.m create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.h create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.m create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart create mode 100644 packages/google_maps_flutter/google_maps_flutter_ios/test/cluster_manager_utils_test.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md b/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md index cb038750c136..381ebf6f5d4b 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md +++ b/packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md @@ -1,3 +1,7 @@ +## 2.12.0 + +* Adds support for marker clustering. + ## 2.11.0 * Adds support for heatmap layers. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/integration_test/google_maps_test.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/integration_test/google_maps_test.dart index db8b39567635..d3ea025d99c9 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/integration_test/google_maps_test.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/integration_test/google_maps_test.dart @@ -1030,6 +1030,85 @@ void main() { }, ); + testWidgets('marker clustering', (WidgetTester tester) async { + final Key key = GlobalKey(); + const int clusterManagersAmount = 2; + const int markersPerClusterManager = 5; + final Map markers = {}; + final Set clusterManagers = {}; + + for (int i = 0; i < clusterManagersAmount; i++) { + final ClusterManagerId clusterManagerId = + ClusterManagerId('cluster_manager_$i'); + final ClusterManager clusterManager = + ClusterManager(clusterManagerId: clusterManagerId); + clusterManagers.add(clusterManager); + } + + for (final ClusterManager cm in clusterManagers) { + for (int i = 0; i < markersPerClusterManager; i++) { + final MarkerId markerId = + MarkerId('${cm.clusterManagerId.value}_marker_$i'); + final Marker marker = Marker( + markerId: markerId, + clusterManagerId: cm.clusterManagerId, + position: LatLng( + _kInitialMapCenter.latitude + i, _kInitialMapCenter.longitude)); + markers[markerId] = marker; + } + } + + final Completer controllerCompleter = + Completer(); + + final GoogleMapsInspectorPlatform inspector = + GoogleMapsInspectorPlatform.instance!; + + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ExampleGoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values), + onMapCreated: (ExampleGoogleMapController googleMapController) { + controllerCompleter.complete(googleMapController); + }, + ), + )); + + final ExampleGoogleMapController controller = + await controllerCompleter.future; + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + final int markersAmountForClusterManager = clusters + .map((Cluster cluster) => cluster.count) + .reduce((int value, int element) => value + element); + expect(markersAmountForClusterManager, markersPerClusterManager); + } + + // Remove markers from clusterManagers and test that clusterManagers are empty. + for (final MapEntry entry in markers.entries) { + markers[entry.key] = _copyMarkerWithClusterManagerId(entry.value, null); + } + await tester.pumpWidget(Directionality( + textDirection: TextDirection.ltr, + child: ExampleGoogleMap( + key: key, + initialCameraPosition: _kInitialCameraPosition, + clusterManagers: clusterManagers, + markers: Set.of(markers.values)), + )); + + for (final ClusterManager cm in clusterManagers) { + final List clusters = await inspector.getClusters( + mapId: controller.mapId, clusterManagerId: cm.clusterManagerId); + expect(clusters.length, 0); + } + }); + testWidgets('testSetStyleMapId', (WidgetTester tester) async { final Key key = GlobalKey(); @@ -1254,3 +1333,26 @@ class _DebugTileProvider implements TileProvider { return Tile(width, height, byteData); } } + +Marker _copyMarkerWithClusterManagerId( + Marker marker, ClusterManagerId? clusterManagerId) { + return Marker( + markerId: marker.markerId, + alpha: marker.alpha, + anchor: marker.anchor, + consumeTapEvents: marker.consumeTapEvents, + draggable: marker.draggable, + flat: marker.flat, + icon: marker.icon, + infoWindow: marker.infoWindow, + position: marker.position, + rotation: marker.rotation, + visible: marker.visible, + zIndex: marker.zIndex, + onTap: marker.onTap, + onDragStart: marker.onDragStart, + onDrag: marker.onDrag, + onDragEnd: marker.onDragEnd, + clusterManagerId: clusterManagerId, + ); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj index cb8922d30b6e..b9fa7719dcf7 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj @@ -12,7 +12,8 @@ 2BDE99378062AE3E60B40021 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */; }; 3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; }; 478116522BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */; }; - 521AB0032B876A76005F460D /* ExtractIconFromDataTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */; }; + 528F16832C62941000148160 /* FGMClusterManagersControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */; }; + 528F16872C62952700148160 /* ExtractIconFromDataTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 528F16862C62952700148160 /* ExtractIconFromDataTests.m */; }; 6851F3562835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */; }; 68E4726A2836FF0C00BDDDAC /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68E472692836FF0C00BDDDAC /* MapKit.framework */; }; 978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; }; @@ -64,7 +65,8 @@ 3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = ""; }; 478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsPolylinesControllerTests.m; sourceTree = ""; }; - 521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = ""; }; + 528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FGMClusterManagersControllerTests.m; sourceTree = ""; }; + 528F16862C62952700148160 /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = ""; }; 61A9A8623F5CA9BBC813DC6B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; }; 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTGoogleMapJSONConversionsConversionTests.m; sourceTree = ""; }; 68E472692836FF0C00BDDDAC /* MapKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = MapKit.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX12.0.sdk/System/iOSSupport/System/Library/Frameworks/MapKit.framework; sourceTree = DEVELOPER_DIR; }; @@ -205,14 +207,15 @@ isa = PBXGroup; children = ( F269303A2BB389BF00BF17C4 /* assets */, + 528F16862C62952700148160 /* ExtractIconFromDataTests.m */, + 528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */, 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */, - 521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */, + 0DD7B6C22B744EEF00E857FD /* FLTTileProviderControllerTests.m */, F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */, 478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */, 982F2A6A27BADE17003C81F4 /* PartiallyMockedMapView.h */, 982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */, F7151F14265D7ED70028CB91 /* Info.plist */, - 0DD7B6C22B744EEF00E857FD /* FLTTileProviderControllerTests.m */, ); path = RunnerTests; sourceTree = ""; @@ -508,12 +511,13 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 528F16832C62941000148160 /* FGMClusterManagersControllerTests.m in Sources */, F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */, 6851F3562835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m in Sources */, 982F2A6C27BADE17003C81F4 /* PartiallyMockedMapView.m in Sources */, 478116522BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m in Sources */, 0DD7B6C32B744EEF00E857FD /* FLTTileProviderControllerTests.m in Sources */, - 521AB0032B876A76005F460D /* ExtractIconFromDataTests.m in Sources */, + 528F16872C62952700148160 /* ExtractIconFromDataTests.m in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FGMClusterManagersControllerTests.m b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FGMClusterManagersControllerTests.m new file mode 100644 index 000000000000..97607bf63ca8 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/RunnerTests/FGMClusterManagersControllerTests.m @@ -0,0 +1,129 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +@import google_maps_flutter_ios; +@import google_maps_flutter_ios.Test; +@import XCTest; +@import GoogleMaps; + +#import +#import +#import "PartiallyMockedMapView.h" + +@interface FGMClusterManagersControllerTests : XCTestCase +@end + +@implementation FGMClusterManagersControllerTests + +- (void)testClustering { + NSObject *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar)); + CGRect frame = CGRectMake(0, 0, 100, 100); + + GMSMapViewOptions *mapViewOptions = [[GMSMapViewOptions alloc] init]; + mapViewOptions.frame = frame; + mapViewOptions.camera = [[GMSCameraPosition alloc] initWithLatitude:0 longitude:0 zoom:0]; + + PartiallyMockedMapView *mapView = [[PartiallyMockedMapView alloc] initWithOptions:mapViewOptions]; + + id handler = OCMClassMock([FGMMapsCallbackApi class]); + + FGMClusterManagersController *clusterManagersController = + [[FGMClusterManagersController alloc] initWithMapView:mapView callbackHandler:handler]; + + FLTMarkersController *markersController = + [[FLTMarkersController alloc] initWithMapView:mapView + callbackHandler:handler + clusterManagersController:clusterManagersController + registrar:registrar]; + + // Add cluster managers. + NSString *clusterManagerId = @"cm"; + FGMPlatformClusterManager *clusterManagerToAdd = + [FGMPlatformClusterManager makeWithIdentifier:clusterManagerId]; + [clusterManagersController addClusterManagers:@[ clusterManagerToAdd ]]; + + // Add cluster managers in JSON format. + NSString *JSONClusterManagerId = @"json_cm"; + NSDictionary *JSONclusterManagerToAdd = @{@"clusterManagerId" : JSONClusterManagerId}; + [clusterManagersController addJSONClusterManagers:@[ JSONclusterManagerToAdd ]]; + + // Verify that cluster managers are available + GMUClusterManager *clusterManager = + [clusterManagersController clusterManagerWithIdentifier:clusterManagerId]; + XCTAssertNotNil(clusterManager, @"Cluster Manager should not be nil"); + GMUClusterManager *JSONClusterManager = + [clusterManagersController clusterManagerWithIdentifier:JSONClusterManagerId]; + XCTAssertNotNil(JSONClusterManager, @"Cluster Manager should not be nil"); + + // Add markers + NSString *markerId1 = @"m1"; + NSString *markerId2 = @"m2"; + + FGMPlatformMarker *marker1 = [FGMPlatformMarker makeWithJson:@{ + @"markerId" : markerId1, + @"position" : @[ @0, @0 ], + @"clusterManagerId" : clusterManagerId + }]; + NSDictionary *marker2 = + @{@"markerId" : markerId2, @"position" : @[ @0, @0 ], @"clusterManagerId" : clusterManagerId}; + + [markersController addMarkers:@[ marker1 ]]; + [markersController addJSONMarkers:@[ marker2 ]]; + + FlutterError *error = nil; + + // Invoke clustering + [clusterManagersController invokeClusteringForEachClusterManager]; + + // Verify that the markers were added to the cluster manager + NSArray *clusters1 = + [clusterManagersController clustersWithIdentifier:clusterManagerId error:&error]; + XCTAssertNil(error, @"Error should be nil"); + for (FGMPlatformCluster *cluster in clusters1) { + NSString *cmId = cluster.clusterManagerId; + XCTAssertNotNil(cmId, @"Cluster Manager Identifier should not be nil"); + if ([cmId isEqualToString:clusterManagerId]) { + NSArray *markerIds = cluster.markerIds; + XCTAssertEqual(markerIds.count, 2, @"Cluster should contain two marker"); + XCTAssertTrue([markerIds containsObject:markerId1], @"Cluster should contain markerId1"); + XCTAssertTrue([markerIds containsObject:markerId2], @"Cluster should contain markerId2"); + return; + } + } + + [markersController removeMarkersWithIdentifiers:@[ markerId2 ]]; + + // Verify that the marker2 is removed from the clusterManager + NSArray *clusters2 = + [clusterManagersController clustersWithIdentifier:clusterManagerId error:&error]; + XCTAssertNil(error, @"Error should be nil"); + + for (FGMPlatformCluster *cluster in clusters2) { + NSString *cmId = cluster.clusterManagerId; + XCTAssertNotNil(cmId, @"Cluster Manager ID should not be nil"); + if ([cmId isEqualToString:clusterManagerId]) { + NSArray *markerIds = cluster.markerIds; + XCTAssertEqual(markerIds.count, 1, @"Cluster should contain one marker"); + XCTAssertTrue([markerIds containsObject:markerId1], @"Cluster should contain markerId1"); + return; + } + } + + [markersController removeMarkersWithIdentifiers:@[ markerId1 ]]; + + // Verify that all markers are removed from clusterManager + NSArray *clusters3 = + [clusterManagersController clustersWithIdentifier:clusterManagerId error:&error]; + XCTAssertNil(error, @"Error should be nil"); + XCTAssertEqual(clusters3.count, 0, @"Cluster Manager should not contain any clusters"); + + // Remove cluster manager + [clusterManagersController removeClusterManagersWithIdentifiers:@[ clusterManagerId ]]; + + // Verify that the cluster manager is removed + clusterManager = [clusterManagersController clusterManagerWithIdentifier:clusterManagerId]; + XCTAssertNil(clusterManager, @"Cluster Manager should be nil"); +} + +@end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/lib/main.dart index 09fa814fdcf8..3144c2aff5e6 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:maps_example_dart/animate_camera.dart'; +import 'package:maps_example_dart/clustering.dart'; import 'package:maps_example_dart/lite_mode.dart'; import 'package:maps_example_dart/map_click.dart'; import 'package:maps_example_dart/map_coordinates.dart'; @@ -40,6 +41,7 @@ void main() { SnapshotPage(), LiteModePage(), TileOverlayPage(), + ClusteringPage(), MapIdPage(), ]))); } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/lib/main.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/lib/main.dart index 09fa814fdcf8..3144c2aff5e6 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/lib/main.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/lib/main.dart @@ -4,6 +4,7 @@ import 'package:flutter/material.dart'; import 'package:maps_example_dart/animate_camera.dart'; +import 'package:maps_example_dart/clustering.dart'; import 'package:maps_example_dart/lite_mode.dart'; import 'package:maps_example_dart/map_click.dart'; import 'package:maps_example_dart/map_coordinates.dart'; @@ -40,6 +41,7 @@ void main() { SnapshotPage(), LiteModePage(), TileOverlayPage(), + ClusteringPage(), MapIdPage(), ]))); } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/clustering.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/clustering.dart new file mode 100644 index 000000000000..f6860f7bf42e --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/clustering.dart @@ -0,0 +1,278 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:math'; + +import 'package:flutter/material.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +import 'example_google_map.dart'; +import 'page.dart'; + +/// Page for demonstrating marker clustering support. +class ClusteringPage extends GoogleMapExampleAppPage { + /// Default Constructor. + const ClusteringPage({Key? key}) + : super(const Icon(Icons.place), 'Manage clustering', key: key); + + @override + Widget build(BuildContext context) { + return const ClusteringBody(); + } +} + +/// Body of the clustering page. +class ClusteringBody extends StatefulWidget { + /// Default Constructor. + const ClusteringBody({super.key}); + + @override + State createState() => ClusteringBodyState(); +} + +/// State of the clustering page. +class ClusteringBodyState extends State { + /// Default Constructor. + ClusteringBodyState(); + + /// Starting point from where markers are added. + static const LatLng center = LatLng(-33.86, 151.1547171); + + /// Marker offset factor for randomizing marker placing. + static const double _markerOffsetFactor = 0.05; + + /// Offset for longitude when placing markers to different cluster managers. + static const double _clusterManagerLongitudeOffset = 0.1; + + /// Maximum amount of cluster managers. + static const int _clusterManagerMaxCount = 3; + + /// Amount of markers to be added to the cluster manager at once. + static const int _markersToAddToClusterManagerCount = 10; + + /// Fully visible alpha value. + static const double _fullyVisibleAlpha = 1.0; + + /// Half visible alpha value. + static const double _halfVisibleAlpha = 0.5; + + /// Google map controller. + ExampleGoogleMapController? controller; + + /// Map of clusterManagers with identifier as the key. + Map clusterManagers = + {}; + + /// Map of markers with identifier as the key. + Map markers = {}; + + /// Id of the currently selected marker. + MarkerId? selectedMarker; + + /// Counter for added cluster manager ids. + int _clusterManagerIdCounter = 1; + + /// Counter for added markers ids. + int _markerIdCounter = 1; + + /// Cluster that was tapped most recently. + Cluster? lastCluster; + + // ignore: use_setters_to_change_properties + void _onMapCreated(ExampleGoogleMapController controller) { + this.controller = controller; + } + + @override + void dispose() { + super.dispose(); + } + + void _onMarkerTapped(MarkerId markerId) { + final Marker? tappedMarker = markers[markerId]; + if (tappedMarker != null) { + setState(() { + final MarkerId? previousMarkerId = selectedMarker; + if (previousMarkerId != null && markers.containsKey(previousMarkerId)) { + final Marker resetOld = markers[previousMarkerId]! + .copyWith(iconParam: BitmapDescriptor.defaultMarker); + markers[previousMarkerId] = resetOld; + } + selectedMarker = markerId; + final Marker newMarker = tappedMarker.copyWith( + iconParam: BitmapDescriptor.defaultMarkerWithHue( + BitmapDescriptor.hueGreen, + ), + ); + markers[markerId] = newMarker; + }); + } + } + + void _addClusterManager() { + if (clusterManagers.length == _clusterManagerMaxCount) { + return; + } + + final String clusterManagerIdVal = + 'cluster_manager_id_$_clusterManagerIdCounter'; + _clusterManagerIdCounter++; + final ClusterManagerId clusterManagerId = + ClusterManagerId(clusterManagerIdVal); + + final ClusterManager clusterManager = ClusterManager( + clusterManagerId: clusterManagerId, + onClusterTap: (Cluster cluster) => setState(() { + lastCluster = cluster; + }), + ); + + setState(() { + clusterManagers[clusterManagerId] = clusterManager; + }); + _addMarkersToCluster(clusterManager); + } + + void _removeClusterManager(ClusterManager clusterManager) { + setState(() { + // Remove markers managed by cluster manager to be removed. + markers.removeWhere((MarkerId key, Marker marker) => + marker.clusterManagerId == clusterManager.clusterManagerId); + // Remove cluster manager. + clusterManagers.remove(clusterManager.clusterManagerId); + }); + } + + void _addMarkersToCluster(ClusterManager clusterManager) { + for (int i = 0; i < _markersToAddToClusterManagerCount; i++) { + final String markerIdVal = + '${clusterManager.clusterManagerId.value}_marker_id_$_markerIdCounter'; + _markerIdCounter++; + final MarkerId markerId = MarkerId(markerIdVal); + + final int clusterManagerIndex = + clusterManagers.values.toList().indexOf(clusterManager); + + // Add additional offset to longitude for each cluster manager to space + // out markers in different cluster managers. + final double clusterManagerLongitudeOffset = + clusterManagerIndex * _clusterManagerLongitudeOffset; + + final Marker marker = Marker( + clusterManagerId: clusterManager.clusterManagerId, + markerId: markerId, + position: LatLng( + center.latitude + _getRandomOffset(), + center.longitude + _getRandomOffset() + clusterManagerLongitudeOffset, + ), + infoWindow: InfoWindow(title: markerIdVal, snippet: '*'), + onTap: () => _onMarkerTapped(markerId), + ); + markers[markerId] = marker; + } + setState(() {}); + } + + double _getRandomOffset() { + return (Random().nextDouble() - 0.5) * _markerOffsetFactor; + } + + void _remove(MarkerId markerId) { + setState(() { + if (markers.containsKey(markerId)) { + markers.remove(markerId); + } + }); + } + + void _changeMarkersAlpha() { + for (final MarkerId markerId in markers.keys) { + final Marker marker = markers[markerId]!; + final double current = marker.alpha; + markers[markerId] = marker.copyWith( + alphaParam: current == _fullyVisibleAlpha + ? _halfVisibleAlpha + : _fullyVisibleAlpha, + ); + } + setState(() {}); + } + + @override + Widget build(BuildContext context) { + final MarkerId? selectedId = selectedMarker; + return Column( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + SizedBox( + height: 300.0, + child: ExampleGoogleMap( + onMapCreated: _onMapCreated, + initialCameraPosition: const CameraPosition( + target: LatLng(-33.852, 151.25), + zoom: 11.0, + ), + markers: Set.of(markers.values), + clusterManagers: Set.of(clusterManagers.values), + ), + ), + Column(children: [ + Row( + mainAxisAlignment: MainAxisAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: clusterManagers.length >= _clusterManagerMaxCount + ? null + : () => _addClusterManager(), + child: const Text('Add cluster manager'), + ), + TextButton( + onPressed: clusterManagers.isEmpty + ? null + : () => _removeClusterManager(clusterManagers.values.last), + child: const Text('Remove cluster manager'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + for (final MapEntry clusterEntry + in clusterManagers.entries) + TextButton( + onPressed: () => _addMarkersToCluster(clusterEntry.value), + child: Text('Add markers to ${clusterEntry.key.value}'), + ), + ], + ), + Wrap( + alignment: WrapAlignment.spaceEvenly, + children: [ + TextButton( + onPressed: selectedId == null + ? null + : () { + _remove(selectedId); + setState(() { + selectedMarker = null; + }); + }, + child: const Text('Remove selected marker'), + ), + TextButton( + onPressed: markers.isEmpty ? null : () => _changeMarkersAlpha(), + child: const Text('Change all markers alpha'), + ), + ], + ), + if (lastCluster != null) + Padding( + padding: const EdgeInsets.all(10), + child: Text( + 'Cluster with ${lastCluster!.count} markers clicked at ${lastCluster!.position}')), + ]), + ], + ); + } +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/example_google_map.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/example_google_map.dart index 0734731af7c3..fcf24452c875 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/example_google_map.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/lib/example_google_map.dart @@ -87,6 +87,9 @@ class ExampleGoogleMapController { .listen((MapTapEvent e) => _googleMapState.onTap(e.position)); GoogleMapsFlutterPlatform.instance.onLongPress(mapId: mapId).listen( (MapLongPressEvent e) => _googleMapState.onLongPress(e.position)); + GoogleMapsFlutterPlatform.instance + .onClusterTap(mapId: mapId) + .listen((ClusterTapEvent e) => _googleMapState.onClusterTap(e.value)); } /// Updates configuration options of the map user interface. @@ -101,6 +104,13 @@ class ExampleGoogleMapController { .updateMarkers(markerUpdates, mapId: mapId); } + /// Updates cluster manager configuration. + Future _updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates) { + return GoogleMapsFlutterPlatform.instance + .updateClusterManagers(clusterManagerUpdates, mapId: mapId); + } + /// Updates polygon configuration. Future _updatePolygons(PolygonUpdates polygonUpdates) { return GoogleMapsFlutterPlatform.instance @@ -237,6 +247,7 @@ class ExampleGoogleMap extends StatefulWidget { this.polygons = const {}, this.polylines = const {}, this.circles = const {}, + this.clusterManagers = const {}, this.onCameraMoveStarted, this.tileOverlays = const {}, this.onCameraMove, @@ -312,6 +323,9 @@ class ExampleGoogleMap extends StatefulWidget { /// Tile overlays to be placed on the map. final Set tileOverlays; + /// Cluster Managers to be placed for the map. + final Set clusterManagers; + /// Called when the camera starts moving. final VoidCallback? onCameraMoveStarted; @@ -371,6 +385,8 @@ class _ExampleGoogleMapState extends State { Map _polygons = {}; Map _polylines = {}; Map _circles = {}; + Map _clusterManagers = + {}; late MapConfiguration _mapConfiguration; @override @@ -390,6 +406,7 @@ class _ExampleGoogleMapState extends State { polygons: widget.polygons, polylines: widget.polylines, circles: widget.circles, + clusterManagers: widget.clusterManagers, ), mapConfiguration: _mapConfiguration, ); @@ -399,6 +416,7 @@ class _ExampleGoogleMapState extends State { void initState() { super.initState(); _mapConfiguration = _configurationFromMapWidget(widget); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); _markers = keyByMarkerId(widget.markers); _polygons = keyByPolygonId(widget.polygons); _polylines = keyByPolylineId(widget.polylines); @@ -416,6 +434,7 @@ class _ExampleGoogleMapState extends State { void didUpdateWidget(ExampleGoogleMap oldWidget) { super.didUpdateWidget(oldWidget); _updateOptions(); + _updateClusterManagers(); _updateMarkers(); _updatePolygons(); _updatePolylines(); @@ -441,6 +460,13 @@ class _ExampleGoogleMapState extends State { _markers = keyByMarkerId(widget.markers); } + Future _updateClusterManagers() async { + final ExampleGoogleMapController controller = await _controller.future; + unawaited(controller._updateClusterManagers(ClusterManagerUpdates.from( + _clusterManagers.values.toSet(), widget.clusterManagers))); + _clusterManagers = keyByClusterManagerId(widget.clusterManagers); + } + Future _updatePolygons() async { final ExampleGoogleMapController controller = await _controller.future; unawaited(controller._updatePolygons( @@ -518,6 +544,12 @@ class _ExampleGoogleMapState extends State { void onLongPress(LatLng position) { widget.onLongPress?.call(position); } + + void onClusterTap(Cluster cluster) { + final ClusterManager? clusterManager = + _clusterManagers[cluster.clusterManagerId]; + clusterManager?.onClusterTap?.call(cluster); + } } /// Builds a [MapConfiguration] from the given [map]. diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/test/fake_google_maps_flutter_platform.dart b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/test/fake_google_maps_flutter_platform.dart index 22447ba5ecad..9ac70ab760fe 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/test/fake_google_maps_flutter_platform.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/example/shared/maps_example_dart/test/fake_google_maps_flutter_platform.dart @@ -94,6 +94,15 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { await _fakeDelay(); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) async { + mapInstances[mapId]?.clusterManagerUpdates.add(clusterManagerUpdates); + await _fakeDelay(); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -241,6 +250,11 @@ class FakeGoogleMapsFlutterPlatform extends GoogleMapsFlutterPlatform { return mapEventStreamController.stream.whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return mapEventStreamController.stream.whereType(); + } + @override void dispose({required int mapId}) { disposed = true; @@ -282,6 +296,8 @@ class PlatformMapStateRecorder { this.mapObjects = const MapObjects(), this.mapConfiguration = const MapConfiguration(), }) { + clusterManagerUpdates.add(ClusterManagerUpdates.from( + const {}, mapObjects.clusterManagers)); markerUpdates.add(MarkerUpdates.from(const {}, mapObjects.markers)); polygonUpdates .add(PolygonUpdates.from(const {}, mapObjects.polygons)); @@ -300,4 +316,6 @@ class PlatformMapStateRecorder { final List polylineUpdates = []; final List circleUpdates = []; final List> tileOverlaySets = >[]; + final List clusterManagerUpdates = + []; } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.h new file mode 100644 index 000000000000..9e5f6f16ae67 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.h @@ -0,0 +1,61 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import +#import +@import GoogleMapsUtils; + +#import "messages.g.h" + +NS_ASSUME_NONNULL_BEGIN + +/// A controller that manages all of the cluster managers on a map. +@interface FGMClusterManagersController : NSObject + +/// Initializes cluster manager controller. +/// +/// @param callbackHandler A callback handler. +/// @param mapView A map view that will be used to display clustered markers. +- (instancetype)initWithMapView:(GMSMapView *)mapView + callbackHandler:(FGMMapsCallbackApi *)callbackHandler; + +/// Creates cluster managers and initializes them form JSON data. +/// +/// @param clusterManagersToAdd Array of cluster managers JSON data to add. +- (void)addJSONClusterManagers:(NSArray *)clusterManagersToAdd; + +/// Creates cluster managers and initializes them. +/// +/// @param clusterManagersToAdd Array of cluster managers to add. +- (void)addClusterManagers:(NSArray *)clusterManagersToAdd; + +/// Removes requested cluster managers from the controller. +/// +/// @param identifiers Array of cluster manager IDs to remove. +- (void)removeClusterManagersWithIdentifiers:(NSArray *)identifiers; + +/// Returns the cluster managers for the given identifier. +/// +/// @param identifier The identifier of the cluster manager. +/// @return A cluster manager if found; otherwise, nil. +- (nullable GMUClusterManager *)clusterManagerWithIdentifier:(NSString *)identifier; + +/// Returns an array of clusters managed by the cluster manager. +/// +/// @param identifier The identifier of the cluster manager whose clusters are to be retrieved. +/// @return An array of clusters. Returns `nil` only if `error` is populated. +- (nullable NSArray *) + clustersWithIdentifier:(NSString *)identifier + error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error; + +/// Called when a cluster marker is tapped on the map. +/// +/// @param cluster The cluster that was tapped on. +- (void)didTapCluster:(GMUStaticCluster *)cluster; + +/// Calls the cluster method of all the cluster managers. +- (void)invokeClusteringForEachClusterManager; +@end + +NS_ASSUME_NONNULL_END diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.m new file mode 100644 index 000000000000..d9fcb6a9db89 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMClusterManagersController.m @@ -0,0 +1,135 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FGMClusterManagersController.h" + +#import "FGMMarkerUserData.h" +#import "FLTGoogleMapJSONConversions.h" + +@interface FGMClusterManagersController () + +/// A dictionary mapping unique cluster manager identifiers to their corresponding cluster managers. +@property(strong, nonatomic) + NSMutableDictionary *clusterManagerIdentifierToManagers; + +/// The callback handler interface for calls to Flutter. +@property(strong, nonatomic) FGMMapsCallbackApi *callbackHandler; + +/// The current map instance on which the cluster managers are operating. +@property(strong, nonatomic) GMSMapView *mapView; + +@end + +@implementation FGMClusterManagersController +- (instancetype)initWithMapView:(GMSMapView *)mapView + callbackHandler:(FGMMapsCallbackApi *)callbackHandler { + self = [super init]; + if (self) { + _callbackHandler = callbackHandler; + _mapView = mapView; + _clusterManagerIdentifierToManagers = [[NSMutableDictionary alloc] init]; + } + return self; +} + +- (void)addJSONClusterManagers:(NSArray *)clusterManagersToAdd { + for (NSDictionary *clusterDict in clusterManagersToAdd) { + NSString *identifier = clusterDict[@"clusterManagerId"]; + [self addClusterManager:identifier]; + } +} + +- (void)addClusterManagers:(NSArray *)clusterManagersToAdd { + for (FGMPlatformClusterManager *clusterManager in clusterManagersToAdd) { + NSString *identifier = clusterManager.identifier; + [self addClusterManager:identifier]; + } +} + +- (void)addClusterManager:(NSString *)identifier { + id algorithm = [[GMUNonHierarchicalDistanceBasedAlgorithm alloc] init]; + id iconGenerator = [[GMUDefaultClusterIconGenerator alloc] init]; + id renderer = + [[GMUDefaultClusterRenderer alloc] initWithMapView:self.mapView + clusterIconGenerator:iconGenerator]; + self.clusterManagerIdentifierToManagers[identifier] = + [[GMUClusterManager alloc] initWithMap:self.mapView algorithm:algorithm renderer:renderer]; + ; +} + +- (void)removeClusterManagersWithIdentifiers:(NSArray *)identifiers { + for (NSString *identifier in identifiers) { + GMUClusterManager *clusterManager = + [self.clusterManagerIdentifierToManagers objectForKey:identifier]; + if (!clusterManager) { + continue; + } + [clusterManager clearItems]; + [self.clusterManagerIdentifierToManagers removeObjectForKey:identifier]; + } +} + +- (nullable GMUClusterManager *)clusterManagerWithIdentifier:(NSString *)identifier { + return [self.clusterManagerIdentifierToManagers objectForKey:identifier]; +} + +- (void)invokeClusteringForEachClusterManager { + for (GMUClusterManager *clusterManager in [self.clusterManagerIdentifierToManagers allValues]) { + [clusterManager cluster]; + } +} + +- (nullable NSArray *) + clustersWithIdentifier:(NSString *)identifier + error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { + GMUClusterManager *clusterManager = + [self.clusterManagerIdentifierToManagers objectForKey:identifier]; + + if (!clusterManager) { + *error = [FlutterError + errorWithCode:@"Invalid clusterManagerId" + message:@"getClusters called with invalid clusterManagerId" + details:[NSString stringWithFormat:@"clusterManagerId was: '%@'", identifier]]; + return nil; + } + + // Ref: + // https://github.com/googlemaps/google-maps-ios-utils/blob/0e7ed81f1bbd9d29e4529c40ae39b0791b0a0eb8/src/Clustering/GMUClusterManager.m#L94. + NSUInteger integralZoom = (NSUInteger)floorf(_mapView.camera.zoom + 0.5f); + NSArray> *clusters = [clusterManager.algorithm clustersAtZoom:integralZoom]; + NSMutableArray *response = + [[NSMutableArray alloc] initWithCapacity:clusters.count]; + for (id cluster in clusters) { + FGMPlatformCluster *platFormCluster = FGMGetPigeonCluster(cluster, identifier); + [response addObject:platFormCluster]; + } + return response; +} + +- (void)didTapCluster:(GMUStaticCluster *)cluster { + NSString *clusterManagerId = [self clusterManagerIdentifierForCluster:cluster]; + if (!clusterManagerId) { + return; + } + FGMPlatformCluster *platFormCluster = FGMGetPigeonCluster(cluster, clusterManagerId); + [self.callbackHandler didTapCluster:platFormCluster + completion:^(FlutterError *_Nullable _){ + }]; +} + +#pragma mark - Private methods + +/// Returns the cluster manager identifier for given cluster. +/// +/// @return The cluster manager identifier if found; otherwise, nil. +- (nullable NSString *)clusterManagerIdentifierForCluster:(GMUStaticCluster *)cluster { + if ([cluster.items.firstObject isKindOfClass:[GMSMarker class]]) { + GMSMarker *firstMarker = (GMSMarker *)cluster.items.firstObject; + return FGMGetClusterManagerIdentifierFromMarker(firstMarker); + } + + return nil; +} + +@end diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.h new file mode 100644 index 000000000000..310455a05658 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.h @@ -0,0 +1,37 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import + +NS_ASSUME_NONNULL_BEGIN + +/// Defines user data object for markers. +@interface FGMMarkerUserData : NSObject + +/// The identifier of the marker. +@property(nonatomic, copy) NSString *markerIdentifier; + +/// The identifier of the cluster manager. +/// This property is set only if the marker is managed by a cluster manager. +@property(nonatomic, copy, nullable) NSString *clusterManagerIdentifier; + +@end + +/// Associates a marker identifier and optionally a cluster manager identifier with a marker's user +/// data. +extern void FGMSetIdentifiersToMarkerUserData(NSString *markerIdentifier, + NSString *_Nullable clusterManagerIdentifier, + GMSMarker *marker); + +/// Get the marker identifier from marker's user data. +/// +/// @return The marker identifier if found; otherwise, nil. +extern NSString *_Nullable FGMGetMarkerIdentifierFromMarker(GMSMarker *marker); + +/// Get the cluster manager identifier from marker's user data. +/// +/// @return The cluster manager identifier if found; otherwise, nil. +extern NSString *_Nullable FGMGetClusterManagerIdentifierFromMarker(GMSMarker *marker); + +NS_ASSUME_NONNULL_END diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.m new file mode 100644 index 000000000000..4e8e365c1ec0 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FGMMarkerUserData.m @@ -0,0 +1,34 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +#import "FGMMarkerUserData.h" + +@implementation FGMMarkerUserData + +@end + +void FGMSetIdentifiersToMarkerUserData(NSString *markerIdentifier, + NSString *_Nullable clusterManagerIdentifier, + GMSMarker *marker) { + FGMMarkerUserData *userData = [[FGMMarkerUserData alloc] init]; + userData.markerIdentifier = markerIdentifier; + userData.clusterManagerIdentifier = clusterManagerIdentifier; + marker.userData = userData; +}; + +NSString *_Nullable FGMGetMarkerIdentifierFromMarker(GMSMarker *marker) { + if ([marker.userData isKindOfClass:[FGMMarkerUserData class]]) { + FGMMarkerUserData *userData = (FGMMarkerUserData *)marker.userData; + return userData.markerIdentifier; + } + return nil; +}; + +NSString *_Nullable FGMGetClusterManagerIdentifierFromMarker(GMSMarker *marker) { + if ([marker.userData isKindOfClass:[FGMMarkerUserData class]]) { + FGMMarkerUserData *userData = (FGMMarkerUserData *)marker.userData; + return userData.clusterManagerIdentifier; + } + return nil; +}; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h index 403e93af8596..d6a7992e979e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.h @@ -34,6 +34,10 @@ extern FGMPlatformLatLngBounds *FGMGetPigeonLatLngBoundsForCoordinateBounds( extern FGMPlatformCameraPosition *FGMGetPigeonCameraPositionForPosition( GMSCameraPosition *position); +/// Converts a GMUStaticCluster to its Pigeon representation. +extern FGMPlatformCluster *FGMGetPigeonCluster(GMUStaticCluster *cluster, + NSString *clusterManagerIdentifier); + @interface FLTGoogleMapJSONConversions : NSObject extern NSString *const kHeatmapsToAddKey; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m index 69ceb73620eb..6542c998c839 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapJSONConversions.m @@ -3,6 +3,7 @@ // found in the LICENSE file. #import "FLTGoogleMapJSONConversions.h" +#import "FGMMarkerUserData.h" /// Returns dict[key], or nil if dict[key] is NSNull. id FGMGetValueOrNilFromDict(NSDictionary *dict, NSString *key) { @@ -39,6 +40,23 @@ CLLocationCoordinate2D FGMGetCoordinateForPigeonLatLng(FGMPlatformLatLng *latLng zoom:position.zoom]; } +FGMPlatformCluster *FGMGetPigeonCluster(GMUStaticCluster *cluster, + NSString *clusterManagerIdentifier) { + NSMutableArray *markerIDs = [[NSMutableArray alloc] initWithCapacity:cluster.items.count]; + GMSCoordinateBounds *bounds = [[GMSCoordinateBounds alloc] init]; + + for (GMSMarker *marker in cluster.items) { + [markerIDs addObject:FGMGetMarkerIdentifierFromMarker(marker)]; + bounds = [bounds includingCoordinate:marker.position]; + } + + return [FGMPlatformCluster + makeWithClusterManagerId:clusterManagerIdentifier + position:FGMGetPigeonLatLngForCoordinate(cluster.position) + bounds:FGMGetPigeonLatLngBoundsForCoordinateBounds(bounds) + markerIds:markerIDs]; +} + @implementation FLTGoogleMapJSONConversions // These constants must match the corresponding constants in serialization.dart diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h index 26f69eaf3882..cc69bcd39f2e 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/FLTGoogleMapsPlugin.h @@ -4,6 +4,8 @@ #import #import + +#import "FGMClusterManagersController.h" #import "GoogleMapCircleController.h" #import "GoogleMapController.h" #import "GoogleMapMarkerController.h" diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h index 4fdc08721d30..3b785c81df37 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.h @@ -4,6 +4,8 @@ #import #import + +#import "FGMClusterManagersController.h" #import "GoogleMapCircleController.h" #import "GoogleMapMarkerController.h" #import "GoogleMapPolygonController.h" diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m index 0f1ca2e3ad99..df49a63902ff 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapController.m @@ -2,7 +2,11 @@ // Use of this source code is governed by a BSD-style license that can be // found in the LICENSE file. +@import GoogleMapsUtils; + #import "GoogleMapController.h" + +#import "FGMMarkerUserData.h" #import "FLTGoogleMapHeatmapController.h" #import "FLTGoogleMapJSONConversions.h" #import "FLTGoogleMapTileOverlayController.h" @@ -116,6 +120,7 @@ @interface FLTGoogleMapController () @property(nonatomic, strong) FGMMapsCallbackApi *dartCallbackHandler; @property(nonatomic, assign) BOOL trackCameraPosition; @property(nonatomic, weak) NSObject *registrar; +@property(nonatomic, strong) FGMClusterManagersController *clusterManagersController; @property(nonatomic, strong) FLTMarkersController *markersController; @property(nonatomic, strong) FLTPolygonsController *polygonsController; @property(nonatomic, strong) FLTPolylinesController *polylinesController; @@ -175,8 +180,12 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView _mapView.delegate = self; _mapView.paddingAdjustmentBehavior = kGMSMapViewPaddingAdjustmentBehaviorNever; _registrar = registrar; + _clusterManagersController = + [[FGMClusterManagersController alloc] initWithMapView:_mapView + callbackHandler:_dartCallbackHandler]; _markersController = [[FLTMarkersController alloc] initWithMapView:_mapView callbackHandler:_dartCallbackHandler + clusterManagersController:_clusterManagersController registrar:registrar]; _polygonsController = [[FLTPolygonsController alloc] initWithMapView:_mapView callbackHandler:_dartCallbackHandler @@ -192,6 +201,11 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView [[FLTTileOverlaysController alloc] initWithMapView:_mapView callbackHandler:_dartCallbackHandler registrar:registrar]; + + id clusterManagersToAdd = args[@"clusterManagersToAdd"]; + if ([clusterManagersToAdd isKindOfClass:[NSArray class]]) { + [_clusterManagersController addJSONClusterManagers:clusterManagersToAdd]; + } id markersToAdd = args[@"markersToAdd"]; if ([markersToAdd isKindOfClass:[NSArray class]]) { [_markersController addJSONMarkers:markersToAdd]; @@ -217,6 +231,9 @@ - (instancetype)initWithMapView:(GMSMapView *_Nonnull)mapView [_tileOverlaysController addJSONTileOverlays:tileOverlaysToAdd]; } + // Invoke clustering after markers are added. + [_clusterManagersController invokeClusteringForEachClusterManager]; + [_mapView addObserver:self forKeyPath:@"frame" options:0 context:nil]; _callHandler = [[FGMMapCallHandler alloc] initWithMapController:self @@ -386,28 +403,36 @@ - (void)mapView:(GMSMapView *)mapView idleAtCameraPosition:(GMSCameraPosition *) } - (BOOL)mapView:(GMSMapView *)mapView didTapMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - return [self.markersController didTapMarkerWithIdentifier:markerId]; + if ([marker.userData isKindOfClass:[GMUStaticCluster class]]) { + GMUStaticCluster *cluster = marker.userData; + [self.clusterManagersController didTapCluster:cluster]; + // When NO is returned, the map will focus on the cluster. + return NO; + } + return + [self.markersController didTapMarkerWithIdentifier:FGMGetMarkerIdentifierFromMarker(marker)]; } - (void)mapView:(GMSMapView *)mapView didEndDraggingMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didEndDraggingMarkerWithIdentifier:markerId location:marker.position]; + [self.markersController + didEndDraggingMarkerWithIdentifier:FGMGetMarkerIdentifierFromMarker(marker) + location:marker.position]; } - (void)mapView:(GMSMapView *)mapView didBeginDraggingMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didStartDraggingMarkerWithIdentifier:markerId location:marker.position]; + [self.markersController + didStartDraggingMarkerWithIdentifier:FGMGetMarkerIdentifierFromMarker(marker) + location:marker.position]; } - (void)mapView:(GMSMapView *)mapView didDragMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didDragMarkerWithIdentifier:markerId location:marker.position]; + [self.markersController didDragMarkerWithIdentifier:FGMGetMarkerIdentifierFromMarker(marker) + location:marker.position]; } - (void)mapView:(GMSMapView *)mapView didTapInfoWindowOfMarker:(GMSMarker *)marker { - NSString *markerId = marker.userData[0]; - [self.markersController didTapInfoWindowOfMarkerWithIdentifier:markerId]; + [self.markersController + didTapInfoWindowOfMarkerWithIdentifier:FGMGetMarkerIdentifierFromMarker(marker)]; } - (void)mapView:(GMSMapView *)mapView didTapOverlay:(GMSOverlay *)overlay { NSString *overlayId = overlay.userData[0]; @@ -562,6 +587,16 @@ - (void)updateMarkersByAdding:(nonnull NSArray *)toAdd [self.controller.markersController addMarkers:toAdd]; [self.controller.markersController changeMarkers:toChange]; [self.controller.markersController removeMarkersWithIdentifiers:idsToRemove]; + + // Invoke clustering after markers are added. + [self.controller.clusterManagersController invokeClusteringForEachClusterManager]; +} + +- (void)updateClusterManagersByAdding:(nonnull NSArray *)toAdd + removing:(nonnull NSArray *)idsToRemove + error:(FlutterError *_Nullable __autoreleasing *_Nonnull)error { + [self.controller.clusterManagersController addClusterManagers:toAdd]; + [self.controller.clusterManagersController removeClusterManagersWithIdentifiers:idsToRemove]; } - (void)updatePolygonsByAdding:(nonnull NSArray *)toAdd @@ -783,6 +818,13 @@ - (nullable NSNumber *)areZoomGesturesEnabledWithError: return [FGMPlatformHeatmap makeWithJson:heatmapInfo]; } +- (nullable NSArray *) + clustersWithIdentifier:(NSString *)clusterManagerId + error:(FlutterError *_Nullable *_Nonnull)error { + return [self.controller.clusterManagersController clustersWithIdentifier:clusterManagerId + error:error]; +} + - (nullable NSNumber *)isCompassEnabledWithError: (FlutterError *_Nullable __autoreleasing *_Nonnull)error { return @(self.controller.mapView.settings.compassButton); diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h index 1b87ab1bb94f..95b834c5f71d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.h @@ -5,6 +5,7 @@ #import #import +#import "FGMClusterManagersController.h" #import "GoogleMapController.h" #import "messages.g.h" @@ -13,9 +14,10 @@ NS_ASSUME_NONNULL_BEGIN // Defines marker controllable by Flutter. @interface FLTGoogleMapMarkerController : NSObject @property(assign, nonatomic, readonly) BOOL consumeTapEvents; -- (instancetype)initWithPosition:(CLLocationCoordinate2D)position - identifier:(NSString *)identifier - mapView:(GMSMapView *)mapView; +- (instancetype)initWithMarker:(GMSMarker *)marker + markerIdentifier:(NSString *)markerIdentifier + clusterManagerIdentifier:(nullable NSString *)clusterManagerIdentifier + mapView:(GMSMapView *)mapView; - (void)showInfoWindow; - (void)hideInfoWindow; - (BOOL)isInfoWindowShown; @@ -25,6 +27,7 @@ NS_ASSUME_NONNULL_BEGIN @interface FLTMarkersController : NSObject - (instancetype)initWithMapView:(GMSMapView *)mapView callbackHandler:(FGMMapsCallbackApi *)callbackHandler + clusterManagersController:(nullable FGMClusterManagersController *)clusterManagersController registrar:(NSObject *)registrar; - (void)addJSONMarkers:(NSArray *> *)markersToAdd; - (void)addMarkers:(NSArray *)markersToAdd; diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m index 46ac7b24ee59..f4e149427358 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/GoogleMapMarkerController.m @@ -3,6 +3,8 @@ // found in the LICENSE file. #import "GoogleMapMarkerController.h" + +#import "FGMMarkerUserData.h" #import "FLTGoogleMapJSONConversions.h" @interface FLTGoogleMapMarkerController () @@ -10,19 +12,26 @@ @interface FLTGoogleMapMarkerController () @property(strong, nonatomic) GMSMarker *marker; @property(weak, nonatomic) GMSMapView *mapView; @property(assign, nonatomic, readwrite) BOOL consumeTapEvents; +/// The unique identifier for the cluster manager. +@property(copy, nonatomic, nullable) NSString *clusterManagerIdentifier; +/// The unique identifier for the marker. +@property(copy, nonatomic) NSString *markerIdentifier; @end @implementation FLTGoogleMapMarkerController -- (instancetype)initWithPosition:(CLLocationCoordinate2D)position - identifier:(NSString *)identifier - mapView:(GMSMapView *)mapView { +- (instancetype)initWithMarker:(GMSMarker *)marker + markerIdentifier:(NSString *)markerIdentifier + clusterManagerIdentifier:(nullable NSString *)clusterManagerIdentifier + mapView:(GMSMapView *)mapView { self = [super init]; if (self) { - _marker = [GMSMarker markerWithPosition:position]; + _marker = marker; + _markerIdentifier = [markerIdentifier copy]; + _clusterManagerIdentifier = [clusterManagerIdentifier copy]; _mapView = mapView; - _marker.userData = @[ identifier ]; + FGMSetIdentifiersToMarkerUserData(_markerIdentifier, _clusterManagerIdentifier, _marker); } return self; } @@ -83,7 +92,14 @@ - (void)setRotation:(CLLocationDegrees)rotation { } - (void)setVisible:(BOOL)visible { - self.marker.map = visible ? self.mapView : nil; + // If marker belongs the cluster manager, visibility need to be controlled with the opacity + // as the cluster manager controls when marker is on the map and when not. + // Alpha value for marker must always be interpreted before visibility value. + if (self.clusterManagerIdentifier) { + self.marker.opacity = visible ? self.marker.opacity : 0.0f; + } else { + self.marker.map = visible ? self.mapView : nil; + } } - (void)setZIndex:(int)zIndex { @@ -432,6 +448,8 @@ @interface FLTMarkersController () @property(strong, nonatomic) NSMutableDictionary *markerIdentifierToController; @property(strong, nonatomic) FGMMapsCallbackApi *callbackHandler; +/// Controller for adding/removing/fetching cluster managers +@property(weak, nonatomic, nullable) FGMClusterManagersController *clusterManagersController; @property(weak, nonatomic) NSObject *registrar; @property(weak, nonatomic) GMSMapView *mapView; @@ -441,11 +459,13 @@ @implementation FLTMarkersController - (instancetype)initWithMapView:(GMSMapView *)mapView callbackHandler:(FGMMapsCallbackApi *)callbackHandler + clusterManagersController:(nullable FGMClusterManagersController *)clusterManagersController registrar:(NSObject *)registrar { self = [super init]; if (self) { _callbackHandler = callbackHandler; _mapView = mapView; + _clusterManagersController = clusterManagersController; _markerIdentifierToController = [[NSMutableDictionary alloc] init]; _registrar = registrar; } @@ -454,39 +474,59 @@ - (instancetype)initWithMapView:(GMSMapView *)mapView - (void)addJSONMarkers:(NSArray *> *)markersToAdd { for (NSDictionary *marker in markersToAdd) { - CLLocationCoordinate2D position = [FLTMarkersController getPosition:marker]; - NSString *identifier = marker[@"markerId"]; - FLTGoogleMapMarkerController *controller = - [[FLTGoogleMapMarkerController alloc] initWithPosition:position - identifier:identifier - mapView:self.mapView]; - [controller interpretMarkerOptions:marker - registrar:self.registrar - screenScale:[self getScreenScale]]; - self.markerIdentifierToController[identifier] = controller; + [self addJSONMarker:marker]; } } - (void)addMarkers:(NSArray *)markersToAdd { for (FGMPlatformMarker *marker in markersToAdd) { - CLLocationCoordinate2D position = [FLTMarkersController getPosition:marker.json]; - NSString *identifier = marker.json[@"markerId"]; - FLTGoogleMapMarkerController *controller = - [[FLTGoogleMapMarkerController alloc] initWithPosition:position - identifier:identifier - mapView:self.mapView]; - [controller interpretMarkerOptions:marker.json - registrar:self.registrar - screenScale:[self getScreenScale]]; - self.markerIdentifierToController[identifier] = controller; + [self addJSONMarker:marker.json]; + } +} + +- (void)addJSONMarker:(NSDictionary *)markerToAdd { + CLLocationCoordinate2D position = [FLTMarkersController getPosition:markerToAdd]; + NSString *markerIdentifier = markerToAdd[@"markerId"]; + NSString *clusterManagerIdentifier = markerToAdd[@"clusterManagerId"]; + GMSMarker *marker = [GMSMarker markerWithPosition:position]; + FLTGoogleMapMarkerController *controller = + [[FLTGoogleMapMarkerController alloc] initWithMarker:marker + markerIdentifier:markerIdentifier + clusterManagerIdentifier:clusterManagerIdentifier + mapView:self.mapView]; + [controller interpretMarkerOptions:markerToAdd + registrar:self.registrar + screenScale:[self getScreenScale]]; + if (clusterManagerIdentifier) { + GMUClusterManager *clusterManager = + [_clusterManagersController clusterManagerWithIdentifier:clusterManagerIdentifier]; + if ([marker conformsToProtocol:@protocol(GMUClusterItem)]) { + [clusterManager addItem:(id)marker]; + } } + self.markerIdentifierToController[markerIdentifier] = controller; } - (void)changeMarkers:(NSArray *)markersToChange { for (FGMPlatformMarker *marker in markersToChange) { - NSString *identifier = marker.json[@"markerId"]; - FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; - [controller interpretMarkerOptions:marker.json + [self changeMarker:marker]; + } +} + +- (void)changeMarker:(FGMPlatformMarker *)markerToChange { + NSString *markerIdentifier = markerToChange.json[@"markerId"]; + NSString *clusterManagerIdentifier = markerToChange.json[@"clusterManagerId"]; + + FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[markerIdentifier]; + if (!controller) { + return; + } + NSString *previousClusterManagerIdentifier = [controller clusterManagerIdentifier]; + if (![previousClusterManagerIdentifier isEqualToString:clusterManagerIdentifier]) { + [self removeMarker:markerIdentifier]; + [self addJSONMarker:markerToChange.json]; + } else { + [controller interpretMarkerOptions:markerToChange.json registrar:self.registrar screenScale:[self getScreenScale]]; } @@ -494,13 +534,24 @@ - (void)changeMarkers:(NSArray *)markersToChange { - (void)removeMarkersWithIdentifiers:(NSArray *)identifiers { for (NSString *identifier in identifiers) { - FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; - if (!controller) { - continue; - } + [self removeMarker:identifier]; + } +} + +- (void)removeMarker:(NSString *)identifier { + FLTGoogleMapMarkerController *controller = self.markerIdentifierToController[identifier]; + if (!controller) { + return; + } + NSString *clusterManagerIdentifier = [controller clusterManagerIdentifier]; + if (clusterManagerIdentifier) { + GMUClusterManager *clusterManager = + [_clusterManagersController clusterManagerWithIdentifier:clusterManagerIdentifier]; + [clusterManager removeItem:(id)controller.marker]; + } else { [controller removeMarker]; - [self.markerIdentifierToController removeObjectForKey:identifier]; } + [self.markerIdentifierToController removeObjectForKey:identifier]; } - (BOOL)didTapMarkerWithIdentifier:(NSString *)identifier { diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h index de245e43bd3f..c88ee79c7a0d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/google_maps_flutter_ios-umbrella.h @@ -3,6 +3,7 @@ // found in the LICENSE file. #import +#import #import #import #import diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h index 7752ff0f4ae6..070c24e90e59 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.h @@ -17,6 +17,7 @@ NS_ASSUME_NONNULL_BEGIN @class FGMPlatformCameraUpdate; @class FGMPlatformCircle; @class FGMPlatformHeatmap; +@class FGMPlatformClusterManager; @class FGMPlatformMarker; @class FGMPlatformPolygon; @class FGMPlatformPolyline; @@ -24,6 +25,7 @@ NS_ASSUME_NONNULL_BEGIN @class FGMPlatformTileOverlay; @class FGMPlatformLatLng; @class FGMPlatformLatLngBounds; +@class FGMPlatformCluster; @class FGMPlatformMapConfiguration; @class FGMPlatformPoint; @class FGMPlatformTileLayer; @@ -76,6 +78,14 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, strong) id json; @end +/// Pigeon equivalent of the ClusterManager class. +@interface FGMPlatformClusterManager : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithIdentifier:(NSString *)identifier; +@property(nonatomic, copy) NSString *identifier; +@end + /// Pigeon equivalent of the Marker class. @interface FGMPlatformMarker : NSObject /// `init` unavailable to enforce nonnull fields, see the `make` class method. @@ -151,6 +161,20 @@ NS_ASSUME_NONNULL_BEGIN @property(nonatomic, strong) FGMPlatformLatLng *southwest; @end +/// Pigeon equivalent of Cluster. +@interface FGMPlatformCluster : NSObject +/// `init` unavailable to enforce nonnull fields, see the `make` class method. +- (instancetype)init NS_UNAVAILABLE; ++ (instancetype)makeWithClusterManagerId:(NSString *)clusterManagerId + position:(FGMPlatformLatLng *)position + bounds:(FGMPlatformLatLngBounds *)bounds + markerIds:(NSArray *)markerIds; +@property(nonatomic, copy) NSString *clusterManagerId; +@property(nonatomic, strong) FGMPlatformLatLng *position; +@property(nonatomic, strong) FGMPlatformLatLngBounds *bounds; +@property(nonatomic, copy) NSArray *markerIds; +@end + /// Pigeon equivalent of MapConfiguration. @interface FGMPlatformMapConfiguration : NSObject /// `init` unavailable to enforce nonnull fields, see the `make` class method. @@ -219,6 +243,10 @@ NSObject *FGMGetMessagesCodec(void); changing:(NSArray *)toChange removing:(NSArray *)idsToRemove error:(FlutterError *_Nullable *_Nonnull)error; +/// Updates the set of custer managers for clusters on the map. +- (void)updateClusterManagersByAdding:(NSArray *)toAdd + removing:(NSArray *)idsToRemove + error:(FlutterError *_Nullable *_Nonnull)error; /// Updates the set of markers on the map. - (void)updateMarkersByAdding:(NSArray *)toAdd changing:(NSArray *)toChange @@ -343,6 +371,9 @@ extern void SetUpFGMMapsApiWithSuffix(id binaryMessenger /// Called when a circle is tapped. - (void)didTapCircleWithIdentifier:(NSString *)circleId completion:(void (^)(FlutterError *_Nullable))completion; +/// Called when a marker cluster is tapped. +- (void)didTapCluster:(FGMPlatformCluster *)cluster + completion:(void (^)(FlutterError *_Nullable))completion; /// Called when a polygon is tapped. - (void)didTapPolygonWithIdentifier:(NSString *)polygonId completion:(void (^)(FlutterError *_Nullable))completion; @@ -382,6 +413,10 @@ extern void SetUpFGMMapsApiWithSuffix(id binaryMessenger error:(FlutterError *_Nullable *_Nonnull)error; /// @return `nil` only when `error != nil`. - (nullable FGMPlatformZoomRange *)zoomRange:(FlutterError *_Nullable *_Nonnull)error; +/// @return `nil` only when `error != nil`. +- (nullable NSArray *) + clustersWithIdentifier:(NSString *)clusterManagerId + error:(FlutterError *_Nullable *_Nonnull)error; @end extern void SetUpFGMMapsInspectorApi(id binaryMessenger, diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m index d541e4c8ade6..27c32b8b4cac 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/Classes/messages.g.m @@ -63,6 +63,12 @@ + (nullable FGMPlatformHeatmap *)nullableFromList:(NSArray *)list; - (NSArray *)toList; @end +@interface FGMPlatformClusterManager () ++ (FGMPlatformClusterManager *)fromList:(NSArray *)list; ++ (nullable FGMPlatformClusterManager *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @interface FGMPlatformMarker () + (FGMPlatformMarker *)fromList:(NSArray *)list; + (nullable FGMPlatformMarker *)nullableFromList:(NSArray *)list; @@ -105,6 +111,12 @@ + (nullable FGMPlatformLatLngBounds *)nullableFromList:(NSArray *)list; - (NSArray *)toList; @end +@interface FGMPlatformCluster () ++ (FGMPlatformCluster *)fromList:(NSArray *)list; ++ (nullable FGMPlatformCluster *)nullableFromList:(NSArray *)list; +- (NSArray *)toList; +@end + @interface FGMPlatformMapConfiguration () + (FGMPlatformMapConfiguration *)fromList:(NSArray *)list; + (nullable FGMPlatformMapConfiguration *)nullableFromList:(NSArray *)list; @@ -225,6 +237,27 @@ + (nullable FGMPlatformHeatmap *)nullableFromList:(NSArray *)list { } @end +@implementation FGMPlatformClusterManager ++ (instancetype)makeWithIdentifier:(NSString *)identifier { + FGMPlatformClusterManager *pigeonResult = [[FGMPlatformClusterManager alloc] init]; + pigeonResult.identifier = identifier; + return pigeonResult; +} ++ (FGMPlatformClusterManager *)fromList:(NSArray *)list { + FGMPlatformClusterManager *pigeonResult = [[FGMPlatformClusterManager alloc] init]; + pigeonResult.identifier = GetNullableObjectAtIndex(list, 0); + return pigeonResult; +} ++ (nullable FGMPlatformClusterManager *)nullableFromList:(NSArray *)list { + return (list) ? [FGMPlatformClusterManager fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.identifier ?: [NSNull null], + ]; +} +@end + @implementation FGMPlatformMarker + (instancetype)makeWithJson:(id)json { FGMPlatformMarker *pigeonResult = [[FGMPlatformMarker alloc] init]; @@ -387,6 +420,39 @@ + (nullable FGMPlatformLatLngBounds *)nullableFromList:(NSArray *)list { } @end +@implementation FGMPlatformCluster ++ (instancetype)makeWithClusterManagerId:(NSString *)clusterManagerId + position:(FGMPlatformLatLng *)position + bounds:(FGMPlatformLatLngBounds *)bounds + markerIds:(NSArray *)markerIds { + FGMPlatformCluster *pigeonResult = [[FGMPlatformCluster alloc] init]; + pigeonResult.clusterManagerId = clusterManagerId; + pigeonResult.position = position; + pigeonResult.bounds = bounds; + pigeonResult.markerIds = markerIds; + return pigeonResult; +} ++ (FGMPlatformCluster *)fromList:(NSArray *)list { + FGMPlatformCluster *pigeonResult = [[FGMPlatformCluster alloc] init]; + pigeonResult.clusterManagerId = GetNullableObjectAtIndex(list, 0); + pigeonResult.position = GetNullableObjectAtIndex(list, 1); + pigeonResult.bounds = GetNullableObjectAtIndex(list, 2); + pigeonResult.markerIds = GetNullableObjectAtIndex(list, 3); + return pigeonResult; +} ++ (nullable FGMPlatformCluster *)nullableFromList:(NSArray *)list { + return (list) ? [FGMPlatformCluster fromList:list] : nil; +} +- (NSArray *)toList { + return @[ + self.clusterManagerId ?: [NSNull null], + self.position ?: [NSNull null], + self.bounds ?: [NSNull null], + self.markerIds ?: [NSNull null], + ]; +} +@end + @implementation FGMPlatformMapConfiguration + (instancetype)makeWithJson:(id)json { FGMPlatformMapConfiguration *pigeonResult = [[FGMPlatformMapConfiguration alloc] init]; @@ -503,26 +569,30 @@ - (nullable id)readValueOfType:(UInt8)type { case 132: return [FGMPlatformHeatmap fromList:[self readValue]]; case 133: - return [FGMPlatformMarker fromList:[self readValue]]; + return [FGMPlatformClusterManager fromList:[self readValue]]; case 134: - return [FGMPlatformPolygon fromList:[self readValue]]; + return [FGMPlatformMarker fromList:[self readValue]]; case 135: - return [FGMPlatformPolyline fromList:[self readValue]]; + return [FGMPlatformPolygon fromList:[self readValue]]; case 136: - return [FGMPlatformTile fromList:[self readValue]]; + return [FGMPlatformPolyline fromList:[self readValue]]; case 137: - return [FGMPlatformTileOverlay fromList:[self readValue]]; + return [FGMPlatformTile fromList:[self readValue]]; case 138: - return [FGMPlatformLatLng fromList:[self readValue]]; + return [FGMPlatformTileOverlay fromList:[self readValue]]; case 139: - return [FGMPlatformLatLngBounds fromList:[self readValue]]; + return [FGMPlatformLatLng fromList:[self readValue]]; case 140: - return [FGMPlatformMapConfiguration fromList:[self readValue]]; + return [FGMPlatformLatLngBounds fromList:[self readValue]]; case 141: - return [FGMPlatformPoint fromList:[self readValue]]; + return [FGMPlatformCluster fromList:[self readValue]]; case 142: - return [FGMPlatformTileLayer fromList:[self readValue]]; + return [FGMPlatformMapConfiguration fromList:[self readValue]]; case 143: + return [FGMPlatformPoint fromList:[self readValue]]; + case 144: + return [FGMPlatformTileLayer fromList:[self readValue]]; + case 145: return [FGMPlatformZoomRange fromList:[self readValue]]; default: return [super readValueOfType:type]; @@ -546,39 +616,45 @@ - (void)writeValue:(id)value { } else if ([value isKindOfClass:[FGMPlatformHeatmap class]]) { [self writeByte:132]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformMarker class]]) { + } else if ([value isKindOfClass:[FGMPlatformClusterManager class]]) { [self writeByte:133]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformPolygon class]]) { + } else if ([value isKindOfClass:[FGMPlatformMarker class]]) { [self writeByte:134]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformPolyline class]]) { + } else if ([value isKindOfClass:[FGMPlatformPolygon class]]) { [self writeByte:135]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformTile class]]) { + } else if ([value isKindOfClass:[FGMPlatformPolyline class]]) { [self writeByte:136]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformTileOverlay class]]) { + } else if ([value isKindOfClass:[FGMPlatformTile class]]) { [self writeByte:137]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformLatLng class]]) { + } else if ([value isKindOfClass:[FGMPlatformTileOverlay class]]) { [self writeByte:138]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformLatLngBounds class]]) { + } else if ([value isKindOfClass:[FGMPlatformLatLng class]]) { [self writeByte:139]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformMapConfiguration class]]) { + } else if ([value isKindOfClass:[FGMPlatformLatLngBounds class]]) { [self writeByte:140]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformPoint class]]) { + } else if ([value isKindOfClass:[FGMPlatformCluster class]]) { [self writeByte:141]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformTileLayer class]]) { + } else if ([value isKindOfClass:[FGMPlatformMapConfiguration class]]) { [self writeByte:142]; [self writeValue:[value toList]]; - } else if ([value isKindOfClass:[FGMPlatformZoomRange class]]) { + } else if ([value isKindOfClass:[FGMPlatformPoint class]]) { [self writeByte:143]; [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FGMPlatformTileLayer class]]) { + [self writeByte:144]; + [self writeValue:[value toList]]; + } else if ([value isKindOfClass:[FGMPlatformZoomRange class]]) { + [self writeByte:145]; + [self writeValue:[value toList]]; } else { [super writeValue:value]; } @@ -726,6 +802,32 @@ void SetUpFGMMapsApiWithSuffix(id binaryMessenger, [channel setMessageHandler:nil]; } } + /// Updates the set of custer managers for clusters on the map. + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_maps_flutter_ios." + @"MapsApi.updateClusterManagers", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FGMGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(updateClusterManagersByAdding:removing:error:)], + @"FGMMapsApi api (%@) doesn't respond to " + @"@selector(updateClusterManagersByAdding:removing:error:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSArray *arg_toAdd = GetNullableObjectAtIndex(args, 0); + NSArray *arg_idsToRemove = GetNullableObjectAtIndex(args, 1); + FlutterError *error; + [api updateClusterManagersByAdding:arg_toAdd removing:arg_idsToRemove error:&error]; + callback(wrapResult(nil, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } /// Updates the set of markers on the map. { FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] @@ -1471,6 +1573,31 @@ - (void)didTapCircleWithIdentifier:(NSString *)arg_circleId } }]; } +- (void)didTapCluster:(FGMPlatformCluster *)arg_cluster + completion:(void (^)(FlutterError *_Nullable))completion { + NSString *channelName = [NSString + stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_maps_flutter_ios.MapsCallbackApi.onClusterTap", + _messageChannelSuffix]; + FlutterBasicMessageChannel *channel = + [FlutterBasicMessageChannel messageChannelWithName:channelName + binaryMessenger:self.binaryMessenger + codec:FGMGetMessagesCodec()]; + [channel sendMessage:@[ arg_cluster ?: [NSNull null] ] + reply:^(NSArray *reply) { + if (reply != nil) { + if (reply.count > 1) { + completion([FlutterError errorWithCode:reply[0] + message:reply[1] + details:reply[2]]); + } else { + completion(nil); + } + } else { + completion(createConnectionError(channelName)); + } + }]; +} - (void)didTapPolygonWithIdentifier:(NSString *)arg_polygonId completion:(void (^)(FlutterError *_Nullable))completion { NSString *channelName = [NSString @@ -1810,4 +1937,29 @@ void SetUpFGMMapsInspectorApiWithSuffix(id binaryMesseng [channel setMessageHandler:nil]; } } + { + FlutterBasicMessageChannel *channel = [[FlutterBasicMessageChannel alloc] + initWithName:[NSString stringWithFormat:@"%@%@", + @"dev.flutter.pigeon.google_maps_flutter_ios." + @"MapsInspectorApi.getClusters", + messageChannelSuffix] + binaryMessenger:binaryMessenger + codec:FGMGetMessagesCodec()]; + if (api) { + NSCAssert([api respondsToSelector:@selector(clustersWithIdentifier:error:)], + @"FGMMapsInspectorApi api (%@) doesn't respond to " + @"@selector(clustersWithIdentifier:error:)", + api); + [channel setMessageHandler:^(id _Nullable message, FlutterReply callback) { + NSArray *args = message; + NSString *arg_clusterManagerId = GetNullableObjectAtIndex(args, 0); + FlutterError *error; + NSArray *output = [api clustersWithIdentifier:arg_clusterManagerId + error:&error]; + callback(wrapResult(output, error)); + }]; + } else { + [channel setMessageHandler:nil]; + } + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec index 2715a7dcb155..f744daa68537 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec +++ b/packages/google_maps_flutter/google_maps_flutter_ios/ios/google_maps_flutter_ios.podspec @@ -32,7 +32,7 @@ Downloaded by pub (not CocoaPods). s.platform = :ios, '14.0' # "Google-Maps-iOS-Utils" is static and contains Swift classes. # Find the Swift runtime when these plugins are built as libraries without `use_frameworks!` - s.swift_version = '5.0' + s.swift_version = '5.9' s.xcconfig = { 'LIBRARY_SEARCH_PATHS' => '$(inherited) $(TOOLCHAIN_DIR)/usr/lib/swift/$(PLATFORM_NAME)/ $(SDKROOT)/usr/lib/swift', 'LD_RUNPATH_SEARCH_PATHS' => '$(inherited) /usr/lib/swift', diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart index 35a5e73c30fd..9d8ddadf192c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_map_inspector_ios.dart @@ -5,6 +5,7 @@ import 'package:flutter/foundation.dart'; import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; +import 'google_maps_flutter_ios.dart'; import 'messages.g.dart'; import 'serialization.dart'; @@ -128,4 +129,17 @@ class GoogleMapsInspectorIOS extends GoogleMapsInspectorPlatform { Future isTrafficEnabled({required int mapId}) async { return _inspectorProvider(mapId)!.isTrafficEnabled(); } + + @override + Future> getClusters({ + required int mapId, + required ClusterManagerId clusterManagerId, + }) async { + return (await _inspectorProvider(mapId)! + .getClusters(clusterManagerId.value)) + // See comment in messages.dart for why the force unwrap is okay. + .map((PlatformCluster? cluster) => + GoogleMapsFlutterIOS.clusterFromPlatformCluster(cluster!)) + .toList(); + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart index 483b1e7b19d5..937e34d72d2d 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/google_maps_flutter_ios.dart @@ -14,6 +14,7 @@ import 'package:stream_transform/stream_transform.dart'; import 'google_map_inspector_ios.dart'; import 'messages.g.dart'; import 'serialization.dart'; +import 'utils/cluster_manager.dart'; // TODO(stuartmorgan): Remove the dependency on platform interface toJson // methods. Channel serialization details should all be package-internal. @@ -200,6 +201,11 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { return _events(mapId).whereType(); } + @override + Stream onClusterTap({required int mapId}) { + return _events(mapId).whereType(); + } + @override Future updateMapConfiguration( MapConfiguration configuration, { @@ -314,6 +320,21 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { ); } + @override + Future updateClusterManagers( + ClusterManagerUpdates clusterManagerUpdates, { + required int mapId, + }) { + return _hostApi(mapId).updateClusterManagers( + clusterManagerUpdates.clusterManagersToAdd + .map(_platformClusterManagerFromClusterManager) + .toList(), + clusterManagerUpdates.clusterManagerIdsToRemove + .map((ClusterManagerId id) => id.value) + .toList(), + ); + } + @override Future clearTileCache( TileOverlayId tileOverlayId, { @@ -438,6 +459,8 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { 'circlesToAdd': serializeCircleSet(mapObjects.circles), 'heatmapsToAdd': mapObjects.heatmaps.map(serializeHeatmap).toList(), 'tileOverlaysToAdd': serializeTileOverlaySet(mapObjects.tileOverlays), + 'clusterManagersToAdd': + serializeClusterManagerSet(mapObjects.clusterManagers), }; return UiKitView( @@ -531,6 +554,18 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { MapsInspectorApi(messageChannelSuffix: mapId.toString())); } + /// Converts a Pigeon [PlatformCluster] to the corresponding [Cluster]. + static Cluster clusterFromPlatformCluster(PlatformCluster cluster) { + return Cluster( + ClusterManagerId(cluster.clusterManagerId), + cluster.markerIds + // See comment in messages.dart for why the force unwrap is okay. + .map((String? markerId) => MarkerId(markerId!)) + .toList(), + position: _latLngFromPlatformLatLng(cluster.position), + bounds: _latLngBoundsFromPlatformLatLngBounds(cluster.bounds)); + } + static PlatformLatLng _platformLatLngFromLatLng(LatLng latLng) { return PlatformLatLng( latitude: latLng.latitude, longitude: latLng.longitude); @@ -571,6 +606,12 @@ class GoogleMapsFlutterIOS extends GoogleMapsFlutterPlatform { TileOverlay tileOverlay) { return PlatformTileOverlay(json: tileOverlay.toJson()); } + + static PlatformClusterManager _platformClusterManagerFromClusterManager( + ClusterManager clusterManager) { + return PlatformClusterManager( + identifier: clusterManager.clusterManagerId.value); + } } /// Callback handler for map events from the platform host. @@ -645,6 +686,14 @@ class HostMapMessageHandler implements MapsCallbackApi { streamController.add(CircleTapEvent(mapId, CircleId(circleId))); } + @override + void onClusterTap(PlatformCluster cluster) { + streamController.add(ClusterTapEvent( + mapId, + GoogleMapsFlutterIOS.clusterFromPlatformCluster(cluster), + )); + } + @override void onInfoWindowTap(String markerId) { streamController.add(InfoWindowTapEvent(mapId, MarkerId(markerId))); diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart index e5041ef24e15..3e164bf2d88c 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/messages.g.dart @@ -141,6 +141,28 @@ class PlatformHeatmap { } } +/// Pigeon equivalent of the ClusterManager class. +class PlatformClusterManager { + PlatformClusterManager({ + required this.identifier, + }); + + String identifier; + + Object encode() { + return [ + identifier, + ]; + } + + static PlatformClusterManager decode(Object result) { + result as List; + return PlatformClusterManager( + identifier: result[0]! as String, + ); + } +} + /// Pigeon equivalent of the Marker class. class PlatformMarker { PlatformMarker({ @@ -327,6 +349,43 @@ class PlatformLatLngBounds { } } +/// Pigeon equivalent of Cluster. +class PlatformCluster { + PlatformCluster({ + required this.clusterManagerId, + required this.position, + required this.bounds, + required this.markerIds, + }); + + String clusterManagerId; + + PlatformLatLng position; + + PlatformLatLngBounds bounds; + + List markerIds; + + Object encode() { + return [ + clusterManagerId, + position, + bounds, + markerIds, + ]; + } + + static PlatformCluster decode(Object result) { + result as List; + return PlatformCluster( + clusterManagerId: result[0]! as String, + position: result[1]! as PlatformLatLng, + bounds: result[2]! as PlatformLatLngBounds, + markerIds: (result[3] as List?)!.cast(), + ); + } +} + /// Pigeon equivalent of MapConfiguration. class PlatformMapConfiguration { PlatformMapConfiguration({ @@ -459,39 +518,45 @@ class _PigeonCodec extends StandardMessageCodec { } else if (value is PlatformHeatmap) { buffer.putUint8(132); writeValue(buffer, value.encode()); - } else if (value is PlatformMarker) { + } else if (value is PlatformClusterManager) { buffer.putUint8(133); writeValue(buffer, value.encode()); - } else if (value is PlatformPolygon) { + } else if (value is PlatformMarker) { buffer.putUint8(134); writeValue(buffer, value.encode()); - } else if (value is PlatformPolyline) { + } else if (value is PlatformPolygon) { buffer.putUint8(135); writeValue(buffer, value.encode()); - } else if (value is PlatformTile) { + } else if (value is PlatformPolyline) { buffer.putUint8(136); writeValue(buffer, value.encode()); - } else if (value is PlatformTileOverlay) { + } else if (value is PlatformTile) { buffer.putUint8(137); writeValue(buffer, value.encode()); - } else if (value is PlatformLatLng) { + } else if (value is PlatformTileOverlay) { buffer.putUint8(138); writeValue(buffer, value.encode()); - } else if (value is PlatformLatLngBounds) { + } else if (value is PlatformLatLng) { buffer.putUint8(139); writeValue(buffer, value.encode()); - } else if (value is PlatformMapConfiguration) { + } else if (value is PlatformLatLngBounds) { buffer.putUint8(140); writeValue(buffer, value.encode()); - } else if (value is PlatformPoint) { + } else if (value is PlatformCluster) { buffer.putUint8(141); writeValue(buffer, value.encode()); - } else if (value is PlatformTileLayer) { + } else if (value is PlatformMapConfiguration) { buffer.putUint8(142); writeValue(buffer, value.encode()); - } else if (value is PlatformZoomRange) { + } else if (value is PlatformPoint) { buffer.putUint8(143); writeValue(buffer, value.encode()); + } else if (value is PlatformTileLayer) { + buffer.putUint8(144); + writeValue(buffer, value.encode()); + } else if (value is PlatformZoomRange) { + buffer.putUint8(145); + writeValue(buffer, value.encode()); } else { super.writeValue(buffer, value); } @@ -509,26 +574,30 @@ class _PigeonCodec extends StandardMessageCodec { case 132: return PlatformHeatmap.decode(readValue(buffer)!); case 133: - return PlatformMarker.decode(readValue(buffer)!); + return PlatformClusterManager.decode(readValue(buffer)!); case 134: - return PlatformPolygon.decode(readValue(buffer)!); + return PlatformMarker.decode(readValue(buffer)!); case 135: - return PlatformPolyline.decode(readValue(buffer)!); + return PlatformPolygon.decode(readValue(buffer)!); case 136: - return PlatformTile.decode(readValue(buffer)!); + return PlatformPolyline.decode(readValue(buffer)!); case 137: - return PlatformTileOverlay.decode(readValue(buffer)!); + return PlatformTile.decode(readValue(buffer)!); case 138: - return PlatformLatLng.decode(readValue(buffer)!); + return PlatformTileOverlay.decode(readValue(buffer)!); case 139: - return PlatformLatLngBounds.decode(readValue(buffer)!); + return PlatformLatLng.decode(readValue(buffer)!); case 140: - return PlatformMapConfiguration.decode(readValue(buffer)!); + return PlatformLatLngBounds.decode(readValue(buffer)!); case 141: - return PlatformPoint.decode(readValue(buffer)!); + return PlatformCluster.decode(readValue(buffer)!); case 142: - return PlatformTileLayer.decode(readValue(buffer)!); + return PlatformMapConfiguration.decode(readValue(buffer)!); case 143: + return PlatformPoint.decode(readValue(buffer)!); + case 144: + return PlatformTileLayer.decode(readValue(buffer)!); + case 145: return PlatformZoomRange.decode(readValue(buffer)!); default: return super.readValueOfType(type, buffer); @@ -659,6 +728,32 @@ class MapsApi { } } + /// Updates the set of custer managers for clusters on the map. + Future updateClusterManagers( + List toAdd, List idsToRemove) async { + final String __pigeon_channelName = + 'dev.flutter.pigeon.google_maps_flutter_ios.MapsApi.updateClusterManagers$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = + BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = await __pigeon_channel + .send([toAdd, idsToRemove]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else { + return; + } + } + /// Updates the set of markers on the map. Future updateMarkers(List toAdd, List toChange, List idsToRemove) async { @@ -1161,6 +1256,9 @@ abstract class MapsCallbackApi { /// Called when a circle is tapped. void onCircleTap(String circleId); + /// Called when a marker cluster is tapped. + void onClusterTap(PlatformCluster cluster); + /// Called when a polygon is tapped. void onPolygonTap(String polygonId); @@ -1484,6 +1582,34 @@ abstract class MapsCallbackApi { }); } } + { + final BasicMessageChannel __pigeon_channel = BasicMessageChannel< + Object?>( + 'dev.flutter.pigeon.google_maps_flutter_ios.MapsCallbackApi.onClusterTap$messageChannelSuffix', + pigeonChannelCodec, + binaryMessenger: binaryMessenger); + if (api == null) { + __pigeon_channel.setMessageHandler(null); + } else { + __pigeon_channel.setMessageHandler((Object? message) async { + assert(message != null, + 'Argument for dev.flutter.pigeon.google_maps_flutter_ios.MapsCallbackApi.onClusterTap was null.'); + final List args = (message as List?)!; + final PlatformCluster? arg_cluster = (args[0] as PlatformCluster?); + assert(arg_cluster != null, + 'Argument for dev.flutter.pigeon.google_maps_flutter_ios.MapsCallbackApi.onClusterTap was null, expected non-null PlatformCluster.'); + try { + api.onClusterTap(arg_cluster!); + return wrapResponse(empty: true); + } on PlatformException catch (e) { + return wrapResponse(error: e); + } catch (e) { + return wrapResponse( + error: PlatformException(code: 'error', message: e.toString())); + } + }); + } + } { final BasicMessageChannel __pigeon_channel = BasicMessageChannel< Object?>( @@ -1902,4 +2028,34 @@ class MapsInspectorApi { return (__pigeon_replyList[0] as PlatformZoomRange?)!; } } + + Future> getClusters(String clusterManagerId) async { + final String __pigeon_channelName = + 'dev.flutter.pigeon.google_maps_flutter_ios.MapsInspectorApi.getClusters$__pigeon_messageChannelSuffix'; + final BasicMessageChannel __pigeon_channel = + BasicMessageChannel( + __pigeon_channelName, + pigeonChannelCodec, + binaryMessenger: __pigeon_binaryMessenger, + ); + final List? __pigeon_replyList = await __pigeon_channel + .send([clusterManagerId]) as List?; + if (__pigeon_replyList == null) { + throw _createConnectionError(__pigeon_channelName); + } else if (__pigeon_replyList.length > 1) { + throw PlatformException( + code: __pigeon_replyList[0]! as String, + message: __pigeon_replyList[1] as String?, + details: __pigeon_replyList[2], + ); + } else if (__pigeon_replyList[0] == null) { + throw PlatformException( + code: 'null-error', + message: 'Host platform returned null value for non-null return value.', + ); + } else { + return (__pigeon_replyList[0] as List?)! + .cast(); + } + } } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart new file mode 100644 index 000000000000..d644bfd8c0ef --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/lib/src/utils/cluster_manager.dart @@ -0,0 +1,19 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +/// Converts a Set of Cluster Managers into object serializable in JSON. +Object serializeClusterManagerSet(Set clusterManagers) { + return clusterManagers + .map((ClusterManager cm) => serializeClusterManager(cm)) + .toList(); +} + +/// Converts a Cluster Manager into object serializable in JSON. +Object serializeClusterManager(ClusterManager clusterManager) { + final Map json = {}; + json['clusterManagerId'] = clusterManager.clusterManagerId.value; + return json; +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart b/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart index cd6cab64c914..e5c11bed6457 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pigeons/messages.dart @@ -66,6 +66,13 @@ class PlatformHeatmap { final Object json; } +/// Pigeon equivalent of the ClusterManager class. +class PlatformClusterManager { + PlatformClusterManager({required this.identifier}); + + final String identifier; +} + /// Pigeon equivalent of the Marker class. class PlatformMarker { PlatformMarker(this.json); @@ -139,6 +146,24 @@ class PlatformLatLngBounds { final PlatformLatLng southwest; } +/// Pigeon equivalent of Cluster. +class PlatformCluster { + PlatformCluster({ + required this.clusterManagerId, + required this.position, + required this.bounds, + required this.markerIds, + }); + + final String clusterManagerId; + final PlatformLatLng position; + final PlatformLatLngBounds bounds; + // TODO(stuartmorgan): Make the generic type non-nullable once supported. + // https://github.com/flutter/flutter/issues/97848 + // The consuming code treats the entries as non-nullable. + final List markerIds; +} + /// Pigeon equivalent of MapConfiguration. class PlatformMapConfiguration { PlatformMapConfiguration({required this.json}); @@ -213,6 +238,14 @@ abstract class MapsApi { void updateHeatmaps(List toAdd, List toChange, List idsToRemove); + /// Updates the set of custer managers for clusters on the map. + // TODO(stuartmorgan): Make the generic type non-nullable once supported. + // https://github.com/flutter/flutter/issues/97848 + // The consuming code treats the entries as non-nullable. + @ObjCSelector('updateClusterManagersByAdding:removing:') + void updateClusterManagers( + List toAdd, List idsToRemove); + /// Updates the set of markers on the map. // TODO(stuartmorgan): Make the generic type non-nullable once supported. // https://github.com/flutter/flutter/issues/97848 @@ -354,6 +387,10 @@ abstract class MapsCallbackApi { @ObjCSelector('didTapCircleWithIdentifier:') void onCircleTap(String circleId); + /// Called when a marker cluster is tapped. + @ObjCSelector('didTapCluster:') + void onClusterTap(PlatformCluster cluster); + /// Called when a polygon is tapped. @ObjCSelector('didTapPolygonWithIdentifier:') void onPolygonTap(String polygonId); @@ -386,4 +423,9 @@ abstract class MapsInspectorApi { PlatformHeatmap? getHeatmapInfo(String heatmapId); @ObjCSelector('zoomRange') PlatformZoomRange getZoomRange(); + // TODO(stuartmorgan): Make the generic type non-nullable once supported. + // https://github.com/flutter/flutter/issues/97848 + // The consuming code treats the entries as non-nullable. + @ObjCSelector('clustersWithIdentifier:') + List getClusters(String clusterManagerId); } diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml index 2ba981a262bc..273cd24a4759 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml +++ b/packages/google_maps_flutter/google_maps_flutter_ios/pubspec.yaml @@ -2,7 +2,7 @@ name: google_maps_flutter_ios description: iOS implementation of the google_maps_flutter plugin. repository: https://github.com/flutter/packages/tree/main/packages/google_maps_flutter/google_maps_flutter_ios issue_tracker: https://github.com/flutter/flutter/issues?q=is%3Aissue+is%3Aopen+label%3A%22p%3A+maps%22 -version: 2.11.0 +version: 2.12.0 environment: sdk: ^3.2.3 diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/test/cluster_manager_utils_test.dart b/packages/google_maps_flutter/google_maps_flutter_ios/test/cluster_manager_utils_test.dart new file mode 100644 index 000000000000..d7ea7c0379f0 --- /dev/null +++ b/packages/google_maps_flutter/google_maps_flutter_ios/test/cluster_manager_utils_test.dart @@ -0,0 +1,44 @@ +// Copyright 2013 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter_test/flutter_test.dart'; +import 'package:google_maps_flutter_ios/src/utils/cluster_manager.dart'; +import 'package:google_maps_flutter_platform_interface/google_maps_flutter_platform_interface.dart'; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + + test('serializeClusterManager', () async { + const ClusterManager manager = + ClusterManager(clusterManagerId: ClusterManagerId('1234')); + final Object json = serializeClusterManager(manager); + + expect(json, { + 'clusterManagerId': '1234', + }); + }); + + test('serializeClusterManagerSet', () async { + const ClusterManager manager = + ClusterManager(clusterManagerId: ClusterManagerId('1234')); + const ClusterManager manager2 = + ClusterManager(clusterManagerId: ClusterManagerId('5678')); + const ClusterManager manager3 = + ClusterManager(clusterManagerId: ClusterManagerId('9012')); + final Object json = serializeClusterManagerSet( + {manager, manager2, manager3}); + + expect(json, [ + { + 'clusterManagerId': '1234', + }, + { + 'clusterManagerId': '5678', + }, + { + 'clusterManagerId': '9012', + } + ]); + }); +} diff --git a/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart b/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart index c8f9ca752fac..c82012987a0a 100644 --- a/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart +++ b/packages/google_maps_flutter/google_maps_flutter_ios/test/google_maps_flutter_ios_test.mocks.dart @@ -118,6 +118,23 @@ class MockMapsApi extends _i1.Mock implements _i2.MapsApi { returnValueForMissingStub: _i3.Future.value(), ) as _i3.Future); + @override + _i3.Future updateClusterManagers( + List<_i2.PlatformClusterManager?>? toAdd, + List? idsToRemove, + ) => + (super.noSuchMethod( + Invocation.method( + #updateClusterManagers, + [ + toAdd, + idsToRemove, + ], + ), + returnValue: _i3.Future.value(), + returnValueForMissingStub: _i3.Future.value(), + ) as _i3.Future); + @override _i3.Future updateMarkers( List<_i2.PlatformMarker?>? toAdd,