diff --git a/Examples/UIExplorer/MapViewExample.js b/Examples/UIExplorer/MapViewExample.js index 57201757475..9a7e2e1aab2 100644 --- a/Examples/UIExplorer/MapViewExample.js +++ b/Examples/UIExplorer/MapViewExample.js @@ -149,9 +149,6 @@ var MapViewExample = React.createClass({ getInitialState() { return { - mapRegion: null, - mapRegionInput: null, - annotations: null, isFirstLoad: true, }; }, @@ -163,12 +160,12 @@ var MapViewExample = React.createClass({ style={styles.map} onRegionChange={this._onRegionChange} onRegionChangeComplete={this._onRegionChangeComplete} - region={this.state.mapRegion || undefined} - annotations={this.state.annotations || undefined} + region={this.state.mapRegion} + annotations={this.state.annotations} /> ); @@ -208,6 +205,114 @@ var MapViewExample = React.createClass({ }); +var CalloutMapViewExample = React.createClass({ + + getInitialState() { + return { + isFirstLoad: true, + }; + }, + + render() { + if (this.state.isFirstLoad) { + var onRegionChangeComplete = (region) => { + this.setState({ + isFirstLoad: false, + annotations: [{ + longitude: region.longitude, + latitude: region.latitude, + title: 'More Info...', + hasRightCallout: true, + onRightCalloutPress: () => { + alert('You Are Here'); + }, + }], + }); + }; + } + + return ( + + ); + }, + +}); + +var CustomPinColorMapViewExample = React.createClass({ + + getInitialState() { + return { + isFirstLoad: true, + }; + }, + + render() { + if (this.state.isFirstLoad) { + var onRegionChangeComplete = (region) => { + this.setState({ + isFirstLoad: false, + annotations: [{ + longitude: region.longitude, + latitude: region.latitude, + title: 'You Are Purple', + tintColor: MapView.PinColors.PURPLE, + }], + }); + }; + } + + return ( + + ); + }, + +}); + +var CustomPinImageMapViewExample = React.createClass({ + + getInitialState() { + return { + isFirstLoad: true, + }; + }, + + render() { + if (this.state.isFirstLoad) { + var onRegionChangeComplete = (region) => { + this.setState({ + isFirstLoad: false, + annotations: [{ + longitude: region.longitude, + latitude: region.latitude, + title: 'Thumbs Up!', + image: require('image!uie_thumb_big'), + }], + }); + }; + } + + return ( + + ); + }, + +}); + var styles = StyleSheet.create({ map: { height: 150, @@ -249,5 +354,23 @@ exports.examples = [ render() { return ; } - } + }, + { + title: 'Callout example', + render() { + return ; + } + }, + { + title: 'Custom pin color', + render() { + return ; + } + }, + { + title: 'Custom pin image', + render() { + return ; + } + }, ]; diff --git a/Libraries/Components/MapView/MapView.js b/Libraries/Components/MapView/MapView.js index 58950c49071..26cd3f61ee6 100644 --- a/Libraries/Components/MapView/MapView.js +++ b/Libraries/Components/MapView/MapView.js @@ -12,7 +12,9 @@ 'use strict'; var EdgeInsetsPropType = require('EdgeInsetsPropType'); +var Image = require('Image'); var NativeMethodsMixin = require('NativeMethodsMixin'); +var PinColors = require('NativeModules').UIManager.RCTMap.Constants.PinColors; var Platform = require('Platform'); var React = require('React'); var ReactNativeViewAttributes = require('ReactNativeViewAttributes'); @@ -21,6 +23,8 @@ var View = require('View'); var deepDiffer = require('deepDiffer'); var insetsDiffer = require('insetsDiffer'); var merge = require('merge'); +var processColor = require('processColor'); +var resolveAssetSource = require('resolveAssetSource'); var requireNativeComponent = require('requireNativeComponent'); type Event = Object; @@ -194,7 +198,21 @@ var MapView = React.createClass({ /** * annotation id */ - id: React.PropTypes.string + id: React.PropTypes.string, + + /** + * The pin color. This can be any valid color string, or you can use one + * of the predefined PinColors constants. Applies to both standard pins + * and custom pin images. + * @platform ios + */ + tintColor: React.PropTypes.string, + + /** + * Custom pin image. This must be a static image resource inside the app. + * @platform ios + */ + image: Image.propTypes.source, })), @@ -235,47 +253,79 @@ var MapView = React.createClass({ active: React.PropTypes.bool, }, - _onChange: function(event: Event) { - if (event.nativeEvent.continuous) { - this.props.onRegionChange && - this.props.onRegionChange(event.nativeEvent.region); - } else { - this.props.onRegionChangeComplete && - this.props.onRegionChangeComplete(event.nativeEvent.region); - } - }, + render: function() { - _onPress: function(event: Event) { - if (event.nativeEvent.action === 'annotation-click') { - this.props.onAnnotationPress && this.props.onAnnotationPress(event.nativeEvent.annotation); - } + let {annotations} = this.props; + annotations = annotations && annotations.map((annotation: Object) => { + let {tintColor, image} = annotation; + return { + ...annotation, + tintColor: tintColor && processColor(tintColor), + image: image && resolveAssetSource(image), + }; + }); - if (event.nativeEvent.action === 'callout-click') { - if (!this.props.annotations) { - return; - } - - // Find the annotation with the id of what has been pressed - for (var i = 0; i < this.props.annotations.length; i++) { - var annotation = this.props.annotations[i]; - if (annotation.id === event.nativeEvent.annotationId) { - // Pass the right function - if (event.nativeEvent.side === 'left') { - annotation.onLeftCalloutPress && annotation.onLeftCalloutPress(event.nativeEvent); - } else if (event.nativeEvent.side === 'right') { - annotation.onRightCalloutPress && annotation.onRightCalloutPress(event.nativeEvent); + // TODO: these should be separate events, to reduce bridge traffic + if (annotations || this.props.onAnnotationPress) { + var onPress = (event: Event) => { + if (event.nativeEvent.action === 'annotation-click') { + this.props.onAnnotationPress && + this.props.onAnnotationPress(event.nativeEvent.annotation); + } else if (event.nativeEvent.action === 'callout-click') { + // Find the annotation with the id that was pressed + for (let i = 0, l = annotations.length; i < l; i++) { + let annotation = annotations[i]; + if (annotation.id === event.nativeEvent.annotationId) { + // Pass the right function + if (event.nativeEvent.side === 'left') { + annotation.onLeftCalloutPress && + annotation.onLeftCalloutPress(event.nativeEvent); + } else if (event.nativeEvent.side === 'right') { + annotation.onRightCalloutPress && + annotation.onRightCalloutPress(event.nativeEvent); + } + break; + } } } - } - + }; } - }, - render: function() { - return ; + // TODO: these should be separate events, to reduce bridge traffic + if (this.props.onRegionChange || this.props.onRegionChangeComplete) { + var onChange = (event: Event) => { + if (event.nativeEvent.continuous) { + this.props.onRegionChange && + this.props.onRegionChange(event.nativeEvent.region); + } else { + this.props.onRegionChangeComplete && + this.props.onRegionChangeComplete(event.nativeEvent.region); + } + }; + } + + return ( + + ); }, }); +/** + * Standard iOS MapView pin color constants, to be used with the + * `annotation.tintColor` property. You are not obliged to use these, + * but they are useful for matching the standard iOS look and feel. + */ +MapView.PinColors = PinColors && { + RED: PinColors.RED, + GREEN: PinColors.GREEN, + PURPLE: PinColors.PURPLE, +}; + var RCTMap = requireNativeComponent('RCTMap', MapView, { nativeOnly: {onChange: true, onPress: true} }); diff --git a/React/Base/RCTConvert.m b/React/Base/RCTConvert.m index 53c6b9cad22..8fc14e102fb 100644 --- a/React/Base/RCTConvert.m +++ b/React/Base/RCTConvert.m @@ -397,6 +397,9 @@ RCT_CGSTRUCT_CONVERTER(CGAffineTransform, (@[ + (UIColor *)UIColor:(id)json { + if (!json) { + return nil; + } if ([json isKindOfClass:[NSArray class]]) { NSArray *components = [self NSNumberArray:json]; CGFloat alpha = components.count > 3 ? [self CGFloat:components[3]] : 1.0; @@ -404,13 +407,16 @@ RCT_CGSTRUCT_CONVERTER(CGAffineTransform, (@[ green:[self CGFloat:components[1]] blue:[self CGFloat:components[2]] alpha:alpha]; - } else { + } else if ([json isKindOfClass:[NSNumber class]]) { NSUInteger argb = [self NSUInteger:json]; CGFloat a = ((argb >> 24) & 0xFF) / 255.0; CGFloat r = ((argb >> 16) & 0xFF) / 255.0; CGFloat g = ((argb >> 8) & 0xFF) / 255.0; CGFloat b = (argb & 0xFF) / 255.0; return [UIColor colorWithRed:r green:g blue:b alpha:a]; + } else { + RCTLogConvertError(json, @"a color"); + return nil; } } @@ -460,12 +466,9 @@ RCT_CGSTRUCT_CONVERTER(CGAffineTransform, (@[ NSURL *URL = [self NSURL:path]; NSString *scheme = URL.scheme.lowercaseString; if ([scheme isEqualToString:@"file"]) { - if (RCTIsXCAssetURL(URL)) { - // Image may reside inside a .car file, in which case we have no choice - // but to use +[UIImage imageNamed] - but this method isn't thread safe - NSString *assetName = RCTBundlePathForURL(URL); - image = [UIImage imageNamed:assetName]; - } else { + NSString *assetName = RCTBundlePathForURL(URL); + image = [UIImage imageNamed:assetName]; + if (!image) { // Attempt to load from the file system NSString *filePath = URL.path; if (filePath.pathExtension.length == 0) { diff --git a/React/Base/RCTUtils.h b/React/Base/RCTUtils.h index e138da40531..e8f3781a6c6 100644 --- a/React/Base/RCTUtils.h +++ b/React/Base/RCTUtils.h @@ -84,6 +84,9 @@ RCT_EXTERN NSError *RCTErrorWithMessage(NSString *message); RCT_EXTERN id RCTNilIfNull(id value); RCT_EXTERN id RCTNullIfNil(id value); +// Convert NaN or infinite values to zero, as these aren't JSON-safe +RCT_EXTERN double RCTZeroIfNaN(double value); + // Convert data to a Base64-encoded data URL RCT_EXTERN NSURL *RCTDataURL(NSString *mimeType, NSData *data); @@ -96,3 +99,6 @@ RCT_EXTERN NSString *RCTBundlePathForURL(NSURL *URL); // Determines if a given image URL actually refers to an XCAsset RCT_EXTERN BOOL RCTIsXCAssetURL(NSURL *imageURL); + +// Converts a CGColor to a hex string +RCT_EXTERN NSString *RCTColorToHexString(CGColorRef color); diff --git a/React/Base/RCTUtils.m b/React/Base/RCTUtils.m index 8d6f3238d0c..b7a75148837 100644 --- a/React/Base/RCTUtils.m +++ b/React/Base/RCTUtils.m @@ -420,6 +420,11 @@ id RCTNilIfNull(id value) return value == (id)kCFNull ? nil : value; } +RCT_EXTERN double RCTZeroIfNaN(double value) +{ + return isnan(value) || isinf(value) ? 0 : value; +} + NSURL *RCTDataURL(NSString *mimeType, NSData *data) { return [NSURL URLWithString: @@ -511,3 +516,62 @@ BOOL RCTIsXCAssetURL(NSURL *imageURL) } return YES; } + +static void RCTGetRGBAColorComponents(CGColorRef color, CGFloat rgba[4]) +{ + CGColorSpaceModel model = CGColorSpaceGetModel(CGColorGetColorSpace(color)); + const CGFloat *components = CGColorGetComponents(color); + switch (model) + { + case kCGColorSpaceModelMonochrome: + { + rgba[0] = components[0]; + rgba[1] = components[0]; + rgba[2] = components[0]; + rgba[3] = components[1]; + break; + } + case kCGColorSpaceModelRGB: + { + rgba[0] = components[0]; + rgba[1] = components[1]; + rgba[2] = components[2]; + rgba[3] = components[3]; + break; + } + case kCGColorSpaceModelCMYK: + case kCGColorSpaceModelDeviceN: + case kCGColorSpaceModelIndexed: + case kCGColorSpaceModelLab: + case kCGColorSpaceModelPattern: + case kCGColorSpaceModelUnknown: + { + +#ifdef RCT_DEBUG + //unsupported format + RCTLogError(@"Unsupported color model: %i", model); +#endif + + rgba[0] = 0.0; + rgba[1] = 0.0; + rgba[2] = 0.0; + rgba[3] = 1.0; + break; + } + } +} + +NSString *RCTColorToHexString(CGColorRef color) +{ + CGFloat rgba[4]; + RCTGetRGBAColorComponents(color, rgba); + uint8_t r = rgba[0]*255; + uint8_t g = rgba[1]*255; + uint8_t b = rgba[2]*255; + uint8_t a = rgba[3]*255; + if (a < 255) { + return [NSString stringWithFormat:@"#%02x%02x%02x%02x", r, g, b, a]; + } else { + return [NSString stringWithFormat:@"#%02x%02x%02x", r, g, b]; + } +} diff --git a/React/Views/RCTConvert+MapKit.m b/React/Views/RCTConvert+MapKit.m index 9206c3a4050..dab5b766171 100644 --- a/React/Views/RCTConvert+MapKit.m +++ b/React/Views/RCTConvert+MapKit.m @@ -61,6 +61,11 @@ RCT_ENUM_CONVERTER(MKMapType, (@{ shape.hasLeftCallout = [RCTConvert BOOL:json[@"hasLeftCallout"]]; shape.hasRightCallout = [RCTConvert BOOL:json[@"hasRightCallout"]]; shape.animateDrop = [RCTConvert BOOL:json[@"animateDrop"]]; + shape.tintColor = [RCTConvert UIColor:json[@"tintColor"]]; + shape.image = [RCTConvert UIImage:json[@"image"]]; + if (shape.tintColor && shape.image) { + shape.image = [shape.image imageWithRenderingMode:UIImageRenderingModeAlwaysTemplate]; + } return shape; } diff --git a/React/Views/RCTMapManager.m b/React/Views/RCTMapManager.m index 5372793a816..75602578d33 100644 --- a/React/Views/RCTMapManager.m +++ b/React/Views/RCTMapManager.m @@ -14,6 +14,7 @@ #import "RCTConvert+MapKit.h" #import "RCTEventDispatcher.h" #import "RCTMap.h" +#import "RCTUtils.h" #import "UIView+React.h" #import "RCTPointAnnotation.h" @@ -55,6 +56,17 @@ RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, RCTMap) [view setRegion:json ? [RCTConvert MKCoordinateRegion:json] : defaultView.region animated:YES]; } +- (NSDictionary *)constantsToExport +{ + return @{ + @"PinColors": @{ + @"RED": RCTColorToHexString([MKPinAnnotationView redPinColor].CGColor), + @"GREEN": RCTColorToHexString([MKPinAnnotationView greenPinColor].CGColor), + @"PURPLE": RCTColorToHexString([MKPinAnnotationView purplePinColor].CGColor), + } + }; +} + #pragma mark MKMapViewDelegate - (void)mapView:(RCTMap *)mapView didSelectAnnotationView:(MKAnnotationView *)view @@ -81,10 +93,41 @@ RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, RCTMap) return nil; } - MKPinAnnotationView *annotationView = [[MKPinAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:@"RCTAnnotation"]; + MKAnnotationView *annotationView; + if (annotation.image) { + if (annotation.tintColor) { + NSString *const reuseIdentifier = @"RCTImageViewAnnotation"; + NSInteger imageViewTag = 99; + annotationView = [mapView dequeueReusableAnnotationViewWithIdentifier:reuseIdentifier]; + if (!annotationView) { + annotationView = [[MKAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:reuseIdentifier]; + UIImageView *imageView = [UIImageView new]; + imageView.tag = imageViewTag; + [annotationView addSubview:imageView]; + } + + UIImageView *imageView = (UIImageView *)[annotationView viewWithTag:imageViewTag]; + imageView.image = annotation.image; + imageView.tintColor = annotation.tintColor; + [imageView sizeToFit]; + imageView.center = CGPointZero; + + } else { + + NSString *reuseIdentifier = NSStringFromClass([MKAnnotationView class]); + annotationView = [mapView dequeueReusableAnnotationViewWithIdentifier:reuseIdentifier] ?: [[MKAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:reuseIdentifier]; + annotationView.image = annotation.image; + } + + } else { + + NSString *reuseIdentifier = NSStringFromClass([MKPinAnnotationView class]); + annotationView = [mapView dequeueReusableAnnotationViewWithIdentifier:reuseIdentifier] ?: [[MKPinAnnotationView alloc] initWithAnnotation:annotation reuseIdentifier:reuseIdentifier]; + ((MKPinAnnotationView *)annotationView).animatesDrop = annotation.animateDrop; + ((MKPinAnnotationView *)annotationView).pinTintColor = annotation.tintColor ?: [MKPinAnnotationView redPinColor]; + } annotationView.canShowCallout = true; - annotationView.animatesDrop = annotation.animateDrop; annotationView.leftCalloutAccessoryView = nil; if (annotation.hasLeftCallout) { @@ -172,13 +215,17 @@ RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, RCTMap) BOOL needZoom = NO; CGFloat newLongitudeDelta = 0.0f; MKCoordinateRegion region = mapView.region; - // On iOS 7, it's possible that we observe invalid locations during initialization of the map. - // Filter those out. + + // On iOS 7, it's possible that we observe invalid locations during + // initialization of the map. Filter those out. if (!CLLocationCoordinate2DIsValid(region.center)) { return; } - // Calculation on float is not 100% accurate. If user zoom to max/min and then move, it's likely the map will auto zoom to max/min from time to time. - // So let's try to make map zoom back to 99% max or 101% min so that there are some buffer that moving the map won't constantly hitting the max/min bound. + + // Calculation on float is not 100% accurate. If user zoom to max/min and then + // move, it's likely the map will auto zoom to max/min from time to time. + // So let's try to make map zoom back to 99% max or 101% min so that there is + // some buffer, and moving the map won't constantly hit the max/min bound. if (mapView.maxDelta > FLT_EPSILON && region.span.longitudeDelta > mapView.maxDelta) { needZoom = YES; newLongitudeDelta = mapView.maxDelta * (1 - RCTMapZoomBoundBuffer); @@ -204,15 +251,13 @@ RCT_CUSTOM_VIEW_PROPERTY(region, MKCoordinateRegion, RCTMap) return; } - #define FLUSH_NAN(value) (isnan(value) ? 0 : value) - mapView.onChange(@{ @"continuous": @(continuous), @"region": @{ - @"latitude": @(FLUSH_NAN(region.center.latitude)), - @"longitude": @(FLUSH_NAN(region.center.longitude)), - @"latitudeDelta": @(FLUSH_NAN(region.span.latitudeDelta)), - @"longitudeDelta": @(FLUSH_NAN(region.span.longitudeDelta)), + @"latitude": @(RCTZeroIfNaN(region.center.latitude)), + @"longitude": @(RCTZeroIfNaN(region.center.longitude)), + @"latitudeDelta": @(RCTZeroIfNaN(region.span.latitudeDelta)), + @"longitudeDelta": @(RCTZeroIfNaN(region.span.longitudeDelta)), } }); } diff --git a/React/Views/RCTPointAnnotation.h b/React/Views/RCTPointAnnotation.h index 0646608d480..1ee8fb05508 100644 --- a/React/Views/RCTPointAnnotation.h +++ b/React/Views/RCTPointAnnotation.h @@ -15,5 +15,7 @@ @property (nonatomic, assign) BOOL hasLeftCallout; @property (nonatomic, assign) BOOL hasRightCallout; @property (nonatomic, assign) BOOL animateDrop; +@property (nonatomic, strong) UIColor *tintColor; +@property (nonatomic, strong) UIImage *image; @end