1/** 2 * Copyright (c) 2015-present, Facebook, Inc. 3 * All rights reserved. 4 * 5 * This source code is licensed under the BSD-style license found in the 6 * LICENSE file in the root directory of this source tree. An additional grant 7 * of patent rights can be found in the PATENTS file in the same directory. 8 */ 9 10#import "AIRMapMarker.h" 11 12#import <React/RCTBridge.h> 13#import <React/RCTEventDispatcher.h> 14#import <React/RCTImageLoaderProtocol.h> 15#import <React/RCTUtils.h> 16#import <React/UIView+React.h> 17 18NSInteger const AIR_CALLOUT_OPEN_ZINDEX_BASELINE = 999; 19 20@implementation AIREmptyCalloutBackgroundView 21@end 22 23@implementation AIRMapMarker { 24 BOOL _hasSetCalloutOffset; 25 RCTImageLoaderCancellationBlock _reloadImageCancellationBlock; 26 MKPinAnnotationView *_pinView; 27 BOOL _calloutIsOpen; 28 NSInteger _zIndexBeforeOpen; 29} 30 31- (instancetype)initWithFrame:(CGRect)frame { 32 self = [super initWithFrame:frame]; 33 if (self) { 34 [self.layer addObserver:self forKeyPath:@"zPosition" options:NSKeyValueObservingOptionNew context:nil]; 35 } 36 return self; 37} 38 39- (void)reactSetFrame:(CGRect)frame 40{ 41 // Make sure we use the image size when available 42 CGSize size = self.image ? self.image.size : frame.size; 43 CGRect bounds = {CGPointZero, size}; 44 45 // The MapView is basically in charge of figuring out the center position of the marker view. If the view changed in 46 // height though, we need to compensate in such a way that the bottom of the marker stays at the same spot on the 47 // map. 48 CGFloat dy = (bounds.size.height - self.bounds.size.height) / 2; 49 CGPoint center = (CGPoint){ self.center.x, self.center.y - dy }; 50 51 // Avoid crashes due to nan coords 52 if (isnan(center.x) || isnan(center.y) || 53 isnan(bounds.origin.x) || isnan(bounds.origin.y) || 54 isnan(bounds.size.width) || isnan(bounds.size.height)) { 55 RCTLogError(@"Invalid layout for (%@)%@. position: %@. bounds: %@", 56 self.reactTag, self, NSStringFromCGPoint(center), NSStringFromCGRect(bounds)); 57 return; 58 } 59 60 self.center = center; 61 self.bounds = bounds; 62} 63 64- (void)insertReactSubview:(id<RCTComponent>)subview atIndex:(NSInteger)atIndex { 65 if ([subview isKindOfClass:[AIRMapCallout class]]) { 66 self.calloutView = (AIRMapCallout *)subview; 67 } else { 68 [super insertReactSubview:(UIView *)subview atIndex:atIndex]; 69 } 70} 71 72- (void)removeReactSubview:(id<RCTComponent>)subview { 73 if ([subview isKindOfClass:[AIRMapCallout class]] && self.calloutView == subview) { 74 self.calloutView = nil; 75 } else { 76 [super removeReactSubview:(UIView *)subview]; 77 } 78} 79 80- (MKAnnotationView *)getAnnotationView 81{ 82 if ([self shouldUsePinView]) { 83 // In this case, we want to render a platform "default" marker. 84 if (_pinView == nil) { 85 _pinView = [[MKPinAnnotationView alloc] initWithAnnotation:self reuseIdentifier: nil]; 86 [self addGestureRecognizerToView:_pinView]; 87 _pinView.annotation = self; 88 } 89 90 _pinView.draggable = self.draggable; 91 _pinView.layer.zPosition = self.zIndex; 92 93 // TODO(lmr): Looks like this API was introduces in iOS 8. We may want to handle differently for earlier 94 // versions. Right now it's just leaving it with the default color. People needing the colors are free to 95 // use their own custom markers. 96 if ([_pinView respondsToSelector:@selector(setPinTintColor:)]) { 97 _pinView.pinTintColor = self.pinColor; 98 } 99 100 return _pinView; 101 } else { 102 // If it has subviews, it means we are wanting to render a custom marker with arbitrary react views. 103 // if it has a non-null image, it means we want to render a custom marker with the image. 104 // In either case, we want to return the AIRMapMarker since it is both an MKAnnotation and an 105 // MKAnnotationView all at the same time. 106 self.layer.zPosition = self.zIndex; 107 return self; 108 } 109} 110 111- (void)fillCalloutView:(SMCalloutView *)calloutView 112{ 113 // Set everything necessary on the calloutView before it becomes visible. 114 115 // Apply the MKAnnotationView's desired calloutOffset (from the top-middle of the view) 116 if ([self shouldUsePinView] && !_hasSetCalloutOffset) { 117 calloutView.calloutOffset = CGPointMake(-8,0); 118 } else { 119 calloutView.calloutOffset = self.calloutOffset; 120 } 121 122 if (self.calloutView) { 123 calloutView.title = nil; 124 calloutView.subtitle = nil; 125 if (self.calloutView.tooltip) { 126 // if tooltip is true, then the user wants their react view to be the "tooltip" as wwell, so we set 127 // the background view to something empty/transparent 128 calloutView.backgroundView = [AIREmptyCalloutBackgroundView new]; 129 } else { 130 // the default tooltip look is wanted, and the user is just filling the content with their react subviews. 131 // as a result, we use the default "masked" background view. 132 calloutView.backgroundView = [SMCalloutMaskedBackgroundView new]; 133 } 134 135 // when this is set, the callout's content will be whatever react views the user has put as the callout's 136 // children. 137 calloutView.contentView = self.calloutView; 138 139 } else { 140 141 // if there is no calloutView, it means the user wants to use the default callout behavior with title/subtitle 142 // pairs. 143 calloutView.title = self.title; 144 calloutView.subtitle = self.subtitle; 145 calloutView.contentView = nil; 146 calloutView.backgroundView = [SMCalloutMaskedBackgroundView new]; 147 } 148} 149 150- (void)showCalloutView 151{ 152 _calloutIsOpen = YES; 153 [self setZIndex:_zIndexBeforeOpen]; 154 155 MKAnnotationView *annotationView = [self getAnnotationView]; 156 157 [self setSelected:YES animated:NO]; 158 [self.map selectAnnotation:self animated:NO]; 159 160 id event = @{ 161 @"action": @"marker-select", 162 @"id": self.identifier ?: @"unknown", 163 @"coordinate": @{ 164 @"latitude": @(self.coordinate.latitude), 165 @"longitude": @(self.coordinate.longitude) 166 } 167 }; 168 169 if (self.map.onMarkerSelect) self.map.onMarkerSelect(event); 170 if (self.onSelect) self.onSelect(event); 171 172 if (![self shouldShowCalloutView]) { 173 // no callout to show 174 return; 175 } 176 177 [self fillCalloutView:self.map.calloutView]; 178 179 // This is where we present our custom callout view... MapKit's built-in callout doesn't have the flexibility 180 // we need, but a lot of work was done by Nick Farina to make this identical to MapKit's built-in. 181 [self.map.calloutView presentCalloutFromRect:annotationView.bounds 182 inView:annotationView 183 constrainedToView:self.map 184 animated:YES]; 185} 186 187#pragma mark - Tap Gesture & Events. 188 189- (void)addTapGestureRecognizer { 190 [self addGestureRecognizerToView:nil]; 191} 192 193- (void)addGestureRecognizerToView:(UIView *)view { 194 if (!view) { 195 view = self; 196 } 197 UITapGestureRecognizer *tapGestureRecognizer = [[UITapGestureRecognizer alloc] initWithTarget:self action:@selector(_handleTap:)]; 198 // setting this to NO allows the parent MapView to continue receiving marker selection events 199 tapGestureRecognizer.cancelsTouchesInView = NO; 200 [view addGestureRecognizer:tapGestureRecognizer]; 201} 202 203- (void)_handleTap:(UITapGestureRecognizer *)recognizer { 204 AIRMapMarker *marker = self; 205 if (!marker) return; 206 207 if (marker.selected) { 208 CGPoint touchPoint = [recognizer locationInView:marker.map.calloutView]; 209 CGRect bubbleFrame = [self.calloutView convertRect:marker.map.calloutView.bounds toView:marker.map]; 210 CGPoint touchPointReal = [recognizer locationInView:self.calloutView]; 211 212 UIView *calloutView = [marker.map.calloutView hitTest:touchPoint withEvent:nil]; 213 if (calloutView) { 214 // the callout (or its subview) got clicked, not the marker 215 UIWindow* win = [[[UIApplication sharedApplication] windows] firstObject]; 216 AIRMapCalloutSubview* calloutSubview = nil; 217 UIView* tmp = calloutView; 218 while (tmp && tmp != win && tmp != self.calloutView && tmp != self.map) { 219 if ([tmp respondsToSelector:@selector(onPress)]) { 220 calloutSubview = (AIRMapCalloutSubview*) tmp; 221 break; 222 } 223 tmp = tmp.superview; 224 } 225 226 id event = @{ 227 @"action": calloutSubview ? @"callout-inside-press" : @"callout-press", 228 @"id": marker.identifier ?: @"unknown", 229 @"point": @{ 230 @"x": @(touchPointReal.x), 231 @"y": @(touchPointReal.y), 232 }, 233 @"frame": @{ 234 @"x": @(bubbleFrame.origin.x), 235 @"y": @(bubbleFrame.origin.y), 236 @"width": @(bubbleFrame.size.width), 237 @"height": @(bubbleFrame.size.height), 238 } 239 }; 240 241 if (calloutSubview) calloutSubview.onPress(event); 242 if (marker.onCalloutPress) marker.onCalloutPress(event); 243 if (marker.calloutView && marker.calloutView.onPress) marker.calloutView.onPress(event); 244 if (marker.map.onCalloutPress) marker.map.onCalloutPress(event); 245 return; 246 } 247 } 248 249 // the actual marker got clicked 250 id event = @{ 251 @"action": @"marker-press", 252 @"id": marker.identifier ?: @"unknown", 253 @"coordinate": @{ 254 @"latitude": @(marker.coordinate.latitude), 255 @"longitude": @(marker.coordinate.longitude) 256 } 257 }; 258 259 if (marker.onPress) marker.onPress(event); 260 if (marker.map.onMarkerPress) marker.map.onMarkerPress(event); 261 262 [marker.map selectAnnotation:marker animated:NO]; 263} 264 265- (void)hideCalloutView 266{ 267 _calloutIsOpen = NO; 268 [self setZIndex:_zIndexBeforeOpen]; 269 // hide the callout view 270 [self.map.calloutView dismissCalloutAnimated:YES]; 271 272 [self setSelected:NO animated:NO]; 273 [self.map deselectAnnotation:self animated:NO]; 274 275 id event = @{ 276 @"action": @"marker-deselect", 277 @"id": self.identifier ?: @"unknown", 278 @"coordinate": @{ 279 @"latitude": @(self.coordinate.latitude), 280 @"longitude": @(self.coordinate.longitude) 281 } 282 }; 283 284 if (self.map.onMarkerDeselect) self.map.onMarkerDeselect(event); 285 if (self.onDeselect) self.onDeselect(event); 286} 287 288- (void)setCalloutOffset:(CGPoint)calloutOffset 289{ 290 _hasSetCalloutOffset = YES; 291 [super setCalloutOffset:calloutOffset]; 292} 293 294- (BOOL)shouldShowCalloutView 295{ 296 return self.calloutView != nil || self.title != nil || self.subtitle != nil; 297} 298 299- (BOOL)shouldUsePinView 300{ 301 return self.reactSubviews.count == 0 && !self.imageSrc; 302} 303 304- (void)setOpacity:(double)opacity 305{ 306 [self setAlpha:opacity]; 307} 308 309- (void)setImageSrc:(NSString *)imageSrc 310{ 311 _imageSrc = imageSrc; 312 313 if (_reloadImageCancellationBlock) { 314 _reloadImageCancellationBlock(); 315 _reloadImageCancellationBlock = nil; 316 } 317 _reloadImageCancellationBlock = [[_bridge moduleForName:@"ImageLoader"] loadImageWithURLRequest:[RCTConvert NSURLRequest:_imageSrc] 318 size:self.bounds.size 319 scale:RCTScreenScale() 320 clipped:YES 321 resizeMode:RCTResizeModeCenter 322 progressBlock:nil 323 partialLoadBlock:nil 324 completionBlock:^(NSError *error, UIImage *image) { 325 if (error) { 326 // TODO(lmr): do something with the error? 327 NSLog(@"%@", error); 328 } 329 dispatch_async(dispatch_get_main_queue(), ^{ 330 self.image = image; 331 }); 332 }]; 333} 334 335- (void)setPinColor:(UIColor *)pinColor 336{ 337 _pinColor = pinColor; 338 339 if ([_pinView respondsToSelector:@selector(setPinTintColor:)]) { 340 _pinView.pinTintColor = _pinColor; 341 } 342} 343 344- (void)setZIndex:(NSInteger)zIndex 345{ 346 _zIndexBeforeOpen = zIndex; 347 _zIndex = _calloutIsOpen ? zIndex + AIR_CALLOUT_OPEN_ZINDEX_BASELINE : zIndex; 348 self.layer.zPosition = zIndex; 349} 350 351- (BOOL)isSelected { 352 return _isPreselected || [super isSelected]; 353} 354 355- (void)dealloc { 356 [self.layer removeObserver:self forKeyPath:@"zPosition"]; 357} 358 359- (void)observeValueForKeyPath:(NSString *)keyPath ofObject:(id)object change:(NSDictionary<NSKeyValueChangeKey,id> *)change context:(void *)context { 360 if ([keyPath isEqualToString:@"zPosition"]) { 361 self.layer.zPosition = _zIndex; 362 } 363} 364 365@end 366