Skip to content

Commit

Permalink
[google_maps_flutter] Add marker clustering support - iOS implementat…
Browse files Browse the repository at this point in the history
…ion (#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
  • Loading branch information
jokerttu authored Aug 6, 2024
1 parent 4354f65 commit ec92d7d
Show file tree
Hide file tree
Showing 32 changed files with 1,585 additions and 96 deletions.
Original file line number Diff line number Diff line change
@@ -1,3 +1,7 @@
## 2.12.0

* Adds support for marker clustering.

## 2.11.0

* Adds support for heatmap layers.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<MarkerId, Marker> markers = <MarkerId, Marker>{};
final Set<ClusterManager> clusterManagers = <ClusterManager>{};

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<ExampleGoogleMapController> controllerCompleter =
Completer<ExampleGoogleMapController>();

final GoogleMapsInspectorPlatform inspector =
GoogleMapsInspectorPlatform.instance!;

await tester.pumpWidget(Directionality(
textDirection: TextDirection.ltr,
child: ExampleGoogleMap(
key: key,
initialCameraPosition: _kInitialCameraPosition,
clusterManagers: clusterManagers,
markers: Set<Marker>.of(markers.values),
onMapCreated: (ExampleGoogleMapController googleMapController) {
controllerCompleter.complete(googleMapController);
},
),
));

final ExampleGoogleMapController controller =
await controllerCompleter.future;

for (final ClusterManager cm in clusterManagers) {
final List<Cluster> clusters = await inspector.getClusters(
mapId: controller.mapId, clusterManagerId: cm.clusterManagerId);
final int markersAmountForClusterManager = clusters
.map<int>((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<MarkerId, Marker> 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<Marker>.of(markers.values)),
));

for (final ClusterManager cm in clusterManagers) {
final List<Cluster> clusters = await inspector.getClusters(
mapId: controller.mapId, clusterManagerId: cm.clusterManagerId);
expect(clusters.length, 0);
}
});

testWidgets('testSetStyleMapId', (WidgetTester tester) async {
final Key key = GlobalKey();

Expand Down Expand Up @@ -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,
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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 */; };
Expand Down Expand Up @@ -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 = "<group>"; };
478116512BEF8F47002F593E /* GoogleMapsPolylinesControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = GoogleMapsPolylinesControllerTests.m; sourceTree = "<group>"; };
521AB0022B876A76005F460D /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = "<group>"; };
528F16822C62941000148160 /* FGMClusterManagersControllerTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FGMClusterManagersControllerTests.m; sourceTree = "<group>"; };
528F16862C62952700148160 /* ExtractIconFromDataTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = ExtractIconFromDataTests.m; sourceTree = "<group>"; };
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 = "<group>"; };
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; };
Expand Down Expand Up @@ -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 = "<group>";
Expand Down Expand Up @@ -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;
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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 <Flutter/Flutter.h>
#import <OCMock/OCMock.h>
#import "PartiallyMockedMapView.h"

@interface FGMClusterManagersControllerTests : XCTestCase
@end

@implementation FGMClusterManagersControllerTests

- (void)testClustering {
NSObject<FlutterPluginRegistrar> *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<FGMPlatformCluster *> *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<FGMPlatformCluster *> *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<FGMPlatformCluster *> *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
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +41,7 @@ void main() {
SnapshotPage(),
LiteModePage(),
TileOverlayPage(),
ClusteringPage(),
MapIdPage(),
])));
}
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -40,6 +41,7 @@ void main() {
SnapshotPage(),
LiteModePage(),
TileOverlayPage(),
ClusteringPage(),
MapIdPage(),
])));
}
Loading

0 comments on commit ec92d7d

Please sign in to comment.