Skip to content

Commit ec92d7d

Browse files
authored
[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: #4319 and sequel PR for: #6158 Containing only changes to `google_maps_flutter_ios` package. Follow up PR will hold the app-facing plugin implementation. Linked issue: flutter/flutter#26863
1 parent 4354f65 commit ec92d7d

32 files changed

+1585
-96
lines changed

packages/google_maps_flutter/google_maps_flutter_ios/CHANGELOG.md

+4
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,7 @@
1+
## 2.12.0
2+
3+
* Adds support for marker clustering.
4+
15
## 2.11.0
26

37
* Adds support for heatmap layers.

packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/integration_test/google_maps_test.dart

+102
Original file line numberDiff line numberDiff line change
@@ -1030,6 +1030,85 @@ void main() {
10301030
},
10311031
);
10321032

1033+
testWidgets('marker clustering', (WidgetTester tester) async {
1034+
final Key key = GlobalKey();
1035+
const int clusterManagersAmount = 2;
1036+
const int markersPerClusterManager = 5;
1037+
final Map<MarkerId, Marker> markers = <MarkerId, Marker>{};
1038+
final Set<ClusterManager> clusterManagers = <ClusterManager>{};
1039+
1040+
for (int i = 0; i < clusterManagersAmount; i++) {
1041+
final ClusterManagerId clusterManagerId =
1042+
ClusterManagerId('cluster_manager_$i');
1043+
final ClusterManager clusterManager =
1044+
ClusterManager(clusterManagerId: clusterManagerId);
1045+
clusterManagers.add(clusterManager);
1046+
}
1047+
1048+
for (final ClusterManager cm in clusterManagers) {
1049+
for (int i = 0; i < markersPerClusterManager; i++) {
1050+
final MarkerId markerId =
1051+
MarkerId('${cm.clusterManagerId.value}_marker_$i');
1052+
final Marker marker = Marker(
1053+
markerId: markerId,
1054+
clusterManagerId: cm.clusterManagerId,
1055+
position: LatLng(
1056+
_kInitialMapCenter.latitude + i, _kInitialMapCenter.longitude));
1057+
markers[markerId] = marker;
1058+
}
1059+
}
1060+
1061+
final Completer<ExampleGoogleMapController> controllerCompleter =
1062+
Completer<ExampleGoogleMapController>();
1063+
1064+
final GoogleMapsInspectorPlatform inspector =
1065+
GoogleMapsInspectorPlatform.instance!;
1066+
1067+
await tester.pumpWidget(Directionality(
1068+
textDirection: TextDirection.ltr,
1069+
child: ExampleGoogleMap(
1070+
key: key,
1071+
initialCameraPosition: _kInitialCameraPosition,
1072+
clusterManagers: clusterManagers,
1073+
markers: Set<Marker>.of(markers.values),
1074+
onMapCreated: (ExampleGoogleMapController googleMapController) {
1075+
controllerCompleter.complete(googleMapController);
1076+
},
1077+
),
1078+
));
1079+
1080+
final ExampleGoogleMapController controller =
1081+
await controllerCompleter.future;
1082+
1083+
for (final ClusterManager cm in clusterManagers) {
1084+
final List<Cluster> clusters = await inspector.getClusters(
1085+
mapId: controller.mapId, clusterManagerId: cm.clusterManagerId);
1086+
final int markersAmountForClusterManager = clusters
1087+
.map<int>((Cluster cluster) => cluster.count)
1088+
.reduce((int value, int element) => value + element);
1089+
expect(markersAmountForClusterManager, markersPerClusterManager);
1090+
}
1091+
1092+
// Remove markers from clusterManagers and test that clusterManagers are empty.
1093+
for (final MapEntry<MarkerId, Marker> entry in markers.entries) {
1094+
markers[entry.key] = _copyMarkerWithClusterManagerId(entry.value, null);
1095+
}
1096+
await tester.pumpWidget(Directionality(
1097+
textDirection: TextDirection.ltr,
1098+
child: ExampleGoogleMap(
1099+
key: key,
1100+
initialCameraPosition: _kInitialCameraPosition,
1101+
clusterManagers: clusterManagers,
1102+
markers: Set<Marker>.of(markers.values)),
1103+
));
1104+
1105+
for (final ClusterManager cm in clusterManagers) {
1106+
final List<Cluster> clusters = await inspector.getClusters(
1107+
mapId: controller.mapId, clusterManagerId: cm.clusterManagerId);
1108+
expect(clusters.length, 0);
1109+
}
1110+
});
1111+
10331112
testWidgets('testSetStyleMapId', (WidgetTester tester) async {
10341113
final Key key = GlobalKey();
10351114

@@ -1254,3 +1333,26 @@ class _DebugTileProvider implements TileProvider {
12541333
return Tile(width, height, byteData);
12551334
}
12561335
}
1336+
1337+
Marker _copyMarkerWithClusterManagerId(
1338+
Marker marker, ClusterManagerId? clusterManagerId) {
1339+
return Marker(
1340+
markerId: marker.markerId,
1341+
alpha: marker.alpha,
1342+
anchor: marker.anchor,
1343+
consumeTapEvents: marker.consumeTapEvents,
1344+
draggable: marker.draggable,
1345+
flat: marker.flat,
1346+
icon: marker.icon,
1347+
infoWindow: marker.infoWindow,
1348+
position: marker.position,
1349+
rotation: marker.rotation,
1350+
visible: marker.visible,
1351+
zIndex: marker.zIndex,
1352+
onTap: marker.onTap,
1353+
onDragStart: marker.onDragStart,
1354+
onDrag: marker.onDrag,
1355+
onDragEnd: marker.onDragEnd,
1356+
clusterManagerId: clusterManagerId,
1357+
);
1358+
}

packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/ios/Runner.xcodeproj/project.pbxproj

+9-5
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,8 @@
1212
2BDE99378062AE3E60B40021 /* Pods_RunnerTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */; };
1313
3B3967161E833CAA004F5970 /* AppFrameworkInfo.plist in Resources */ = {isa = PBXBuildFile; fileRef = 3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */; };
1414
478116522BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */; };
15-
521AB0032B876A76005F460D /* ExtractIconFromDataTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */; };
15+
528F16832C62941000148160 /* FGMClusterManagersControllerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */; };
16+
528F16872C62952700148160 /* ExtractIconFromDataTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 528F16862C62952700148160 /* ExtractIconFromDataTests.m */; };
1617
6851F3562835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */; };
1718
68E4726A2836FF0C00BDDDAC /* MapKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 68E472692836FF0C00BDDDAC /* MapKit.framework */; };
1819
978B8F6F1D3862AE00F588F7 /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 7AFFD8EE1D35381100E5BB4D /* AppDelegate.m */; };
@@ -64,7 +65,8 @@
6465
3ACE0AFE8D82CD5962486AFD /* Pods_RunnerTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_RunnerTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6566
3B3967151E833CAA004F5970 /* AppFrameworkInfo.plist */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.plist.xml; name = AppFrameworkInfo.plist; path = Flutter/AppFrameworkInfo.plist; sourceTree = "<group>"; };
6667
478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsPolylinesControllerTests.m; sourceTree = "<group>"; };
67-
521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = "<group>"; };
68+
528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FGMClusterManagersControllerTests.m; sourceTree = "<group>"; };
69+
528F16862C62952700148160 /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = "<group>"; };
6870
61A9A8623F5CA9BBC813DC6B /* Pods_Runner.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Runner.framework; sourceTree = BUILT_PRODUCTS_DIR; };
6971
6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FLTGoogleMapJSONConversionsConversionTests.m; sourceTree = "<group>"; };
7072
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 @@
205207
isa = PBXGroup;
206208
children = (
207209
F269303A2BB389BF00BF17C4 /* assets */,
210+
528F16862C62952700148160 /* ExtractIconFromDataTests.m */,
211+
528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */,
208212
6851F3552835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m */,
209-
521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */,
213+
0DD7B6C22B744EEF00E857FD /* FLTTileProviderControllerTests.m */,
210214
F7151F12265D7ED70028CB91 /* GoogleMapsTests.m */,
211215
478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */,
212216
982F2A6A27BADE17003C81F4 /* PartiallyMockedMapView.h */,
213217
982F2A6B27BADE17003C81F4 /* PartiallyMockedMapView.m */,
214218
F7151F14265D7ED70028CB91 /* Info.plist */,
215-
0DD7B6C22B744EEF00E857FD /* FLTTileProviderControllerTests.m */,
216219
);
217220
path = RunnerTests;
218221
sourceTree = "<group>";
@@ -508,12 +511,13 @@
508511
isa = PBXSourcesBuildPhase;
509512
buildActionMask = 2147483647;
510513
files = (
514+
528F16832C62941000148160 /* FGMClusterManagersControllerTests.m in Sources */,
511515
F7151F13265D7ED70028CB91 /* GoogleMapsTests.m in Sources */,
512516
6851F3562835BC180032B7C8 /* FLTGoogleMapJSONConversionsConversionTests.m in Sources */,
513517
982F2A6C27BADE17003C81F4 /* PartiallyMockedMapView.m in Sources */,
514518
478116522BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m in Sources */,
515519
0DD7B6C32B744EEF00E857FD /* FLTTileProviderControllerTests.m in Sources */,
516-
521AB0032B876A76005F460D /* ExtractIconFromDataTests.m in Sources */,
520+
528F16872C62952700148160 /* ExtractIconFromDataTests.m in Sources */,
517521
);
518522
runOnlyForDeploymentPostprocessing = 0;
519523
};
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,129 @@
1+
// Copyright 2013 The Flutter Authors. All rights reserved.
2+
// Use of this source code is governed by a BSD-style license that can be
3+
// found in the LICENSE file.
4+
5+
@import google_maps_flutter_ios;
6+
@import google_maps_flutter_ios.Test;
7+
@import XCTest;
8+
@import GoogleMaps;
9+
10+
#import <Flutter/Flutter.h>
11+
#import <OCMock/OCMock.h>
12+
#import "PartiallyMockedMapView.h"
13+
14+
@interface FGMClusterManagersControllerTests : XCTestCase
15+
@end
16+
17+
@implementation FGMClusterManagersControllerTests
18+
19+
- (void)testClustering {
20+
NSObject<FlutterPluginRegistrar> *registrar = OCMProtocolMock(@protocol(FlutterPluginRegistrar));
21+
CGRect frame = CGRectMake(0, 0, 100, 100);
22+
23+
GMSMapViewOptions *mapViewOptions = [[GMSMapViewOptions alloc] init];
24+
mapViewOptions.frame = frame;
25+
mapViewOptions.camera = [[GMSCameraPosition alloc] initWithLatitude:0 longitude:0 zoom:0];
26+
27+
PartiallyMockedMapView *mapView = [[PartiallyMockedMapView alloc] initWithOptions:mapViewOptions];
28+
29+
id handler = OCMClassMock([FGMMapsCallbackApi class]);
30+
31+
FGMClusterManagersController *clusterManagersController =
32+
[[FGMClusterManagersController alloc] initWithMapView:mapView callbackHandler:handler];
33+
34+
FLTMarkersController *markersController =
35+
[[FLTMarkersController alloc] initWithMapView:mapView
36+
callbackHandler:handler
37+
clusterManagersController:clusterManagersController
38+
registrar:registrar];
39+
40+
// Add cluster managers.
41+
NSString *clusterManagerId = @"cm";
42+
FGMPlatformClusterManager *clusterManagerToAdd =
43+
[FGMPlatformClusterManager makeWithIdentifier:clusterManagerId];
44+
[clusterManagersController addClusterManagers:@[ clusterManagerToAdd ]];
45+
46+
// Add cluster managers in JSON format.
47+
NSString *JSONClusterManagerId = @"json_cm";
48+
NSDictionary *JSONclusterManagerToAdd = @{@"clusterManagerId" : JSONClusterManagerId};
49+
[clusterManagersController addJSONClusterManagers:@[ JSONclusterManagerToAdd ]];
50+
51+
// Verify that cluster managers are available
52+
GMUClusterManager *clusterManager =
53+
[clusterManagersController clusterManagerWithIdentifier:clusterManagerId];
54+
XCTAssertNotNil(clusterManager, @"Cluster Manager should not be nil");
55+
GMUClusterManager *JSONClusterManager =
56+
[clusterManagersController clusterManagerWithIdentifier:JSONClusterManagerId];
57+
XCTAssertNotNil(JSONClusterManager, @"Cluster Manager should not be nil");
58+
59+
// Add markers
60+
NSString *markerId1 = @"m1";
61+
NSString *markerId2 = @"m2";
62+
63+
FGMPlatformMarker *marker1 = [FGMPlatformMarker makeWithJson:@{
64+
@"markerId" : markerId1,
65+
@"position" : @[ @0, @0 ],
66+
@"clusterManagerId" : clusterManagerId
67+
}];
68+
NSDictionary *marker2 =
69+
@{@"markerId" : markerId2, @"position" : @[ @0, @0 ], @"clusterManagerId" : clusterManagerId};
70+
71+
[markersController addMarkers:@[ marker1 ]];
72+
[markersController addJSONMarkers:@[ marker2 ]];
73+
74+
FlutterError *error = nil;
75+
76+
// Invoke clustering
77+
[clusterManagersController invokeClusteringForEachClusterManager];
78+
79+
// Verify that the markers were added to the cluster manager
80+
NSArray<FGMPlatformCluster *> *clusters1 =
81+
[clusterManagersController clustersWithIdentifier:clusterManagerId error:&error];
82+
XCTAssertNil(error, @"Error should be nil");
83+
for (FGMPlatformCluster *cluster in clusters1) {
84+
NSString *cmId = cluster.clusterManagerId;
85+
XCTAssertNotNil(cmId, @"Cluster Manager Identifier should not be nil");
86+
if ([cmId isEqualToString:clusterManagerId]) {
87+
NSArray *markerIds = cluster.markerIds;
88+
XCTAssertEqual(markerIds.count, 2, @"Cluster should contain two marker");
89+
XCTAssertTrue([markerIds containsObject:markerId1], @"Cluster should contain markerId1");
90+
XCTAssertTrue([markerIds containsObject:markerId2], @"Cluster should contain markerId2");
91+
return;
92+
}
93+
}
94+
95+
[markersController removeMarkersWithIdentifiers:@[ markerId2 ]];
96+
97+
// Verify that the marker2 is removed from the clusterManager
98+
NSArray<FGMPlatformCluster *> *clusters2 =
99+
[clusterManagersController clustersWithIdentifier:clusterManagerId error:&error];
100+
XCTAssertNil(error, @"Error should be nil");
101+
102+
for (FGMPlatformCluster *cluster in clusters2) {
103+
NSString *cmId = cluster.clusterManagerId;
104+
XCTAssertNotNil(cmId, @"Cluster Manager ID should not be nil");
105+
if ([cmId isEqualToString:clusterManagerId]) {
106+
NSArray *markerIds = cluster.markerIds;
107+
XCTAssertEqual(markerIds.count, 1, @"Cluster should contain one marker");
108+
XCTAssertTrue([markerIds containsObject:markerId1], @"Cluster should contain markerId1");
109+
return;
110+
}
111+
}
112+
113+
[markersController removeMarkersWithIdentifiers:@[ markerId1 ]];
114+
115+
// Verify that all markers are removed from clusterManager
116+
NSArray<FGMPlatformCluster *> *clusters3 =
117+
[clusterManagersController clustersWithIdentifier:clusterManagerId error:&error];
118+
XCTAssertNil(error, @"Error should be nil");
119+
XCTAssertEqual(clusters3.count, 0, @"Cluster Manager should not contain any clusters");
120+
121+
// Remove cluster manager
122+
[clusterManagersController removeClusterManagersWithIdentifiers:@[ clusterManagerId ]];
123+
124+
// Verify that the cluster manager is removed
125+
clusterManager = [clusterManagersController clusterManagerWithIdentifier:clusterManagerId];
126+
XCTAssertNil(clusterManager, @"Cluster Manager should be nil");
127+
}
128+
129+
@end

packages/google_maps_flutter/google_maps_flutter_ios/example/ios14/lib/main.dart

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'package:flutter/material.dart';
66
import 'package:maps_example_dart/animate_camera.dart';
7+
import 'package:maps_example_dart/clustering.dart';
78
import 'package:maps_example_dart/lite_mode.dart';
89
import 'package:maps_example_dart/map_click.dart';
910
import 'package:maps_example_dart/map_coordinates.dart';
@@ -40,6 +41,7 @@ void main() {
4041
SnapshotPage(),
4142
LiteModePage(),
4243
TileOverlayPage(),
44+
ClusteringPage(),
4345
MapIdPage(),
4446
])));
4547
}

packages/google_maps_flutter/google_maps_flutter_ios/example/ios15/lib/main.dart

+2
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
import 'package:flutter/material.dart';
66
import 'package:maps_example_dart/animate_camera.dart';
7+
import 'package:maps_example_dart/clustering.dart';
78
import 'package:maps_example_dart/lite_mode.dart';
89
import 'package:maps_example_dart/map_click.dart';
910
import 'package:maps_example_dart/map_coordinates.dart';
@@ -40,6 +41,7 @@ void main() {
4041
SnapshotPage(),
4142
LiteModePage(),
4243
TileOverlayPage(),
44+
ClusteringPage(),
4345
MapIdPage(),
4446
])));
4547
}

0 commit comments

Comments
 (0)