Cluster point data
A newer version of the Maps SDK is available
This page uses v6.4.1 of the Mapbox Maps SDK. A newer version of the SDK is available. Learn about the latest version, v11.8.0, in the Maps SDK documentation.
Download the point GeoJSON and icon asset catalog and add it to your project.
For more information on styling data at runtime, see our Information for style authors guide.
import Mapbox
class ViewController: UIViewController, MGLMapViewDelegate {
var mapView: MGLMapView!
var icon: UIImage!
var popup: UIView?
enum CustomError: Error {
case castingError(String)
}
override func viewDidLoad() {
super.viewDidLoad()
mapView = MGLMapView(frame: view.bounds, styleURL: MGLStyle.lightStyleURL)
mapView.autoresizingMask = [.flexibleWidth, .flexibleHeight]
mapView.tintColor = .darkGray
mapView.delegate = self
view.addSubview(mapView)
// Add a double tap gesture recognizer. This gesture is used for double
// tapping on clusters and then zooming in so the cluster expands to its
// children.
let doubleTap = UITapGestureRecognizer(target: self, action: #selector(handleDoubleTapCluster(sender:)))
doubleTap.numberOfTapsRequired = 2
doubleTap.delegate = self
// It's important that this new double tap fails before the map view's
// built-in gesture can be recognized. This is to prevent the map's gesture from
// overriding this new gesture (and then not detecting a cluster that had been
// tapped on).
for recognizer in mapView.gestureRecognizers!
where (recognizer as? UITapGestureRecognizer)?.numberOfTapsRequired == 2 {
recognizer.require(toFail: doubleTap)
}
mapView.addGestureRecognizer(doubleTap)
// Add a single tap gesture recognizer. This gesture requires the built-in
// MGLMapView tap gestures (such as those for zoom and annotation selection)
// to fail (this order differs from the double tap above).
let singleTap = UITapGestureRecognizer(target: self, action: #selector(handleMapTap(sender:)))
for recognizer in mapView.gestureRecognizers! where recognizer is UITapGestureRecognizer {
singleTap.require(toFail: recognizer)
}
mapView.addGestureRecognizer(singleTap)
icon = UIImage(named: "port")
}
func mapView(_ mapView: MGLMapView, didFinishLoading style: MGLStyle) {
let url = URL(fileURLWithPath: Bundle.main.path(forResource: "ports", ofType: "geojson")!)
let source = MGLShapeSource(identifier: "clusteredPorts",
url: url,
options: [.clustered: true, .clusterRadius: icon.size.width])
style.addSource(source)
// Use a template image so that we can tint it with the `iconColor` runtime styling property.
style.setImage(icon.withRenderingMode(.alwaysTemplate), forName: "icon")
// Show unclustered features as icons. The `cluster` attribute is built into clustering-enabled
// source features.
let ports = MGLSymbolStyleLayer(identifier: "ports", source: source)
ports.iconImageName = NSExpression(forConstantValue: "icon")
ports.iconColor = NSExpression(forConstantValue: UIColor.darkGray.withAlphaComponent(0.9))
ports.predicate = NSPredicate(format: "cluster != YES")
ports.iconAllowsOverlap = NSExpression(forConstantValue: true)
style.addLayer(ports)
// Color clustered features based on clustered point counts.
let stops = [
20: UIColor.lightGray,
50: UIColor.orange,
100: UIColor.red,
200: UIColor.purple
]
// Show clustered features as circles. The `point_count` attribute is built into
// clustering-enabled source features.
let circlesLayer = MGLCircleStyleLayer(identifier: "clusteredPorts", source: source)
circlesLayer.circleRadius = NSExpression(forConstantValue: NSNumber(value: Double(icon.size.width) / 2))
circlesLayer.circleOpacity = NSExpression(forConstantValue: 0.75)
circlesLayer.circleStrokeColor = NSExpression(forConstantValue: UIColor.white.withAlphaComponent(0.75))
circlesLayer.circleStrokeWidth = NSExpression(forConstantValue: 2)
circlesLayer.circleColor = NSExpression(format: "mgl_step:from:stops:(point_count, %@, %@)", UIColor.lightGray, stops)
circlesLayer.predicate = NSPredicate(format: "cluster == YES")
style.addLayer(circlesLayer)
// Label cluster circles with a layer of text indicating feature count. The value for
// `point_count` is an integer. In order to use that value for the
// `MGLSymbolStyleLayer.text` property, cast it as a string.
let numbersLayer = MGLSymbolStyleLayer(identifier: "clusteredPortsNumbers", source: source)
numbersLayer.textColor = NSExpression(forConstantValue: UIColor.white)
numbersLayer.textFontSize = NSExpression(forConstantValue: NSNumber(value: Double(icon.size.width) / 2))
numbersLayer.iconAllowsOverlap = NSExpression(forConstantValue: true)
numbersLayer.text = NSExpression(format: "CAST(point_count, 'NSString')")
numbersLayer.predicate = NSPredicate(format: "cluster == YES")
style.addLayer(numbersLayer)
}
func mapViewRegionIsChanging(_ mapView: MGLMapView) {
showPopup(false, animated: false)
}
private func firstCluster(with gestureRecognizer: UIGestureRecognizer) -> MGLPointFeatureCluster? {
let point = gestureRecognizer.location(in: gestureRecognizer.view)
let width = icon.size.width
let rect = CGRect(x: point.x - width / 2, y: point.y - width / 2, width: width, height: width)
// This example shows how to check if a feature is a cluster by
// checking for that the feature is a `MGLPointFeatureCluster`. Alternatively, you could
// also check for conformance with `MGLCluster` instead.
let features = mapView.visibleFeatures(in: rect, styleLayerIdentifiers: ["clusteredPorts", "ports"])
let clusters = features.compactMap { $0 as? MGLPointFeatureCluster }
// Pick the first cluster, ideally selecting the one nearest nearest one to
// the touch point.
return clusters.first
}
@objc func handleDoubleTapCluster(sender: UITapGestureRecognizer) {
guard let source = mapView.style?.source(withIdentifier: "clusteredPorts") as? MGLShapeSource else {
return
}
guard sender.state == .ended else {
return
}
showPopup(false, animated: false)
guard let cluster = firstCluster(with: sender) else {
return
}
let zoom = source.zoomLevel(forExpanding: cluster)
if zoom > 0 {
mapView.setCenter(cluster.coordinate, zoomLevel: zoom, animated: true)
}
}
@objc func handleMapTap(sender: UITapGestureRecognizer) {
guard let source = mapView.style?.source(withIdentifier: "clusteredPorts") as? MGLShapeSource else {
return
}
guard sender.state == .ended else {
return
}
showPopup(false, animated: false)
let point = sender.location(in: sender.view)
let width = icon.size.width
let rect = CGRect(x: point.x - width / 2, y: point.y - width / 2, width: width, height: width)
let features = mapView.visibleFeatures(in: rect, styleLayerIdentifiers: ["clusteredPorts", "ports"])
// Pick the first feature (which may be a port or a cluster), ideally selecting
// the one nearest nearest one to the touch point.
guard let feature = features.first else {
return
}
let description: String
let color: UIColor
if let cluster = feature as? MGLPointFeatureCluster {
// Tapped on a cluster.
let children = source.children(of: cluster)
description = "Cluster #\(cluster.clusterIdentifier)\n\(children.count) children"
color = .blue
} else if let featureName = feature.attribute(forKey: "name") as? String?,
// Tapped on a port.
let portName = featureName {
description = portName
color = .black
} else {
// Tapped on a port that is missing a name.
description = "No port name"
color = .red
}
popup = popup(at: feature.coordinate, with: description, textColor: color)
showPopup(true, animated: true)
}
// Convenience method to create a reusable popup view.
private func popup(at coordinate: CLLocationCoordinate2D, with description: String, textColor: UIColor) -> UIView {
let popup = UILabel()
popup.backgroundColor = UIColor.white.withAlphaComponent(0.9)
popup.layer.cornerRadius = 4
popup.layer.masksToBounds = true
popup.textAlignment = .center
popup.lineBreakMode = .byTruncatingTail
popup.numberOfLines = 0
popup.font = .systemFont(ofSize: 16)
popup.textColor = textColor
popup.alpha = 0
popup.text = description
popup.sizeToFit()
// Expand the popup.
popup.bounds = popup.bounds.insetBy(dx: -10, dy: -10)
let point = mapView.convert(coordinate, toPointTo: mapView)
popup.center = CGPoint(x: point.x, y: point.y - 50)
return popup
}
func showPopup(_ shouldShow: Bool, animated: Bool) {
guard let popup = self.popup else {
return
}
if shouldShow {
view.addSubview(popup)
}
let alpha: CGFloat = (shouldShow ? 1 : 0)
let animation = {
popup.alpha = alpha
}
let completion = { (_: Bool) in
if !shouldShow {
popup.removeFromSuperview()
}
}
if animated {
UIView.animate(withDuration: 0.25, animations: animation, completion: completion)
} else {
animation()
completion(true)
}
}
}
extension ViewController: UIGestureRecognizerDelegate {
public func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool {
// This will only get called for the custom double tap gesture,
// that should always be recognized simultaneously.
return true
}
public func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool {
// This will only get called for the custom double tap gesture.
return firstCluster(with: gestureRecognizer) != nil
}
}
#import "ViewController.h"
@import Mapbox;
@interface ViewController () <MGLMapViewDelegate, UIGestureRecognizerDelegate>
@property (nonatomic) MGLMapView *mapView;
@property (nonatomic) UIImage *icon;
@property (nonatomic) UIView *popup;
@end
@implementation ViewController
- (void)viewDidLoad {
[super viewDidLoad];
self.mapView = [[MGLMapView alloc] initWithFrame:self.view.bounds styleURL:[MGLStyle lightStyleURL]];
self.mapView.autoresizingMask = UIViewAutoresizingFlexibleWidth | UIViewAutoresizingFlexibleHeight;
self.mapView.tintColor = [UIColor darkGrayColor];
self.mapView.delegate = self;
[self.view addSubview:self.mapView];
// Add a double tap gesture recognizer. This gesture is used for double
// tapping on clusters and then zooming in so the cluster expands to its
// children.
UITapGestureRecognizer *doubleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleDoubleTapCluster:)];
doubleTap.numberOfTapsRequired = 2;
doubleTap.delegate = self;
// It's important that this new double tap fails before the map view's
// built-in gesture can be recognized. This is to prevent the map's gesture from
// overriding this new gesture (and then not detecting a cluster that had been
// tapped on).
for (UIGestureRecognizer *recognizer in self.mapView.gestureRecognizers) {
if ([recognizer isKindOfClass:[UITapGestureRecognizer class]] &&
((UITapGestureRecognizer*)recognizer).numberOfTapsRequired == 2) {
[recognizer requireGestureRecognizerToFail:doubleTap];
}
}
[self.mapView addGestureRecognizer:doubleTap];
// Add a single tap gesture recognizer. This gesture requires the built-in
// MGLMapView tap gestures (such as those for zoom and annotation selection)
// to fail (this order differs from the double tap above).
UITapGestureRecognizer *singleTap = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(handleMapTap:)];
for (UIGestureRecognizer *recognizer in self.mapView.gestureRecognizers) {
if ([recognizer isKindOfClass:[UITapGestureRecognizer class]]) {
[singleTap requireGestureRecognizerToFail:recognizer];
}
}
[self.mapView addGestureRecognizer:singleTap];
self.icon = [UIImage imageNamed:@"port"];
}
- (void)mapView:(MGLMapView *)mapView didFinishLoadingStyle:(MGLStyle *)style {
NSURL *url = [NSURL fileURLWithPath:[[NSBundle mainBundle] pathForResource:@"ports" ofType:@"geojson"]];
MGLShapeSource *source = [[MGLShapeSource alloc] initWithIdentifier:@"clusteredPorts" URL:url options:@{
MGLShapeSourceOptionClustered: @(YES),
MGLShapeSourceOptionClusterRadius: @(self.icon.size.width)
}];
[style addSource:source];
// Use a template image so that we can tint it with the `iconColor` runtime styling property.
[style setImage:[self.icon imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate] forName:@"icon"];
// Show unclustered features as icons. The `cluster` attribute is built into clustering-enabled
// source features.
MGLSymbolStyleLayer *ports = [[MGLSymbolStyleLayer alloc] initWithIdentifier:@"ports" source:source];
ports.iconImageName = [NSExpression expressionForConstantValue:@"icon"];
ports.iconAllowsOverlap = [NSExpression expressionForConstantValue:@(YES)];
ports.iconColor = [NSExpression expressionForConstantValue:[[UIColor darkGrayColor] colorWithAlphaComponent:0.9f]];
ports.predicate = [NSPredicate predicateWithFormat:@"cluster != YES"];
[style addLayer:ports];
// Color clustered features based on clustered point counts.
NSDictionary *stops = @{ @20: [UIColor lightGrayColor],
@50: [UIColor orangeColor],
@100: [UIColor redColor],
@200: [UIColor purpleColor] };
// Show clustered features as circles. The `point_count` attribute is built into
// clustering-enabled source features.
MGLCircleStyleLayer *circlesLayer = [[MGLCircleStyleLayer alloc] initWithIdentifier:@"clusteredPorts" source:source];
circlesLayer.circleRadius = [NSExpression expressionForConstantValue:@(self.icon.size.width / 2)];
circlesLayer.circleOpacity = [NSExpression expressionForConstantValue:@0.75];
circlesLayer.circleStrokeColor = [NSExpression expressionForConstantValue:[[UIColor whiteColor] colorWithAlphaComponent:0.75]];
circlesLayer.circleStrokeWidth = [NSExpression expressionForConstantValue:@2];
circlesLayer.circleColor = [NSExpression expressionWithFormat:@"mgl_step:from:stops:(point_count, %@, %@)",
[UIColor lightGrayColor], stops];
circlesLayer.predicate = [NSPredicate predicateWithFormat:@"cluster == YES"];
[style addLayer:circlesLayer];
// Label cluster circles with a layer of text indicating feature count. The value for
// `point_count` is an integer. In order to use that value for the
// `MGLSymbolStyleLayer.text` property, cast it as a string.
MGLSymbolStyleLayer *numbersLayer = [[MGLSymbolStyleLayer alloc] initWithIdentifier:@"clusteredPortsNumbers" source:source];
numbersLayer.textColor = [NSExpression expressionForConstantValue:[UIColor whiteColor]];
numbersLayer.textFontSize = [NSExpression expressionForConstantValue:@(self.icon.size.width / 2)];
numbersLayer.iconAllowsOverlap = [NSExpression expressionForConstantValue:@(YES)];
numbersLayer.text = [NSExpression expressionWithFormat:@"CAST(point_count, 'NSString')"];
numbersLayer.predicate = [NSPredicate predicateWithFormat:@"cluster == YES"];
[style addLayer:numbersLayer];
}
- (void)mapViewRegionIsChanging:(MGLMapView *)mapView {
[self showPopup:NO animated:NO];
}
- (MGLPointFeatureCluster *)firstClusterWithGestureRecognizer:(UIGestureRecognizer *)gestureRecognizer {
CGPoint point = [gestureRecognizer locationInView:gestureRecognizer.view];
CGFloat width = self.icon.size.width;
CGRect rect = CGRectMake(point.x - width / 2, point.y - width / 2, width, width);
// This example shows how to check if a feature is a cluster by
// checking for that the feature is a `MGLPointFeatureCluster`. Alternatively, you could
// also check for conformance with `MGLCluster` instead.
NSArray<id<MGLFeature>> *features = [self.mapView visibleFeaturesInRect:rect inStyleLayersWithIdentifiers:[NSSet setWithObjects:@"clusteredPorts", @"ports", nil]];
NSPredicate *clusterPredicate = [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary<NSString *,id> * _Nullable bindings) {
return [evaluatedObject isKindOfClass:[MGLPointFeatureCluster class]];
}];
NSArray *clusters = [features filteredArrayUsingPredicate:clusterPredicate];
// Pick the first cluster, ideally selecting the one nearest nearest one to
// the touch point.
return (MGLPointFeatureCluster *)clusters.firstObject;
}
- (IBAction)handleDoubleTapCluster:(UITapGestureRecognizer *)sender {
MGLSource *source = [self.mapView.style sourceWithIdentifier:@"clusteredPorts"];
if (![source isKindOfClass:[MGLShapeSource class]]) {
return;
}
if (sender.state != UIGestureRecognizerStateEnded) {
return;
}
[self showPopup:NO animated:NO];
MGLPointFeatureCluster *cluster = [self firstClusterWithGestureRecognizer:sender];
if (!cluster) {
return;
}
double zoom = [(MGLShapeSource *)source zoomLevelForExpandingCluster:cluster];
if (zoom > 0.0) {
[self.mapView setCenterCoordinate:cluster.coordinate
zoomLevel:zoom
animated:YES];
}
}
- (IBAction)handleMapTap:(UITapGestureRecognizer *)tap {
MGLSource *source = [self.mapView.style sourceWithIdentifier:@"clusteredPorts"];
if (![source isKindOfClass:[MGLShapeSource class]]) {
return;
}
if (tap.state != UIGestureRecognizerStateEnded) {
return;
}
[self showPopup:NO animated:NO];
CGPoint point = [tap locationInView:tap.view];
CGFloat width = self.icon.size.width;
CGRect rect = CGRectMake(point.x - width / 2, point.y - width / 2, width, width);
NSArray<id<MGLFeature>> *features = [self.mapView visibleFeaturesInRect:rect inStyleLayersWithIdentifiers:[NSSet setWithObjects:@"clusteredPorts", @"ports", nil]];
// Pick the first feature (which may be a port or a cluster), ideally selecting
// the one nearest nearest one to the touch point.
id<MGLFeature> feature = features.firstObject;
if (!feature) {
return;
}
NSString *description = @"No port name";
UIColor *color = UIColor.redColor;
if ([feature isKindOfClass:[MGLPointFeatureCluster class]]) {
// Tapped on a cluster.
MGLPointFeatureCluster *cluster = (MGLPointFeatureCluster *)feature;
NSArray *children = [(MGLShapeSource*)source childrenOfCluster:cluster];
description = [NSString stringWithFormat:@"Cluster #%zd\n%zd children",
cluster.clusterIdentifier,
children.count];
color = UIColor.blueColor;
} else {
// Tapped on a port.
id name = [feature attributeForKey:@"name"];
if ([name isKindOfClass:[NSString class]]) {
description = (NSString *)name;
color = UIColor.blackColor;
}
}
self.popup = [self popupAtCoordinate:feature.coordinate
withDescription:description
textColor:color];
[self showPopup:YES animated:YES];
}
- (UIView *)popupAtCoordinate:(CLLocationCoordinate2D)coordinate withDescription:(NSString *)description textColor:(UIColor *)textColor {
UILabel *popup = [[UILabel alloc] init];
popup.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.9f];
popup.layer.cornerRadius = 4;
popup.layer.masksToBounds = YES;
popup.textAlignment = NSTextAlignmentCenter;
popup.lineBreakMode = NSLineBreakByTruncatingTail;
popup.numberOfLines = 0;
popup.font = [UIFont systemFontOfSize:16];
popup.textColor = textColor;
popup.alpha = 0;
popup.text = description;
[popup sizeToFit];
// Expand the popup.
popup.bounds = CGRectInset(popup.bounds, -10, -10);
CGPoint point = [self.mapView convertCoordinate:coordinate toPointToView:self.mapView];
popup.center = CGPointMake(point.x, point.y - 50);
return popup;
}
- (void)showPopup:(BOOL)shouldShow animated:(BOOL)animated {
if (!self.popup) {
return;
}
UIView *popup = self.popup;
if (shouldShow) {
[self.view addSubview:popup];
}
CGFloat alpha = (shouldShow ? 1 : 0);
dispatch_block_t animation = ^{
popup.alpha = alpha;
};
void (^completion)(BOOL) = ^(BOOL finished){
if (!shouldShow) {
[popup removeFromSuperview];
}
};
if (animated) {
[UIView animateWithDuration:0.25
animations:animation
completion:completion];
} else {
animation();
completion(YES);
}
}
#pragma mark - UIGestureRecognizerDelegate
- (BOOL)gestureRecognizer:(UIGestureRecognizer *)gestureRecognizer shouldRecognizeSimultaneouslyWithGestureRecognizer:(UIGestureRecognizer *)otherGestureRecognizer {
// This will only get called for the custom double tap gesture,
// that should always be recognized simultaneously.
return YES;
}
- (BOOL)gestureRecognizerShouldBegin:(UIGestureRecognizer *)gestureRecognizer {
// This will only get called for the custom double tap gesture.
return [self firstClusterWithGestureRecognizer:gestureRecognizer] != nil;
}
@end