diff --git a/Examples/UIExplorer/MapViewExample.js b/Examples/UIExplorer/MapViewExample.js
new file mode 100644
index 00000000000000..2094624be45abe
--- /dev/null
+++ b/Examples/UIExplorer/MapViewExample.js
@@ -0,0 +1,196 @@
+/**
+ * Copyright 2004-present Facebook. All Rights Reserved.
+ *
+ * @providesModule MapViewExample
+ */
+'use strict';
+
+var React = require('react-native');
+var StyleSheet = require('StyleSheet');
+var {
+ MapView,
+ Text,
+ TextInput,
+ View,
+} = React;
+
+var MapRegionInput = React.createClass({
+
+ propTypes: {
+ region: React.PropTypes.shape({
+ latitude: React.PropTypes.number,
+ longitude: React.PropTypes.number,
+ latitudeDelta: React.PropTypes.number,
+ longitudeDelta: React.PropTypes.number,
+ }),
+ onChange: React.PropTypes.func.isRequired,
+ },
+
+ getInitialState: function() {
+ return {
+ latitude: 0,
+ longitude: 0,
+ latitudeDelta: 0,
+ longitudeDelta: 0,
+ };
+ },
+
+ componentWillReceiveProps: function(nextProps) {
+ this.setState(nextProps.region);
+ },
+
+ render: function() {
+ var region = this.state;
+ return (
+
+
+
+ {'Latitude'}
+
+
+
+
+
+ {'Longitude'}
+
+
+
+
+
+ {'Latitude delta'}
+
+
+
+
+
+ {'Longitude delta'}
+
+
+
+
+
+ {'Change'}
+
+
+
+ );
+ },
+
+ _onChangeLatitude: function(e) {
+ this.setState({latitude: parseFloat(e.nativeEvent.text)});
+ },
+
+ _onChangeLongitude: function(e) {
+ this.setState({longitude: parseFloat(e.nativeEvent.text)});
+ },
+
+ _onChangeLatitudeDelta: function(e) {
+ this.setState({latitudeDelta: parseFloat(e.nativeEvent.text)});
+ },
+
+ _onChangeLongitudeDelta: function(e) {
+ this.setState({longitudeDelta: parseFloat(e.nativeEvent.text)});
+ },
+
+ _change: function() {
+ this.props.onChange(this.state);
+ },
+
+});
+
+var MapViewExample = React.createClass({
+
+ getInitialState() {
+ return {
+ mapRegion: null,
+ mapRegionInput: null,
+ };
+ },
+
+ render() {
+ return (
+
+
+
+
+ );
+ },
+
+ _onRegionChanged(region) {
+ this.setState({mapRegionInput: region});
+ },
+
+ _onRegionInputChanged(region) {
+ this.setState({
+ mapRegion: region,
+ mapRegionInput: region,
+ });
+ },
+
+});
+
+var styles = StyleSheet.create({
+ map: {
+ height: 150,
+ margin: 10,
+ borderWidth: 1,
+ borderColor: '#000000',
+ },
+ row: {
+ flexDirection: 'row',
+ justifyContent: 'space-between',
+ },
+ textInput: {
+ width: 150,
+ height: 20,
+ borderWidth: 0.5,
+ borderColor: '#aaaaaa',
+ fontSize: 13,
+ padding: 4,
+ },
+ changeButton: {
+ alignSelf: 'center',
+ marginTop: 5,
+ padding: 3,
+ borderWidth: 0.5,
+ borderColor: '#777777',
+ },
+});
+
+exports.title = '';
+exports.description = 'Base component to display maps';
+exports.examples = [
+ {
+ title: 'Map',
+ render() { return ; }
+ },
+ {
+ title: 'Map shows user location',
+ render() {
+ return ;
+ }
+ }
+];
diff --git a/Examples/UIExplorer/UIExplorerList.js b/Examples/UIExplorer/UIExplorerList.js
index 85627ee7945df5..bf8491dd6d0a6f 100644
--- a/Examples/UIExplorer/UIExplorerList.js
+++ b/Examples/UIExplorer/UIExplorerList.js
@@ -37,6 +37,7 @@ var EXAMPLES = [
require('./SwitchExample'),
require('./SliderExample'),
require('./CameraRollExample.ios'),
+ require('./MapViewExample'),
];
var UIExplorerList = React.createClass({
diff --git a/Libraries/Components/MapView/MapView.js b/Libraries/Components/MapView/MapView.js
new file mode 100644
index 00000000000000..105a80a31044c5
--- /dev/null
+++ b/Libraries/Components/MapView/MapView.js
@@ -0,0 +1,165 @@
+/**
+ * Copyright 2004-present Facebook. All Rights Reserved.
+ *
+ * @providesModule MapView
+ */
+'use strict';
+
+var EdgeInsetsPropType = require('EdgeInsetsPropType');
+var NativeMethodsMixin = require('NativeMethodsMixin');
+var React = require('React');
+var ReactIOSViewAttributes = require('ReactIOSViewAttributes');
+var View = require('View');
+
+var createReactIOSNativeComponentClass = require('createReactIOSNativeComponentClass');
+var deepDiffer = require('deepDiffer');
+var insetsDiffer = require('insetsDiffer');
+var merge = require('merge');
+
+var MapView = React.createClass({
+ mixins: [NativeMethodsMixin],
+
+ propTypes: {
+ /**
+ * Used to style and layout the `MapView`. See `StyleSheet.js` and
+ * `ViewStylePropTypes.js` for more info.
+ */
+ style: View.propTypes.style,
+
+ /**
+ * If `true` the app will ask for the user's location and focus on it.
+ * Default value is `false`.
+ *
+ * **NOTE**: You need to add NSLocationWhenInUseUsageDescription key in
+ * Info.plist to enable geolocation, otherwise it is going
+ * to *fail silently*!
+ */
+ showsUserLocation: React.PropTypes.bool,
+
+ /**
+ * If `false` the user won't be able to pinch/zoom the map.
+ * Default `value` is true.
+ */
+ zoomEnabled: React.PropTypes.bool,
+
+ /**
+ * When this property is set to `true` and a valid camera is associated with
+ * the map, the camera’s heading angle is used to rotate the plane of the
+ * map around its center point. When this property is set to `false`, the
+ * camera’s heading angle is ignored and the map is always oriented so
+ * that true north is situated at the top of the map view
+ */
+ rotateEnabled: React.PropTypes.bool,
+
+ /**
+ * When this property is set to `true` and a valid camera is associated
+ * with the map, the camera’s pitch angle is used to tilt the plane
+ * of the map. When this property is set to `false`, the camera’s pitch
+ * angle is ignored and the map is always displayed as if the user
+ * is looking straight down onto it.
+ */
+ pitchEnabled: React.PropTypes.bool,
+
+ /**
+ * If `false` the user won't be able to change the map region being displayed.
+ * Default value is `true`.
+ */
+ scrollEnabled: React.PropTypes.bool,
+
+ /**
+ * The region to be displayed by the map.
+ *
+ * The region is defined by the center coordinates and the span of
+ * coordinates to display.
+ */
+ region: React.PropTypes.shape({
+ /**
+ * Coordinates for the center of the map.
+ */
+ latitude: React.PropTypes.number.isRequired,
+ longitude: React.PropTypes.number.isRequired,
+
+ /**
+ * Distance between the minimun and the maximum latitude/longitude
+ * to be displayed.
+ */
+ latitudeDelta: React.PropTypes.number.isRequired,
+ longitudeDelta: React.PropTypes.number.isRequired,
+ }),
+
+ /**
+ * Maximum size of area that can be displayed.
+ */
+ maxDelta: React.PropTypes.number,
+
+ /**
+ * Minimum size of area that can be displayed.
+ */
+ minDelta: React.PropTypes.number,
+
+ /**
+ * Insets for the map's legal label, originally at bottom left of the map.
+ * See `EdgeInsetsPropType.js` for more information.
+ */
+ legalLabelInsets: EdgeInsetsPropType,
+
+ /**
+ * Callback that is called continuously when the user is dragging the map.
+ */
+ onRegionChange: React.PropTypes.func,
+
+ /**
+ * Callback that is called once, when the user is done moving the map.
+ */
+ onRegionChangeComplete: React.PropTypes.func,
+ },
+
+ _onChange: function(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() {
+ return (
+
+ );
+ },
+
+});
+
+var RKMap = createReactIOSNativeComponentClass({
+ validAttributes: merge(
+ ReactIOSViewAttributes.UIView, {
+ showsUserLocation: true,
+ zoomEnabled: true,
+ rotateEnabled: true,
+ pitchEnabled: true,
+ scrollEnabled: true,
+ region: {diff: deepDiffer},
+ maxDelta: true,
+ minDelta: true,
+ legalLabelInsets: {diff: insetsDiffer},
+ }
+ ),
+ uiViewClassName: 'RCTMap',
+});
+
+module.exports = MapView;
diff --git a/Libraries/react-native/react-native.js b/Libraries/react-native/react-native.js
index 4b2de062af118e..b555b356ffcd67 100644
--- a/Libraries/react-native/react-native.js
+++ b/Libraries/react-native/react-native.js
@@ -11,6 +11,7 @@ var ReactNative = {
CameraRoll: require('CameraRoll'),
DatePickerIOS: require('DatePickerIOS'),
ExpandingText: require('ExpandingText'),
+ MapView: require('MapView'),
Image: require('Image'),
LayoutAnimation: require('LayoutAnimation'),
ListView: require('ListView'),
diff --git a/ReactKit/ReactKit.xcodeproj/project.pbxproj b/ReactKit/ReactKit.xcodeproj/project.pbxproj
index 48cac5dedc95a0..6ba0ab31924107 100644
--- a/ReactKit/ReactKit.xcodeproj/project.pbxproj
+++ b/ReactKit/ReactKit.xcodeproj/project.pbxproj
@@ -38,6 +38,8 @@
14F3620D1AABD06A001CE568 /* RCTSwitch.m in Sources */ = {isa = PBXBuildFile; fileRef = 14F362081AABD06A001CE568 /* RCTSwitch.m */; };
14F3620E1AABD06A001CE568 /* RCTSwitchManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 14F3620A1AABD06A001CE568 /* RCTSwitchManager.m */; };
14F484561AABFCE100FDF6B9 /* RCTSliderManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 14F484551AABFCE100FDF6B9 /* RCTSliderManager.m */; };
+ 14435CE51AAC4AE100FC20F4 /* RCTMap.m in Sources */ = {isa = PBXBuildFile; fileRef = 14435CE21AAC4AE100FC20F4 /* RCTMap.m */; };
+ 14435CE61AAC4AE100FC20F4 /* RCTMapManager.m in Sources */ = {isa = PBXBuildFile; fileRef = 14435CE41AAC4AE100FC20F4 /* RCTMapManager.m */; };
830A229E1A66C68A008503DA /* RCTRootView.m in Sources */ = {isa = PBXBuildFile; fileRef = 830A229D1A66C68A008503DA /* RCTRootView.m */; };
830BA4551A8E3BDA00D53203 /* RCTCache.m in Sources */ = {isa = PBXBuildFile; fileRef = 830BA4541A8E3BDA00D53203 /* RCTCache.m */; };
832348161A77A5AA00B55238 /* Layout.c in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FC71A68125100A75B9A /* Layout.c */; };
@@ -134,6 +136,10 @@
14F3620A1AABD06A001CE568 /* RCTSwitchManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSwitchManager.m; sourceTree = ""; };
14F484541AABFCE100FDF6B9 /* RCTSliderManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTSliderManager.h; sourceTree = ""; };
14F484551AABFCE100FDF6B9 /* RCTSliderManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTSliderManager.m; sourceTree = ""; };
+ 14435CE11AAC4AE100FC20F4 /* RCTMap.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTMap.h; sourceTree = ""; };
+ 14435CE21AAC4AE100FC20F4 /* RCTMap.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMap.m; sourceTree = ""; };
+ 14435CE31AAC4AE100FC20F4 /* RCTMapManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTMapManager.h; sourceTree = ""; };
+ 14435CE41AAC4AE100FC20F4 /* RCTMapManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTMapManager.m; sourceTree = ""; };
830213F31A654E0800B993E6 /* RCTBridgeModule.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = RCTBridgeModule.h; sourceTree = ""; };
830A229C1A66C68A008503DA /* RCTRootView.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = RCTRootView.h; sourceTree = ""; };
830A229D1A66C68A008503DA /* RCTRootView.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = RCTRootView.m; sourceTree = ""; };
@@ -219,6 +225,10 @@
14F3620A1AABD06A001CE568 /* RCTSwitchManager.m */,
14F484541AABFCE100FDF6B9 /* RCTSliderManager.h */,
14F484551AABFCE100FDF6B9 /* RCTSliderManager.m */,
+ 14435CE11AAC4AE100FC20F4 /* RCTMap.h */,
+ 14435CE21AAC4AE100FC20F4 /* RCTMap.m */,
+ 14435CE31AAC4AE100FC20F4 /* RCTMapManager.h */,
+ 14435CE41AAC4AE100FC20F4 /* RCTMapManager.m */,
13442BF21AA90E0B0037E5B0 /* RCTAnimationType.h */,
58C571C01AA56C1900CDF9C8 /* RCTDatePickerManager.h */,
58C571BF1AA56C1900CDF9C8 /* RCTDatePickerManager.m */,
@@ -435,12 +445,14 @@
134FCB361A6D42D900051CC8 /* RCTSparseArray.m in Sources */,
13A1F71E1A75392D00D3D453 /* RCTKeyCommands.m in Sources */,
83CBBA531A601E3B00E9B192 /* RCTUtils.m in Sources */,
+ 14435CE61AAC4AE100FC20F4 /* RCTMapManager.m in Sources */,
83CBBA601A601EAA00E9B192 /* RCTBridge.m in Sources */,
137327E81AA5CF210034F82E /* RCTTabBarItem.m in Sources */,
13E067551A70F44B002CDEE1 /* RCTShadowView.m in Sources */,
13B0801A1A69489C00A75B9A /* RCTNavigator.m in Sources */,
830BA4551A8E3BDA00D53203 /* RCTCache.m in Sources */,
137327E71AA5CF210034F82E /* RCTTabBar.m in Sources */,
+ 14435CE51AAC4AE100FC20F4 /* RCTMap.m in Sources */,
134FCB3E1A6E7F0800051CC8 /* RCTWebViewExecutor.m in Sources */,
13B0801C1A69489C00A75B9A /* RCTNavItem.m in Sources */,
83CBBA691A601EF300E9B192 /* RCTEventDispatcher.m in Sources */,
diff --git a/ReactKit/Views/RCTMap.h b/ReactKit/Views/RCTMap.h
new file mode 100644
index 00000000000000..5ab56079bd236c
--- /dev/null
+++ b/ReactKit/Views/RCTMap.h
@@ -0,0 +1,24 @@
+// Copyright 2004-present Facebook. All Rights Reserved.
+
+#import
+#import
+
+extern const CLLocationDegrees RCTMapDefaultSpan;
+extern const NSTimeInterval RCTMapRegionChangeObserveInterval;
+extern const CGFloat RCTMapZoomBoundBuffer;
+
+@class RCTEventDispatcher;
+
+@interface RCTMap: MKMapView
+
+@property (nonatomic, assign) BOOL followUserLocation;
+@property (nonatomic, copy) NSDictionary *JSONRegion;
+@property (nonatomic, assign) CGFloat minDelta;
+@property (nonatomic, assign) CGFloat maxDelta;
+@property (nonatomic, assign) UIEdgeInsets legalLabelInsets;
+@property (nonatomic, strong) NSTimer *regionChangeObserveTimer;
+
+@end
+
+#define FLUSH_NAN(value) \
+ (isnan(value) ? 0 : value)
diff --git a/ReactKit/Views/RCTMap.m b/ReactKit/Views/RCTMap.m
new file mode 100644
index 00000000000000..09dac2a5b5cb51
--- /dev/null
+++ b/ReactKit/Views/RCTMap.m
@@ -0,0 +1,130 @@
+// Copyright 2004-present Facebook. All Rights Reserved.
+
+#import "RCTMap.h"
+
+#import "RCTEventDispatcher.h"
+#import "RCTLog.h"
+#import "RCTUtils.h"
+
+const CLLocationDegrees RCTMapDefaultSpan = 0.005;
+const NSTimeInterval RCTMapRegionChangeObserveInterval = 0.1;
+const CGFloat RCTMapZoomBoundBuffer = 0.01;
+
+@interface RCTMap()
+
+@property (nonatomic, strong) UIView *legalLabel;
+@property (nonatomic, strong) CLLocationManager *locationManager;
+
+@end
+
+@implementation RCTMap
+
+- (instancetype)init
+{
+ self = [super init];
+ if (self) {
+ // Find Apple link label
+ for (UIView *subview in self.subviews) {
+ if ([NSStringFromClass(subview.class) isEqualToString:@"MKAttributionLabel"]) {
+ // This check is super hacky, but the whole premise of moving around Apple's internal subviews is super hacky
+ _legalLabel = subview;
+ break;
+ }
+ }
+ }
+ return self;
+}
+
+- (void)dealloc
+{
+ [self.regionChangeObserveTimer invalidate];
+}
+
+- (void)layoutSubviews
+{
+ [super layoutSubviews];
+
+ // Force resize subviews - only the layer is resized by default
+ CGRect mapFrame = self.frame;
+ self.frame = CGRectZero;
+ self.frame = mapFrame;
+
+ if (_legalLabel) {
+ dispatch_async(dispatch_get_main_queue(), ^{
+ CGRect frame = _legalLabel.frame;
+ if (_legalLabelInsets.left) {
+ frame.origin.x = _legalLabelInsets.left;
+ } else if (_legalLabelInsets.right) {
+ frame.origin.x = mapFrame.size.width - _legalLabelInsets.right - frame.size.width;
+ }
+ if (_legalLabelInsets.top) {
+ frame.origin.y = _legalLabelInsets.top;
+ } else if (_legalLabelInsets.bottom) {
+ frame.origin.y = mapFrame.size.height - _legalLabelInsets.bottom - frame.size.height;
+ }
+ _legalLabel.frame = frame;
+ });
+ }
+}
+
+#pragma mark Accessors
+
+- (void)setShowsUserLocation:(BOOL)showsUserLocation
+{
+ if (self.showsUserLocation != showsUserLocation) {
+ if (showsUserLocation && !_locationManager) {
+ _locationManager = [[CLLocationManager alloc] init];
+ if ([_locationManager respondsToSelector:@selector(requestWhenInUseAuthorization)]) {
+ [_locationManager requestWhenInUseAuthorization];
+ }
+ }
+ [super setShowsUserLocation:showsUserLocation];
+
+ // If it needs to show user location, force map view centered
+ // on user's current location on user location updates
+ self.followUserLocation = showsUserLocation;
+ }
+}
+
+- (void)setJSONRegion:(NSDictionary *)region
+{
+ if (region) {
+ MKCoordinateRegion coordinateRegion = self.region;
+ if ([region[@"latitude"] isKindOfClass:[NSNumber class]]) {
+ coordinateRegion.center.latitude = [region[@"latitude"] doubleValue];
+ } else {
+ RCTLogError(@"region must include numeric latitude, got: %@", region);
+ return;
+ }
+ if ([region[@"longitude"] isKindOfClass:[NSNumber class]]) {
+ coordinateRegion.center.longitude = [region[@"longitude"] doubleValue];
+ } else {
+ RCTLogError(@"region must include numeric longitude, got: %@", region);
+ return;
+ }
+ if ([region[@"latitudeDelta"] isKindOfClass:[NSNumber class]]) {
+ coordinateRegion.span.latitudeDelta = [region[@"latitudeDelta"] doubleValue];
+ }
+ if ([region[@"longitudeDelta"] isKindOfClass:[NSNumber class]]) {
+ coordinateRegion.span.longitudeDelta = [region[@"longitudeDelta"] doubleValue];
+ }
+
+ [self setRegion:coordinateRegion animated:YES];
+ }
+}
+
+- (NSDictionary *)JSONRegion
+{
+ MKCoordinateRegion region = self.region;
+ if (!CLLocationCoordinate2DIsValid(region.center)) {
+ return nil;
+ }
+ return @{
+ @"latitude": @(FLUSH_NAN(region.center.latitude)),
+ @"longitude": @(FLUSH_NAN(region.center.longitude)),
+ @"latitudeDelta": @(FLUSH_NAN(region.span.latitudeDelta)),
+ @"longitudeDelta": @(FLUSH_NAN(region.span.longitudeDelta)),
+ };
+}
+
+@end
diff --git a/ReactKit/Views/RCTMapManager.h b/ReactKit/Views/RCTMapManager.h
new file mode 100644
index 00000000000000..93b7049ca314a3
--- /dev/null
+++ b/ReactKit/Views/RCTMapManager.h
@@ -0,0 +1,7 @@
+// Copyright 2004-present Facebook. All Rights Reserved.
+
+#import "RCTViewManager.h"
+
+@interface RCTMapManager : RCTViewManager
+
+@end
diff --git a/ReactKit/Views/RCTMapManager.m b/ReactKit/Views/RCTMapManager.m
new file mode 100644
index 00000000000000..421396a1e3d8ba
--- /dev/null
+++ b/ReactKit/Views/RCTMapManager.m
@@ -0,0 +1,119 @@
+// Copyright 2004-present Facebook. All Rights Reserved.
+
+#import "RCTMapManager.h"
+
+#import "RCTBridge.h"
+#import "RCTEventDispatcher.h"
+#import "RCTMap.h"
+#import "UIView+ReactKit.h"
+
+@interface RCTMapManager()
+
+@end
+
+@implementation RCTMapManager
+
+- (UIView *)view
+{
+ RCTMap *map = [[RCTMap alloc] init];
+ map.delegate = self;
+ return map;
+}
+
+RCT_EXPORT_VIEW_PROPERTY(showsUserLocation);
+RCT_EXPORT_VIEW_PROPERTY(zoomEnabled);
+RCT_EXPORT_VIEW_PROPERTY(rotateEnabled);
+RCT_EXPORT_VIEW_PROPERTY(pitchEnabled);
+RCT_EXPORT_VIEW_PROPERTY(scrollEnabled);
+RCT_EXPORT_VIEW_PROPERTY(maxDelta);
+RCT_EXPORT_VIEW_PROPERTY(minDelta);
+RCT_EXPORT_VIEW_PROPERTY(legalLabelInsets);
+RCT_REMAP_VIEW_PROPERTY(region, JSONRegion)
+
+#pragma mark MKMapViewDelegate
+
+- (void)mapView:(RCTMap *)mapView didUpdateUserLocation:(MKUserLocation *)location
+{
+ if (mapView.followUserLocation) {
+ MKCoordinateRegion region;
+ region.span.latitudeDelta = RCTMapDefaultSpan;
+ region.span.longitudeDelta = RCTMapDefaultSpan;
+ region.center = location.coordinate;
+ [mapView setRegion:region animated:YES];
+
+ // Move to user location only for the first time it loads up.
+ mapView.followUserLocation = NO;
+ }
+}
+
+- (void)mapView:(RCTMap *)mapView regionWillChangeAnimated:(BOOL)animated
+{
+ [self _regionChanged:mapView];
+
+ mapView.regionChangeObserveTimer = [NSTimer timerWithTimeInterval:RCTMapRegionChangeObserveInterval
+ target:self
+ selector:@selector(_onTick:)
+ userInfo:@{ @"mapView": mapView }
+ repeats:YES];
+ [[NSRunLoop mainRunLoop] addTimer:mapView.regionChangeObserveTimer forMode:NSRunLoopCommonModes];
+}
+
+- (void)mapView:(RCTMap *)mapView regionDidChangeAnimated:(BOOL)animated
+{
+ [self _regionChanged:mapView];
+ [self _emitRegionChangeEvent:mapView continuous:NO];
+
+ [mapView.regionChangeObserveTimer invalidate];
+ mapView.regionChangeObserveTimer = nil;
+}
+
+#pragma mark Private
+
+- (void)_onTick:(NSTimer *)timer
+{
+ [self _regionChanged:timer.userInfo[@"mapView"]];
+}
+
+- (void)_regionChanged:(RCTMap *)mapView
+{
+ 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.
+ 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.
+ if (mapView.maxDelta > FLT_EPSILON && region.span.longitudeDelta > mapView.maxDelta) {
+ needZoom = YES;
+ newLongitudeDelta = mapView.maxDelta * (1 - RCTMapZoomBoundBuffer);
+ } else if (mapView.minDelta > FLT_EPSILON && region.span.longitudeDelta < mapView.minDelta) {
+ needZoom = YES;
+ newLongitudeDelta = mapView.minDelta * (1 + RCTMapZoomBoundBuffer);
+ }
+ if (needZoom) {
+ region.span.latitudeDelta = region.span.latitudeDelta / region.span.longitudeDelta * newLongitudeDelta;
+ region.span.longitudeDelta = newLongitudeDelta;
+ mapView.region = region;
+ }
+
+ // Continously observe region changes
+ [self _emitRegionChangeEvent:mapView continuous:YES];
+}
+
+- (void)_emitRegionChangeEvent:(RCTMap *)mapView continuous:(BOOL)continuous
+{
+ NSDictionary *region = mapView.JSONRegion;
+ if (region) {
+ NSDictionary *event = @{
+ @"target": [mapView reactTag],
+ @"continuous": @(continuous),
+ @"region": mapView.JSONRegion,
+ };
+ [self.bridge.eventDispatcher sendInputEventWithName:@"topChange" body:event];
+ }
+}
+
+@end