diff --git a/Examples/ObjectiveC/ClusteringExample.m b/Examples/ObjectiveC/ClusteringExample.m index 56047091..b3c44bed 100644 --- a/Examples/ObjectiveC/ClusteringExample.m +++ b/Examples/ObjectiveC/ClusteringExample.m @@ -3,11 +3,11 @@ NSString *const MBXExampleClustering = @"ClusteringExample"; -@interface ClusteringExample () +@interface ClusteringExample () @property (nonatomic) MGLMapView *mapView; @property (nonatomic) UIImage *icon; -@property (nonatomic) UILabel *popup; +@property (nonatomic) UIView *popup; @end @@ -21,8 +21,29 @@ - (void)viewDidLoad { self.mapView.tintColor = [UIColor darkGrayColor]; self.mapView.delegate = self; [self.view addSubview:self.mapView]; - - // Add our own gesture recognizer to handle taps on our custom map features. This gesture requires the built-in MGLMapView tap gestures (such as those for zoom and annotation selection) to fail. + + // 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]]) { @@ -46,7 +67,8 @@ - (void)mapView:(MGLMapView *)mapView didFinishLoadingStyle:(MGLStyle *)style { // 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. + // 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.iconColor = [NSExpression expressionForConstantValue:[[UIColor darkGrayColor] colorWithAlphaComponent:0.9]]; @@ -58,7 +80,9 @@ - (void)mapView:(MGLMapView *)mapView didFinishLoadingStyle:(MGLStyle *)style { @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. + + // 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]; @@ -69,7 +93,9 @@ - (void)mapView:(MGLMapView *)mapView didFinishLoadingStyle:(MGLStyle *)style { 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. + // 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)]; @@ -83,63 +109,181 @@ - (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> *features = [self.mapView visibleFeaturesInRect:rect inStyleLayersWithIdentifiers:[NSSet setWithObjects:@"clusteredPorts", @"ports", nil]]; + + NSPredicate *clusterPredicate = [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary * _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 { - if (tap.state == UIGestureRecognizerStateEnded) { - 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); - - // Find cluster circles and/or individual port icons in a touch-sized region around the tap. - // In theory, we should only find either one cluster (since they don't overlap) or one port - // (since overlapping ones would be clustered). - NSArray *clusters = [self.mapView visibleFeaturesInRect:rect inStyleLayersWithIdentifiers:[NSSet setWithObject:@"clusteredPorts"]]; - NSArray *ports = [self.mapView visibleFeaturesInRect:rect inStyleLayersWithIdentifiers:[NSSet setWithObject:@"ports"]]; - - if (clusters.count) { - [self showPopup:NO animated:YES]; - MGLPointFeature *cluster = (MGLPointFeature *)clusters.firstObject; - [self.mapView setCenterCoordinate:cluster.coordinate zoomLevel:(self.mapView.zoomLevel + 1) animated:YES]; - } else if (ports.count) { - MGLPointFeature *port = ((MGLPointFeature *)ports.firstObject); - - if (!self.popup) { - self.popup = [[UILabel alloc] initWithFrame:CGRectMake(0, 0, 100, 40)]; - self.popup.backgroundColor = [[UIColor whiteColor] colorWithAlphaComponent:0.9]; - self.popup.layer.cornerRadius = 4; - self.popup.layer.masksToBounds = YES; - self.popup.textAlignment = NSTextAlignmentCenter; - self.popup.lineBreakMode = NSLineBreakByTruncatingTail; - self.popup.font = [UIFont systemFontOfSize:16]; - self.popup.textColor = [UIColor blackColor]; - self.popup.alpha = 0; - [self.view addSubview:self.popup]; - } - - self.popup.text = [NSString stringWithFormat:@"%@", [port attributeForKey:@"name"]]; - CGSize size = [self.popup.text sizeWithAttributes:@{ NSFontAttributeName: self.popup.font }]; - self.popup.bounds = CGRectInset(CGRectMake(0, 0, size.width, size.height), -10, -10); - point = [self.mapView convertCoordinate:port.coordinate toPointToView:self.mapView]; - self.popup.center = CGPointMake(point.x, point.y - 50); - - if (self.popup.alpha < 1) { - [self showPopup:YES animated:YES]; - } - } else { - [self showPopup:NO animated:YES]; + + 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> *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 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 #%ld\n%ld 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.9]; + 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) { - __typeof__(self) __weak weakSelf = self; - [UIView animateWithDuration:0.25 animations:^{ - weakSelf.popup.alpha = alpha; - }]; + [UIView animateWithDuration:0.25 + animations:animation + completion:completion]; } else { - self.popup.alpha = alpha; + 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 diff --git a/Examples/Swift/ClusteringExample.swift b/Examples/Swift/ClusteringExample.swift index 75a4fe85..bf0742e9 100644 --- a/Examples/Swift/ClusteringExample.swift +++ b/Examples/Swift/ClusteringExample.swift @@ -6,7 +6,7 @@ class ClusteringExample_Swift: UIViewController, MGLMapViewDelegate { var mapView: MGLMapView! var icon: UIImage! - var popup: UILabel? + var popup: UIView? enum CustomError: Error { case castingError(String) @@ -21,7 +21,26 @@ class ClusteringExample_Swift: UIViewController, MGLMapViewDelegate { mapView.delegate = self view.addSubview(mapView) - // 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. + // 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) @@ -42,7 +61,8 @@ class ClusteringExample_Swift: UIViewController, MGLMapViewDelegate { // 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. + // 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)) @@ -57,7 +77,8 @@ class ClusteringExample_Swift: UIViewController, MGLMapViewDelegate { 200: UIColor.purple ] - // Show clustered features as circles. The `point_count` attribute is built into clustering-enabled source features. + // 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) @@ -67,7 +88,9 @@ class ClusteringExample_Swift: UIViewController, MGLMapViewDelegate { 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. + // 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)) @@ -82,63 +105,158 @@ class ClusteringExample_Swift: UIViewController, MGLMapViewDelegate { showPopup(false, animated: false) } - @objc @IBAction func handleMapTap(sender: UITapGestureRecognizer) throws { - if sender.state == .ended { - 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 clusters = mapView.visibleFeatures(in: rect, styleLayerIdentifiers: ["clusteredPorts"]) - let ports = mapView.visibleFeatures(in: rect, styleLayerIdentifiers: ["ports"]) - - if !clusters.isEmpty { - showPopup(false, animated: true) - let cluster = clusters.first! - mapView.setCenter(cluster.coordinate, zoomLevel: (mapView.zoomLevel + 1), animated: true) - } else if !ports.isEmpty { - let port = ports.first! - - if popup == nil { - popup = UILabel(frame: CGRect(x: 0, y: 0, width: 100, height: 40)) - popup!.backgroundColor = UIColor.white.withAlphaComponent(0.9) - popup!.layer.cornerRadius = 4 - popup!.layer.masksToBounds = true - popup!.textAlignment = .center - popup!.lineBreakMode = .byTruncatingTail - popup!.font = UIFont.systemFont(ofSize: 16) - popup!.textColor = UIColor.black - popup!.alpha = 0 - view.addSubview(popup!) - } - - guard let portName = port.attribute(forKey: "name")! as? String else { - throw CustomError.castingError("Could not cast port name to string") - } - - popup!.text = portName - let size = (popup!.text! as NSString).size(withAttributes: [NSAttributedStringKey.font: popup!.font]) - popup!.bounds = CGRect(x: 0, y: 0, width: size.width, height: size.height).insetBy(dx: -10, dy: -10) - let point = mapView.convert(port.coordinate, toPointTo: mapView) - popup!.center = CGPoint(x: point.x, y: point.y - 50) - - if popup!.alpha < 1 { - showPopup(true, animated: true) - } - } else { - showPopup(false, animated: true) - } + 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) - if animated { - UIView.animate(withDuration: 0.25) { [unowned self] in - self.popup?.alpha = alpha + + 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 { - popup?.alpha = alpha + animation() + completion(true) } } +} + +extension ClusteringExample_Swift: 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 + } }