[ios,macos] Selecting offscreen annotation pans map to fit annotation & callout view (#3249, #9790)

This commit is contained in:
Julian Rex
2018-03-21 13:51:10 -04:00
committed by GitHub
parent 4b3d0638b8
commit d4f2bc07ca
14 changed files with 508 additions and 43 deletions

View File

@@ -36,6 +36,8 @@ Mapbox welcomes participation and contributions from everyone. Please read [CONT
* Fixed an issue preventing `MGLAnnotationImage.image` from being updated. ([#10372](https://github.com/mapbox/mapbox-gl-native/pull/10372))
* Improved performance of `MGLAnnotationView`-backed annotations that have `scalesWithViewingDistance` enabled. ([#10951](https://github.com/mapbox/mapbox-gl-native/pull/10951))
* Fix an issue where a wrong annotation may selected if annotations were set close together. ([#11438](https://github.com/mapbox/mapbox-gl-native/pull/11438))
* The `MGLMapView.selectedAnnotations` property (backed by `-[MGLMapView setSelectedAnnotations:]`) now selects annotations that are off-screen. ([#9790](https://github.com/mapbox/mapbox-gl-native/issues/9790))
* The `animated` parameter to `-[MGLMapView selectAnnotation:animated:]` now controls whether the annotation and its callout are brought on-screen. If `animated` is `NO` then the annotation is selected if offscreen, but the map is not panned. Currently only point annotations are supported. Setting the `MGLMapView.selectedAnnotations` property now animates. ([#3249](https://github.com/mapbox/mapbox-gl-native/issues/3249))
### Map snapshots

View File

@@ -37,10 +37,14 @@ static CGFloat const tipWidth = 10.0;
return self;
}
#pragma mark - API
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated
{
[self presentCalloutFromRect:rect inView:view constrainedToRect:CGRectNull animated:animated];
}
- (void)presentCalloutFromRect:(CGRect)rect inView:(nonnull UIView *)view constrainedToRect:(__unused CGRect)constrainedRect animated:(BOOL)animated
{
if ([self.delegate respondsToSelector:@selector(calloutViewWillAppear:)])
{
@@ -108,5 +112,4 @@ static CGFloat const tipWidth = 10.0;
CGPathRelease(tipPath);
}
@end

View File

@@ -54,6 +54,9 @@ typedef NS_ENUM(NSInteger, MBXSettingsAnnotationsRows) {
MBXSettingsAnnotationsQueryAnnotations,
MBXSettingsAnnotationsCustomUserDot,
MBXSettingsAnnotationsRemoveAnnotations,
MBXSettingsAnnotationSelectRandomOffscreenPointAnnotation,
MBXSettingsAnnotationCenterSelectedAnnotation,
MBXSettingsAnnotationAddVisibleAreaPolyline
};
typedef NS_ENUM(NSInteger, MBXSettingsRuntimeStylingRows) {
@@ -340,6 +343,9 @@ typedef NS_ENUM(NSInteger, MBXSettingsMiscellaneousRows) {
@"Query Annotations",
[NSString stringWithFormat:@"%@ Custom User Dot", (_customUserLocationAnnnotationEnabled ? @"Disable" : @"Enable")],
@"Remove Annotations",
@"Select an offscreen point annotation",
@"Center selected annotation",
@"Add visible area polyline"
]];
break;
case MBXSettingsRuntimeStyling:
@@ -468,6 +474,18 @@ typedef NS_ENUM(NSInteger, MBXSettingsMiscellaneousRows) {
case MBXSettingsAnnotationsRemoveAnnotations:
[self.mapView removeAnnotations:self.mapView.annotations];
break;
case MBXSettingsAnnotationSelectRandomOffscreenPointAnnotation:
[self selectAnOffscreenPointAnnotation];
break;
case MBXSettingsAnnotationCenterSelectedAnnotation:
[self centerSelectedAnnotation];
break;
case MBXSettingsAnnotationAddVisibleAreaPolyline:
[self addVisibleAreaPolyline];
break;
default:
NSAssert(NO, @"All annotations setting rows should be implemented");
break;
@@ -1551,6 +1569,73 @@ typedef NS_ENUM(NSInteger, MBXSettingsMiscellaneousRows) {
[self presentViewController:alertController animated:YES completion:nil];
}
- (id<MGLAnnotation>)randomOffscreenPointAnnotation {
NSPredicate *pointAnnotationPredicate = [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary<NSString *,id> * _Nullable bindings) {
return [evaluatedObject isKindOfClass:[MGLPointAnnotation class]];
}];
NSArray *annotations = [self.mapView.annotations filteredArrayUsingPredicate:pointAnnotationPredicate];
if (annotations.count == 0) {
return nil;
}
NSArray *visibleAnnotations = [self.mapView.visibleAnnotations filteredArrayUsingPredicate:pointAnnotationPredicate];
if (visibleAnnotations.count == annotations.count) {
return nil;
}
NSMutableArray *invisibleAnnotations = [annotations mutableCopy];
if (visibleAnnotations.count > 0) {
[invisibleAnnotations removeObjectsInArray:visibleAnnotations];
}
// Now pick a random offscreen annotation.
uint32_t index = arc4random_uniform((uint32_t)invisibleAnnotations.count);
return invisibleAnnotations[index];
}
- (void)selectAnOffscreenPointAnnotation {
id<MGLAnnotation> annotation = [self randomOffscreenPointAnnotation];
if (annotation) {
[self.mapView selectAnnotation:annotation animated:YES];
NSAssert(self.mapView.selectedAnnotations.firstObject, @"The annotation was not selected");
}
}
- (void)centerSelectedAnnotation {
id<MGLAnnotation> annotation = self.mapView.selectedAnnotations.firstObject;
if (!annotation)
return;
CGPoint point = [self.mapView convertCoordinate:annotation.coordinate toPointToView:self.mapView];
// Animate, so that point becomes the the center
CLLocationCoordinate2D center = [self.mapView convertPoint:point toCoordinateFromView:self.mapView];
[self.mapView setCenterCoordinate:center animated:YES];
}
- (void)addVisibleAreaPolyline {
CGRect constrainedRect = UIEdgeInsetsInsetRect(self.mapView.bounds, self.mapView.contentInset);
CLLocationCoordinate2D lineCoords[5];
lineCoords[0] = [self.mapView convertPoint: CGPointMake(CGRectGetMinX(constrainedRect), CGRectGetMinY(constrainedRect)) toCoordinateFromView:self.mapView];
lineCoords[1] = [self.mapView convertPoint: CGPointMake(CGRectGetMaxX(constrainedRect), CGRectGetMinY(constrainedRect)) toCoordinateFromView:self.mapView];
lineCoords[2] = [self.mapView convertPoint: CGPointMake(CGRectGetMaxX(constrainedRect), CGRectGetMaxY(constrainedRect)) toCoordinateFromView:self.mapView];
lineCoords[3] = [self.mapView convertPoint: CGPointMake(CGRectGetMinX(constrainedRect), CGRectGetMaxY(constrainedRect)) toCoordinateFromView:self.mapView];
lineCoords[4] = lineCoords[0];
MGLPolyline *line = [MGLPolyline polylineWithCoordinates:lineCoords
count:sizeof(lineCoords)/sizeof(lineCoords[0])];
[self.mapView addAnnotation:line];
}
- (void)printTelemetryLogFile
{
NSString *fileContents = [NSString stringWithContentsOfFile:[self telemetryDebugLogFilePath] encoding:NSUTF8StringEncoding error:nil];
@@ -1602,7 +1687,13 @@ typedef NS_ENUM(NSInteger, MBXSettingsMiscellaneousRows) {
toCoordinateFromView:self.mapView];
pin.title = title ?: @"Dropped Pin";
pin.subtitle = [[[MGLCoordinateFormatter alloc] init] stringFromCoordinate:pin.coordinate];
// Calling `addAnnotation:` on mapView is not required since `selectAnnotation:animated` has the side effect of adding the annotation if required
// Calling `addAnnotation:` on mapView is required here (since `selectAnnotation:animated` has
// the side effect of adding the annotation if required, but returning an incorrect callout
// positioning rect)
[self.mapView addAnnotation:pin];
[self.mapView selectAnnotation:pin animated:YES];
}
}

View File

@@ -41,6 +41,13 @@ NS_ASSUME_NONNULL_BEGIN
*/
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated;
/**
Presents a callout view by adding it to `view` and pointing at the given rect
of `view`’s bounds. Constrains the callout to the rect in the space of `view`.
*/
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToRect:(CGRect)constrainedRect animated:(BOOL)animated;
/**
Dismisses the callout view.
*/
@@ -48,6 +55,24 @@ NS_ASSUME_NONNULL_BEGIN
@optional
/**
If implemented, should provide margins to expand the rect the callout is presented from.
These are used to determine positioning. Currently only the top and bottom properties of the return
value are used. For example, `{ .top = -50.0, .left = -10.0, .bottom = 0.0, .right = -10.0 }` indicates
a 50 point margin above the presentation origin rect (and 10 point margins to the left and the right)
in which the callout is assumed to be displayed.
There are no assumed defaults for these margins, as they should be calculated from the callout that
is to be presented. For example, `SMCalloutView` generates the top margin from the callout height, but
the left and right margins from a minimum width that the callout should have.
@param rect Rect that the callout is presented from. This should be the same as the one passed in
`-[MGLCalloutView presentCalloutFromRect:inView:constrainedToRect:animated:]`
@return `UIEdgeInsets` representing the margins. Values should be negative.
*/
- (UIEdgeInsets)marginInsetsHintForPresentationFromRect:(CGRect)rect NS_SWIFT_NAME(marginInsetsHintForPresentation(from:));
/**
A Boolean value indicating whether the callout view should be anchored to
the corresponding annotation. You can adjust the callout view’s precise location by

View File

@@ -1200,17 +1200,32 @@ MGL_EXPORT IB_DESIGNABLE
Assigning a new array to this property selects only the first annotation in
the array.
If the annotation is of type `MGLPointAnnotation` and is offscreen, the camera
will animate to bring the annotation and its callout just on screen. If you
need finer control, consider using `-selectAnnotation:animated:`.
@note In versions prior to `4.0.0` if the annotation was offscreen it was not
selected.
*/
@property (nonatomic, copy) NS_ARRAY_OF(id <MGLAnnotation>) *selectedAnnotations;
/**
Selects an annotation and displays a callout view for it.
Selects an annotation and displays its callout view.
If the given annotation is not visible within the current viewport, this
method has no effect.
The `animated` parameter determines whether the map is panned to bring the
annotation on-screen, specifically:
| `animated` parameter | Effect |
|------------------|--------|
| `NO` | The annotation is selected, and the callout is presented. However the map is not panned to bring the annotation or callout onscreen. The presentation of the callout is animated. |
| `YES` | The annotation is selected, and the callout is presented. If the annotation is offscreen *and* is of type `MGLPointAnnotation`, the map is panned so that the annotation and its callout are brought just onscreen. The annotation is *not* centered within the viewport. |
@param annotation The annotation object to select.
@param animated If `YES`, the callout view is animated into position.
@param animated If `YES`, the annotation and callout view are animated on-screen.
@note In versions prior to `4.0.0` selecting an offscreen annotation did not
change the camera.
*/
- (void)selectAnnotation:(id <MGLAnnotation>)annotation animated:(BOOL)animated;

View File

@@ -144,6 +144,9 @@ const CGFloat MGLAnnotationImagePaddingForCallout = 1;
const CGSize MGLAnnotationAccessibilityElementMinimumSize = CGSizeMake(10, 10);
/// Padding to edge of view that an offscreen annotation must have when being brought onscreen (by being selected)
const UIEdgeInsets MGLMapViewOffscreenAnnotationPadding = UIEdgeInsetsMake(-20.0f, -20.0f, -20.0f, -20.0f);
/// An indication that the requested annotation was not found or is nonexistent.
enum { MGLAnnotationTagNotFound = UINT32_MAX };
@@ -272,7 +275,7 @@ public:
CADisplayLink *_displayLink;
BOOL _needsDisplayRefresh;
NSUInteger _changeDelimiterSuppressionDepth;
NSInteger _changeDelimiterSuppressionDepth;
/// Center coordinate of the pinch gesture on the previous iteration of the gesture.
CLLocationCoordinate2D _previousPinchCenterCoordinate;
@@ -1625,7 +1628,7 @@ public:
{
CGPoint calloutPoint = [singleTap locationInView:self];
CGRect positionRect = [self positioningRectForAnnotation:annotation defaultCalloutPoint:calloutPoint];
[self selectAnnotation:annotation animated:YES calloutPositioningRect:positionRect];
[self selectAnnotation:annotation moveOnscreen:YES animateSelection:YES calloutPositioningRect:positionRect];
}
else if (self.selectedAnnotation)
{
@@ -4234,6 +4237,12 @@ public:
});
}
- (BOOL)isBringingAnnotationOnscreenSupportedForAnnotation:(id<MGLAnnotation>)annotation animated:(BOOL)animated {
// Consider delegating
return animated && [annotation isKindOfClass:[MGLPointAnnotation class]];
}
- (id <MGLAnnotation>)selectedAnnotation
{
if (_userLocationAnnotationIsSelected)
@@ -4274,20 +4283,16 @@ public:
if ([firstAnnotation isKindOfClass:[MGLMultiPoint class]]) return;
// Select the annotation if it’s visible.
if (MGLCoordinateInCoordinateBounds(firstAnnotation.coordinate, self.visibleCoordinateBounds))
{
[self selectAnnotation:firstAnnotation animated:NO];
}
[self selectAnnotation:firstAnnotation animated:YES];
}
- (void)selectAnnotation:(id <MGLAnnotation>)annotation animated:(BOOL)animated
{
CGRect positioningRect = [self positioningRectForAnnotation:annotation defaultCalloutPoint:CGPointZero];
[self selectAnnotation:annotation animated:animated calloutPositioningRect:positioningRect];
[self selectAnnotation:annotation moveOnscreen:animated animateSelection:YES calloutPositioningRect:positioningRect];
}
- (void)selectAnnotation:(id <MGLAnnotation>)annotation animated:(BOOL)animated calloutPositioningRect:(CGRect)calloutPositioningRect
- (void)selectAnnotation:(id <MGLAnnotation>)annotation moveOnscreen:(BOOL)moveOnscreen animateSelection:(BOOL)animateSelection calloutPositioningRect:(CGRect)calloutPositioningRect
{
if ( ! annotation) return;
@@ -4311,22 +4316,34 @@ public:
MGLAnnotationContext &annotationContext = _annotationContextsByAnnotationTag.at(annotationTag);
annotationView = annotationContext.annotationView;
if (annotationView && annotationView.enabled) {
// Annotations represented by views use the view frame as the positioning rect.
calloutPositioningRect = annotationView.frame;
[annotationView.superview bringSubviewToFront:annotationView];
[annotationView setSelected:YES animated:animated];
// Annotations represented by views use the view frame as the positioning rect.
calloutPositioningRect = annotationView.frame;
[annotationView.superview bringSubviewToFront:annotationView];
[annotationView setSelected:YES animated:animateSelection];
}
}
self.selectedAnnotation = annotation;
// Determine if we're allowed to move this offscreen annotation on screen, even though we've asked it to
if (moveOnscreen) {
moveOnscreen = [self isBringingAnnotationOnscreenSupportedForAnnotation:annotation animated:animateSelection];
}
CGRect expandedPositioningRect = UIEdgeInsetsInsetRect(calloutPositioningRect, MGLMapViewOffscreenAnnotationPadding);
// Used for callout positioning, and moving offscreen annotations onscreen.
CGRect constrainedRect = UIEdgeInsetsInsetRect(self.bounds, self.contentInset);
UIView <MGLCalloutView> *calloutView = nil;
if ([annotation respondsToSelector:@selector(title)] &&
annotation.title &&
[self.delegate respondsToSelector:@selector(mapView:annotationCanShowCallout:)] &&
[self.delegate mapView:self annotationCanShowCallout:annotation])
{
// build the callout
UIView <MGLCalloutView> *calloutView;
if ([self.delegate respondsToSelector:@selector(mapView:calloutViewForAnnotation:)])
{
id providedCalloutView = [self.delegate mapView:self calloutViewForAnnotation:annotation];
@@ -4384,13 +4401,51 @@ public:
// set annotation delegate to handle taps on the callout view
calloutView.delegate = self;
// present popup
[calloutView presentCalloutFromRect:calloutPositioningRect
inView:self.glView
constrainedToView:self.glView
animated:animated];
// If the callout view provides inset (outset) information, we can use it to expand our positioning
// rect, which we then use to help move the annotation on-screen if want need to.
if (moveOnscreen && [calloutView respondsToSelector:@selector(marginInsetsHintForPresentationFromRect:)]) {
UIEdgeInsets margins = [calloutView marginInsetsHintForPresentationFromRect:calloutPositioningRect];
expandedPositioningRect = UIEdgeInsetsInsetRect(expandedPositioningRect, margins);
}
}
if (moveOnscreen)
{
moveOnscreen = NO;
// Need to consider the content insets.
CGRect bounds = UIEdgeInsetsInsetRect(self.bounds, self.contentInset);
// Any one of these cases should trigger a move onscreen
if (CGRectGetMinX(calloutPositioningRect) < CGRectGetMinX(bounds))
{
constrainedRect.origin.x = expandedPositioningRect.origin.x;
moveOnscreen = YES;
}
else if (CGRectGetMaxX(calloutPositioningRect) > CGRectGetMaxX(bounds))
{
constrainedRect.origin.x = CGRectGetMaxX(expandedPositioningRect) - constrainedRect.size.width;
moveOnscreen = YES;
}
if (CGRectGetMinY(calloutPositioningRect) < CGRectGetMinY(bounds))
{
constrainedRect.origin.y = expandedPositioningRect.origin.y;
moveOnscreen = YES;
}
else if (CGRectGetMaxY(calloutPositioningRect) > CGRectGetMaxY(bounds))
{
constrainedRect.origin.y = CGRectGetMaxY(expandedPositioningRect) - constrainedRect.size.height;
moveOnscreen = YES;
}
}
// Remember, calloutView can be nil here.
[calloutView presentCalloutFromRect:calloutPositioningRect
inView:self.glView
constrainedToRect:constrainedRect
animated:animateSelection];
// notify delegate
if ([self.delegate respondsToSelector:@selector(mapView:didSelectAnnotation:)])
{
@@ -4401,6 +4456,13 @@ public:
{
[self.delegate mapView:self didSelectAnnotationView:annotationView];
}
if (moveOnscreen)
{
CGPoint center = CGPointMake(CGRectGetMidX(constrainedRect), CGRectGetMidY(constrainedRect));
CLLocationCoordinate2D centerCoord = [self convertPoint:center toCoordinateFromView:self];
[self setCenterCoordinate:centerCoord animated:animateSelection];
}
}
- (MGLCompactCalloutView *)calloutViewForAnnotation:(id <MGLAnnotation>)annotation
@@ -4591,6 +4653,7 @@ public:
animated:animated];
}
#pragma mark Annotation Image Delegate
- (void)annotationImageNeedsRedisplay:(MGLAnnotationImage *)annotationImage

View File

@@ -51,12 +51,15 @@ static NSString * const MGLTestAnnotationReuseIdentifer = @"MGLTestAnnotationReu
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated { }
- (void)presentCalloutFromRect:(CGRect)rect inView:(nonnull UIView *)view constrainedToRect:(CGRect)constrainedRect animated:(BOOL)animated {}
@end
@interface MGLAnnotationViewTests : XCTestCase <MGLMapViewDelegate>
@property (nonatomic) XCTestExpectation *expectation;
@property (nonatomic) MGLMapView *mapView;
@property (nonatomic, weak) MGLAnnotationView *annotationView;
@property (nonatomic) NSInteger annotationSelectedCount;
@end
@implementation MGLAnnotationViewTests
@@ -98,6 +101,61 @@ static NSString * const MGLTestAnnotationReuseIdentifer = @"MGLTestAnnotationReu
XCTAssertNotNil(customAnnotationView);
}
- (void)testSelectingOffscreenAnnotation
{
// Partial test for https://github.com/mapbox/mapbox-gl-native/issues/9790
// This isn't quite the same as in updateAnnotationViews, but should be sufficient for this test.
MGLCoordinateBounds coordinateBounds = [_mapView convertRect:_mapView.bounds toCoordinateBoundsFromView:_mapView];
// -90 latitude is invalid. TBD.
BOOL anyOffscreen = NO;
NSInteger selectionCount = 0;
for (NSInteger latitude = -89; latitude <= 90; latitude += 10)
{
for (NSInteger longitude = -180; longitude <= 180; longitude += 10)
{
MGLTestAnnotation *annotation = [[MGLTestAnnotation alloc] init];
annotation.coordinate = CLLocationCoordinate2DMake(latitude, longitude);
[_mapView addAnnotation:annotation];
if (!(MGLCoordinateInCoordinateBounds(annotation.coordinate, coordinateBounds)))
anyOffscreen = YES;
XCTAssertNil(_mapView.selectedAnnotations.firstObject, @"There should be no selected annotation");
// First selection
[_mapView selectAnnotation:annotation animated:NO];
selectionCount++;
XCTAssert(_mapView.selectedAnnotations.count == 1, @"There should only be 1 selected annotation");
XCTAssertEqualObjects(_mapView.selectedAnnotations.firstObject, annotation, @"The annotation should be selected");
// Deselect
[_mapView deselectAnnotation:annotation animated:NO];
XCTAssert(_mapView.selectedAnnotations.count == 0, @"There should be no selected annotations");
// Second selection
_mapView.selectedAnnotations = @[annotation];
selectionCount++;
XCTAssert(_mapView.selectedAnnotations.count == 1, @"There should be 1 selected annotation");
XCTAssertEqualObjects(_mapView.selectedAnnotations.firstObject, annotation, @"The annotation should be selected");
// Deselect
[_mapView deselectAnnotation:annotation animated:NO];
XCTAssert(_mapView.selectedAnnotations.count == 0, @"There should be no selected annotations");
}
}
XCTAssert(anyOffscreen, @"At least one of these annotations should be offscreen");
XCTAssertEqual(selectionCount, self.annotationSelectedCount, @"-mapView:didSelectAnnotation: should be called for each selection");
}
#pragma mark - MGLMapViewDelegate -
- (MGLAnnotationView *)mapView:(MGLMapView *)mapView viewForAnnotation:(id<MGLAnnotation>)annotation
{
MGLAnnotationView *annotationView = [mapView dequeueReusableAnnotationViewWithIdentifier:MGLTestAnnotationReuseIdentifer];
@@ -117,4 +175,9 @@ static NSString * const MGLTestAnnotationReuseIdentifer = @"MGLTestAnnotationReu
[_expectation fulfill];
}
- (void)mapView:(MGLMapView *)mapView didSelectAnnotation:(id<MGLAnnotation>)annotation
{
self.annotationSelectedCount++;
}
@end

View File

@@ -113,6 +113,18 @@ extern NSTimeInterval const kMGLSMCalloutViewRepositionDelayForUIScrollView;
*/
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated;
/**
@brief Presents a callout view by adding it to "inView" and pointing at the given rect of inView's bounds.
@discussion Constrains the callout to the rect (in the space of the given view).
@param rect @c CGRect to present the view from
@param view view to 'constrain' the @c constrainedView to
@param constrainedRect Rect to constrain the callout to
@param animated @c BOOL if presentation should be animated
*/
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToRect:(CGRect)constrainedRect animated:(BOOL)animated;
/**
@brief Present a callout layer in the `layer` and pointing at the given rect of the `layer` bounds

View File

@@ -247,6 +247,35 @@ NSTimeInterval const kMGLSMCalloutViewRepositionDelayForUIScrollView = 1.0/3.0;
return CGSizeMake(nudgeLeft ? nudgeLeft : nudgeRight, nudgeTop ? nudgeTop : nudgeBottom);
}
- (UIEdgeInsets)marginInsetsHintForPresentationFromRect:(CGRect)rect {
// form our subviews based on our content set so far
[self rebuildSubviews];
// size the callout to fit the width constraint as best as possible
CGFloat height = self.calloutHeight;
CGSize size = [self sizeThatFits:CGSizeMake(0.0f, height)];
// Without re-jigging presentCalloutFromRect, let's just make a best-guess with what we have
// right now.
CGFloat horizontalMargin = fmaxf(0, ceilf((CALLOUT_MIN_WIDTH-rect.size.width)/2));
UIEdgeInsets insets = {
.top = 0.0f,
.right = -horizontalMargin,
.bottom = 0.0f,
.left = -horizontalMargin
};
if (self.permittedArrowDirection == MGLSMCalloutArrowDirectionUp)
insets.bottom -= size.height;
else
insets.top -= size.height;
return insets;
}
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToView:(UIView *)constrainedView animated:(BOOL)animated {
[self presentCalloutFromRect:rect inLayer:view.layer ofView:view constrainedToLayer:constrainedView.layer animated:animated];
}
@@ -255,8 +284,18 @@ NSTimeInterval const kMGLSMCalloutViewRepositionDelayForUIScrollView = 1.0/3.0;
[self presentCalloutFromRect:rect inLayer:layer ofView:nil constrainedToLayer:constrainedLayer animated:animated];
}
// this private method handles both CALayer and UIView parents depending on what's passed.
- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer ofView:(UIView *)view constrainedToLayer:(CALayer *)constrainedLayer animated:(BOOL)animated {
// figure out the constrained view's rect in our popup view's coordinate system
CGRect constrainedRect = [constrainedLayer convertRect:constrainedLayer.bounds toLayer:layer];
[self presentCalloutFromRect:rect inLayer:layer ofView:view constrainedToRect:constrainedRect animated:animated];
}
- (void)presentCalloutFromRect:(CGRect)rect inView:(UIView *)view constrainedToRect:(CGRect)constrainedRect animated:(BOOL)animated {
[self presentCalloutFromRect:rect inLayer:view.layer ofView:view constrainedToRect:constrainedRect animated:animated];
}
// this private method handles both CALayer and UIView parents depending on what's passed.
- (void)presentCalloutFromRect:(CGRect)rect inLayer:(CALayer *)layer ofView:(UIView *)view constrainedToRect:(CGRect)constrainedRect animated:(BOOL)animated {
// Sanity check: dismiss this callout immediately if it's displayed somewhere
if (self.layer.superlayer) [self dismissCalloutAnimated:NO];
@@ -265,8 +304,6 @@ NSTimeInterval const kMGLSMCalloutViewRepositionDelayForUIScrollView = 1.0/3.0;
[self.layer removeAnimationForKey:@"present"];
[self.layer removeAnimationForKey:@"dismiss"];
// figure out the constrained view's rect in our popup view's coordinate system
CGRect constrainedRect = [constrainedLayer convertRect:constrainedLayer.bounds toLayer:layer];
// apply our edge constraints
constrainedRect = UIEdgeInsetsInsetRect(constrainedRect, self.constrainedInsets);

View File

@@ -25,6 +25,8 @@
### Annotations
* Fix an issue where a wrong annotation may selected if annotations were set close together. ([#11438](https://github.com/mapbox/mapbox-gl-native/pull/11438))
* The `MGLMapView.selectedAnnotations` property (backed by `-[MGLMapView setSelectedAnnotations:]`) now selects annotations that are off-screen. ([#9790](https://github.com/mapbox/mapbox-gl-native/issues/9790))
* The `animated` parameter to `-[MGLMapView selectAnnotation:animated:]` now controls whether the annotation and its callout are brought on-screen. If `animated` is `NO` then the annotation is selected if offscreen, but the map is not panned. Currently only point annotations are supported.([#3249](https://github.com/mapbox/mapbox-gl-native/issues/3249))
### Map snapshots

View File

@@ -545,7 +545,13 @@
<action selector="drawAnimatedAnnotation:" target="-1" id="CYM-WB-s97"/>
</connections>
</menuItem>
<menuItem title="Show All Annnotations" keyEquivalent="A" id="yMj-uM-8SN">
<menuItem title="Select an Offscreen Point Annotation" id="Xy2-Cc-RUB">
<modifierMask key="keyEquivalentModifierMask"/>
<connections>
<action selector="selectOffscreenPointAnnotation:" target="-1" id="Fhm-l3-G6h"/>
</connections>
</menuItem>
<menuItem title="Show All Annotations" keyEquivalent="A" id="yMj-uM-8SN">
<modifierMask key="keyEquivalentModifierMask" shift="YES" command="YES"/>
<connections>
<action selector="showAllAnnotations:" target="-1" id="ahr-OR-Em2"/>
@@ -664,7 +670,7 @@ CA
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="109" y="131" width="350" height="84"/>
<rect key="screenRect" x="0.0" y="0.0" width="1280" height="777"/>
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="877"/>
<view key="contentView" id="eA4-n3-qPe">
<rect key="frame" x="0.0" y="0.0" width="350" height="84"/>
<autoresizingMask key="autoresizingMask"/>
@@ -740,7 +746,7 @@ CA
<windowStyleMask key="styleMask" titled="YES" closable="YES" miniaturizable="YES" resizable="YES" utility="YES"/>
<windowPositionMask key="initialPositionMask" leftStrut="YES" rightStrut="YES" topStrut="YES" bottomStrut="YES"/>
<rect key="contentRect" x="830" y="430" width="400" height="300"/>
<rect key="screenRect" x="0.0" y="0.0" width="1280" height="777"/>
<rect key="screenRect" x="0.0" y="0.0" width="1440" height="877"/>
<view key="contentView" id="8ha-hw-zOD">
<rect key="frame" x="0.0" y="0.0" width="400" height="300"/>
<autoresizingMask key="autoresizingMask"/>
@@ -748,11 +754,11 @@ CA
<scrollView autohidesScrollers="YES" horizontalLineScroll="19" horizontalPageScroll="10" verticalLineScroll="19" verticalPageScroll="10" usesPredominantAxisScrolling="NO" translatesAutoresizingMaskIntoConstraints="NO" id="Q8b-0e-dLv">
<rect key="frame" x="-1" y="20" width="402" height="281"/>
<clipView key="contentView" id="J9U-Yx-o2S">
<rect key="frame" x="1" y="0.0" width="400" height="280"/>
<rect key="frame" x="1" y="0.0" width="400" height="265"/>
<autoresizingMask key="autoresizingMask"/>
<subviews>
<tableView verticalHuggingPriority="750" allowsExpansionToolTips="YES" columnAutoresizingStyle="lastColumnOnly" autosaveColumns="NO" headerView="MAZ-Iq-hBi" id="Ato-Vu-HYT">
<rect key="frame" x="0.0" y="0.0" width="423" height="257"/>
<rect key="frame" x="0.0" y="0.0" width="423" height="242"/>
<autoresizingMask key="autoresizingMask"/>
<size key="intercellSpacing" width="3" height="2"/>
<color key="backgroundColor" name="controlBackgroundColor" catalog="System" colorSpace="catalog"/>
@@ -891,7 +897,7 @@ CA
</subviews>
</clipView>
<scroller key="horizontalScroller" verticalHuggingPriority="750" horizontal="YES" id="QLr-6P-Ogs">
<rect key="frame" x="1" y="264" width="400" height="16"/>
<rect key="frame" x="1" y="265" width="400" height="15"/>
<autoresizingMask key="autoresizingMask"/>
</scroller>
<scroller key="verticalScroller" hidden="YES" verticalHuggingPriority="750" horizontal="NO" id="q0K-eE-mzL">

View File

@@ -641,6 +641,51 @@ NS_ARRAY_OF(id <MGLAnnotation>) *MBXFlattenedShapes(NS_ARRAY_OF(id <MGLAnnotatio
repeats:YES];
}
- (id<MGLAnnotation>)randomOffscreenPointAnnotation {
NSPredicate *pointAnnotationPredicate = [NSPredicate predicateWithBlock:^BOOL(id _Nullable evaluatedObject, NSDictionary<NSString *,id> * _Nullable bindings) {
return [evaluatedObject isKindOfClass:[MGLPointAnnotation class]];
}];
NSArray *annotations = [self.mapView.annotations filteredArrayUsingPredicate:pointAnnotationPredicate];
if (annotations.count == 0) {
return nil;
}
// NOTE: self.mapView.visibleAnnotations occasionally returns nil - see
// https://github.com/mapbox/mapbox-gl-native/issues/11296
NSArray *visibleAnnotations = [self.mapView.visibleAnnotations filteredArrayUsingPredicate:pointAnnotationPredicate];
NSLog(@"Number of visible point annotations = %ld", visibleAnnotations.count);
if (visibleAnnotations.count == annotations.count) {
return nil;
}
NSMutableArray *invisibleAnnotations = [annotations mutableCopy];
if (visibleAnnotations.count > 0) {
[invisibleAnnotations removeObjectsInArray:visibleAnnotations];
}
// Now pick a random offscreen annotation.
uint32_t index = arc4random_uniform((uint32_t)invisibleAnnotations.count);
return invisibleAnnotations[index];
}
- (IBAction)selectOffscreenPointAnnotation:(id)sender {
id<MGLAnnotation> annotation = [self randomOffscreenPointAnnotation];
if (annotation) {
[self.mapView selectAnnotation:annotation];
// Alternative method to select the annotation. These two should do the same thing.
// self.mapView.selectedAnnotations = @[annotation];
NSAssert(self.mapView.selectedAnnotations.firstObject, @"The annotation was not selected");
}
}
- (void)updateAnimatedAnnotation:(NSTimer *)timer {
DroppedPinAnnotation *annotation = timer.userInfo;
double angle = timer.fireDate.timeIntervalSinceReferenceDate;
@@ -1111,6 +1156,9 @@ NS_ARRAY_OF(id <MGLAnnotation>) *MBXFlattenedShapes(NS_ARRAY_OF(id <MGLAnnotatio
if (menuItem.action == @selector(insertGraticuleLayer:)) {
return ![self.mapView.style sourceWithIdentifier:@"graticule"];
}
if (menuItem.action == @selector(selectOffscreenPointAnnotation:)) {
return YES;
}
if (menuItem.action == @selector(showAllAnnotations:) || menuItem.action == @selector(removeAllAnnotations:)) {
return self.mapView.annotations.count > 0;
}

View File

@@ -721,16 +721,27 @@ MGL_EXPORT IB_DESIGNABLE
Assigning a new array to this property selects only the first annotation in the
array.
If the annotation is of type `MGLPointAnnotation` and is offscreen, the map is
panned so that the annotation and its callout are brought just onscreen. The
annotation is *not* centered within the viewport.
@note In versions prior to `4.0.0` if the annotation was offscreen it was not
selected.
*/
@property (nonatomic, copy) NS_ARRAY_OF(id <MGLAnnotation>) *selectedAnnotations;
/**
Selects an annotation and displays a callout popover for it.
If the given annotation is not visible within the current viewport, this method
has no effect.
If the annotation is of type `MGLPointAnnotation` and is offscreen, the map is
panned so that the annotation and its callout are brought just onscreen. The
annotation is *not* centered within the viewport.
@param annotation The annotation object to select.
@note In versions prior to `4.0.0` selecting an offscreen annotation did not
change the camera.
*/
- (void)selectAnnotation:(id <MGLAnnotation>)annotation;

View File

@@ -97,6 +97,9 @@ const CGFloat MGLAnnotationImagePaddingForHitTest = 4;
/// Distance from the callout’s anchor point to the annotation it points to.
const CGFloat MGLAnnotationImagePaddingForCallout = 4;
/// Padding to edge of view that an offscreen annotation must have when being brought onscreen (by being selected)
const NSEdgeInsets MGLMapViewOffscreenAnnotationPadding = NSEdgeInsetsMake(-30.0f, -30.0f, -30.0f, -30.0f);
/// Unique identifier representing a single annotation in mbgl.
typedef uint32_t MGLAnnotationTag;
@@ -2205,10 +2208,12 @@ public:
return;
}
// Select the annotation if it’s visible.
if (MGLCoordinateInCoordinateBounds(firstAnnotation.coordinate, self.visibleCoordinateBounds)) {
[self selectAnnotation:firstAnnotation];
}
[self selectAnnotation:firstAnnotation];
}
- (BOOL)isBringingAnnotationOnscreenSupportedForAnnotation:(id<MGLAnnotation>)annotation animated:(BOOL)animated {
// Consider delegating
return animated && [annotation isKindOfClass:[MGLPointAnnotation class]];
}
- (void)selectAnnotation:(id <MGLAnnotation>)annotation
@@ -2217,6 +2222,11 @@ public:
}
- (void)selectAnnotation:(id <MGLAnnotation>)annotation atPoint:(NSPoint)gesturePoint
{
[self selectAnnotation:annotation atPoint:gesturePoint moveOnscreen:YES animateSelection:YES];
}
- (void)selectAnnotation:(id <MGLAnnotation>)annotation atPoint:(NSPoint)gesturePoint moveOnscreen:(BOOL)moveOnscreen animateSelection:(BOOL)animateSelection
{
id <MGLAnnotation> selectedAnnotation = self.selectedAnnotation;
if (annotation == selectedAnnotation) {
@@ -2232,9 +2242,14 @@ public:
[self addAnnotation:annotation];
}
if (moveOnscreen) {
moveOnscreen = [self isBringingAnnotationOnscreenSupportedForAnnotation:annotation animated:animateSelection];
}
// The annotation's anchor will bounce to the current click.
NSRect positioningRect = [self positioningRectForCalloutForAnnotationWithTag:annotationTag];
if (NSIsEmptyRect(NSIntersectionRect(positioningRect, self.bounds))) {
if (!moveOnscreen && NSIsEmptyRect(NSIntersectionRect(positioningRect, self.bounds))) {
positioningRect = CGRectMake(gesturePoint.x, gesturePoint.y, positioningRect.size.width, positioningRect.size.height);
}
@@ -2254,11 +2269,65 @@ public:
// alignment rect, or off the left edge in a right-to-left UI.
callout.delegate = self;
self.calloutForSelectedAnnotation = callout;
NSRectEdge edge = (self.userInterfaceLayoutDirection == NSUserInterfaceLayoutDirectionRightToLeft
? NSMinXEdge
: NSMaxXEdge);
// The following will do nothing if the positioning rect is not on-screen. See
// `-[MGLMapView updateAnnotationCallouts]` for presenting the callout when the selected
// annotation comes back on-screen.
[callout showRelativeToRect:positioningRect ofView:self preferredEdge:edge];
}
if (moveOnscreen)
{
moveOnscreen = NO;
NSRect (^edgeInsetsInsetRect)(NSRect, NSEdgeInsets) = ^(NSRect rect, NSEdgeInsets insets) {
return NSMakeRect(rect.origin.x + insets.left,
rect.origin.y + insets.top,
rect.size.width - insets.left - insets.right,
rect.size.height - insets.top - insets.bottom);
};
// Add padding around the positioning rect (in essence an inset from the edge of the viewport
NSRect expandedPositioningRect = edgeInsetsInsetRect(positioningRect, MGLMapViewOffscreenAnnotationPadding);
// Used for callout positioning, and moving offscreen annotations onscreen.
CGRect constrainedRect = edgeInsetsInsetRect(self.bounds, self.contentInsets);
CGRect bounds = constrainedRect;
// Any one of these cases should trigger a move onscreen
if (CGRectGetMinX(positioningRect) < CGRectGetMinX(bounds))
{
constrainedRect.origin.x = expandedPositioningRect.origin.x;
moveOnscreen = YES;
}
else if (CGRectGetMaxX(positioningRect) > CGRectGetMaxX(bounds))
{
constrainedRect.origin.x = CGRectGetMaxX(expandedPositioningRect) - constrainedRect.size.width;
moveOnscreen = YES;
}
if (CGRectGetMinY(positioningRect) < CGRectGetMinY(bounds))
{
constrainedRect.origin.y = expandedPositioningRect.origin.y;
moveOnscreen = YES;
}
else if (CGRectGetMaxY(positioningRect) > CGRectGetMaxY(bounds))
{
constrainedRect.origin.y = CGRectGetMaxY(expandedPositioningRect) - constrainedRect.size.height;
moveOnscreen = YES;
}
if (moveOnscreen)
{
CGPoint center = CGPointMake(CGRectGetMidX(constrainedRect), CGRectGetMidY(constrainedRect));
CLLocationCoordinate2D centerCoord = [self convertPoint:center toCoordinateFromView:self];
[self setCenterCoordinate:centerCoord animated:animateSelection];
}
}
}
- (void)showAnnotations:(NS_ARRAY_OF(id <MGLAnnotation>) *)annotations animated:(BOOL)animated {
@@ -2393,7 +2462,25 @@ public:
- (void)updateAnnotationCallouts {
NSPopover *callout = self.calloutForSelectedAnnotation;
if (callout) {
callout.positioningRect = [self positioningRectForCalloutForAnnotationWithTag:_selectedAnnotationTag];
NSRect rect = [self positioningRectForCalloutForAnnotationWithTag:_selectedAnnotationTag];
if (!NSIsEmptyRect(NSIntersectionRect(rect, self.bounds))) {
// It's possible that the current callout hasn't been presented (since the original
// positioningRect was offscreen). We can check that the callout has a valid window
// This results in the callout being presented just as the annotation comes on screen
// which matches MapKit, but (currently) not iOS.
if (!callout.contentViewController.view.window) {
NSRectEdge edge = (self.userInterfaceLayoutDirection == NSUserInterfaceLayoutDirectionRightToLeft
? NSMinXEdge
: NSMaxXEdge);
// Re-present the callout
[callout showRelativeToRect:rect ofView:self preferredEdge:edge];
}
else {
callout.positioningRect = rect;
}
}
}
}