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