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