diff --git a/Examples/UIExplorer/LayoutEventsExample.js b/Examples/UIExplorer/LayoutEventsExample.js
new file mode 100644
index 000000000..6aec6257e
--- /dev/null
+++ b/Examples/UIExplorer/LayoutEventsExample.js
@@ -0,0 +1,150 @@
+/**
+ * The examples provided by Facebook are for non-commercial testing and
+ * evaluation purposes only.
+ *
+ * Facebook reserves all rights not expressly granted.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
+ * OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NON INFRINGEMENT. IN NO EVENT SHALL
+ * FACEBOOK BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
+ * AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
+ * CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
+ *
+ * @flow
+ */
+'use strict';
+
+var React = require('react-native');
+var {
+ Image,
+ LayoutAnimation,
+ StyleSheet,
+ Text,
+ View,
+} = React;
+
+type LayoutEvent = {
+ nativeEvent: {
+ layout: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+ };
+};
+
+var LayoutEventExample = React.createClass({
+ getInitialState: function() {
+ return {
+ viewStyle: {
+ margin: 20,
+ },
+ };
+ },
+ animateViewLayout: function() {
+ LayoutAnimation.configureNext(
+ LayoutAnimation.Presets.spring,
+ () => {
+ console.log('layout animation done.');
+ this.addWrapText();
+ },
+ (error) => { throw new Error(JSON.stringify(error)); }
+ );
+ this.setState({
+ viewStyle: {
+ margin: this.state.viewStyle.margin > 20 ? 20 : 60,
+ }
+ });
+ },
+ addWrapText: function() {
+ this.setState(
+ {extraText: ' And a bunch more text to wrap around a few lines.'},
+ this.changeContainer
+ );
+ },
+ changeContainer: function() {
+ this.setState({containerStyle: {width: 280}});
+ },
+ onViewLayout: function(e: LayoutEvent) {
+ console.log('received view layout event\n', e.nativeEvent);
+ this.setState({viewLayout: e.nativeEvent.layout});
+ },
+ onTextLayout: function(e: LayoutEvent) {
+ console.log('received text layout event\n', e.nativeEvent);
+ this.setState({textLayout: e.nativeEvent.layout});
+ },
+ onImageLayout: function(e: LayoutEvent) {
+ console.log('received image layout event\n', e.nativeEvent);
+ this.setState({imageLayout: e.nativeEvent.layout});
+ },
+ render: function() {
+ var viewStyle = [styles.view, this.state.viewStyle];
+ var textLayout = this.state.textLayout || {width: '?', height: '?'};
+ var imageLayout = this.state.imageLayout || {x: '?', y: '?'};
+ return (
+
+
+ onLayout events are called on mount and whenever layout is updated,
+ including after layout animations complete.{' '}
+
+ Press here to change layout.
+
+
+
+
+
+ ViewLayout: {JSON.stringify(this.state.viewLayout, null, ' ') + '\n\n'}
+
+
+ A simple piece of text.{this.state.extraText}
+
+
+ {'\n'}
+ Text w/h: {textLayout.width}/{textLayout.height + '\n'}
+ Image x/y: {imageLayout.x}/{imageLayout.y}
+
+
+
+ );
+ }
+});
+
+var styles = StyleSheet.create({
+ view: {
+ padding: 12,
+ borderColor: 'black',
+ borderWidth: 0.5,
+ backgroundColor: 'transparent',
+ },
+ text: {
+ alignSelf: 'flex-start',
+ borderColor: 'rgba(0, 0, 255, 0.2)',
+ borderWidth: 0.5,
+ },
+ image: {
+ width: 50,
+ height: 50,
+ marginBottom: 10,
+ alignSelf: 'center',
+ },
+ pressText: {
+ fontWeight: 'bold',
+ },
+});
+
+exports.title = 'onLayout';
+exports.description = 'Layout events can be used to measure view size and position.';
+exports.examples = [
+{
+ title: 'onLayout',
+ render: function(): ReactElement {
+ return ;
+ },
+}];
diff --git a/Examples/UIExplorer/UIExplorerList.js b/Examples/UIExplorer/UIExplorerList.js
index a24ec1a54..dd2336df5 100644
--- a/Examples/UIExplorer/UIExplorerList.js
+++ b/Examples/UIExplorer/UIExplorerList.js
@@ -64,6 +64,7 @@ var APIS = [
require('./BorderExample'),
require('./CameraRollExample.ios'),
require('./GeolocationExample'),
+ require('./LayoutEventsExample'),
require('./LayoutExample'),
require('./NetInfoExample'),
require('./PanResponderExample'),
diff --git a/IntegrationTests/IntegrationTestsApp.js b/IntegrationTests/IntegrationTestsApp.js
index dbb5dde83..1e61a0dbc 100644
--- a/IntegrationTests/IntegrationTestsApp.js
+++ b/IntegrationTests/IntegrationTestsApp.js
@@ -25,6 +25,7 @@ var TESTS = [
require('./IntegrationTestHarnessTest'),
require('./TimersTest'),
require('./AsyncStorageTest'),
+ require('./LayoutEventsTest'),
require('./SimpleSnapshotTest'),
];
diff --git a/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m b/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m
index e0a43e793..9bf1a4fc1 100644
--- a/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m
+++ b/IntegrationTests/IntegrationTestsTests/IntegrationTestsTests.m
@@ -71,6 +71,11 @@
[_runner runTest:_cmd module:@"AsyncStorageTest"];
}
+- (void)testLayoutEvents
+{
+ [_runner runTest:_cmd module:@"LayoutEventsTest"];
+}
+
#pragma mark Snapshot Tests
- (void)testSimpleSnapshot
diff --git a/IntegrationTests/LayoutEventsTest.js b/IntegrationTests/LayoutEventsTest.js
new file mode 100644
index 000000000..7e8cd3a0d
--- /dev/null
+++ b/IntegrationTests/LayoutEventsTest.js
@@ -0,0 +1,167 @@
+/**
+ * Copyright (c) 2015-present, Facebook, Inc.
+ * All rights reserved.
+ *
+ * This source code is licensed under the BSD-style license found in the
+ * LICENSE file in the root directory of this source tree. An additional grant
+ * of patent rights can be found in the PATENTS file in the same directory.
+ *
+ * @providesModule LayoutEventsTest
+ * @flow
+ */
+'use strict';
+
+var React = require('react-native');
+var {
+ Image,
+ LayoutAnimation,
+ NativeModules,
+ StyleSheet,
+ Text,
+ View,
+} = React;
+var TestModule = NativeModules.TestModule || NativeModules.SnapshotTestManager;
+
+var deepDiffer = require('deepDiffer');
+
+function debug() {
+ //console.log.apply(null, arguments);
+}
+
+type LayoutEvent = {
+ nativeEvent: {
+ layout: {
+ x: number;
+ y: number;
+ width: number;
+ height: number;
+ };
+ };
+};
+
+var LayoutEventsTest = React.createClass({
+ getInitialState: function() {
+ return {
+ didAnimation: false,
+ };
+ },
+ animateViewLayout: function() {
+ LayoutAnimation.configureNext(
+ LayoutAnimation.Presets.spring,
+ () => {
+ debug('layout animation done.');
+ this.checkLayout(this.addWrapText);
+ },
+ (error) => { throw new Error(JSON.stringify(error)); }
+ );
+ this.setState({viewStyle: {margin: 60}});
+ },
+ addWrapText: function() {
+ this.setState(
+ {extraText: ' And a bunch more text to wrap around a few lines.'},
+ () => this.checkLayout(this.changeContainer)
+ );
+ },
+ changeContainer: function() {
+ this.setState(
+ {containerStyle: {width: 280}},
+ () => this.checkLayout(TestModule.markTestCompleted)
+ );
+ },
+ checkLayout: function(next?: ?Function) {
+ if (!this.isMounted()) {
+ return;
+ }
+ this.refs.view.measure((x, y, width, height) => {
+ this.compare('view', {x, y, width, height}, this.state.viewLayout);
+ if (typeof next === 'function') {
+ next();
+ } else if (!this.state.didAnimation) {
+ // Trigger first state change after onLayout fires
+ this.animateViewLayout();
+ this.state.didAnimation = true;
+ }
+ });
+ this.refs.txt.measure((x, y, width, height) => {
+ this.compare('txt', {x, y, width, height}, this.state.textLayout);
+ });
+ this.refs.img.measure((x, y, width, height) => {
+ this.compare('img', {x, y, width, height}, this.state.imageLayout);
+ });
+ },
+ compare: function(node: string, measured: any, onLayout: any): void {
+ if (deepDiffer(measured, onLayout)) {
+ var data = {measured, onLayout};
+ throw new Error(
+ node + ' onLayout mismatch with measure ' +
+ JSON.stringify(data, null, ' ')
+ );
+ }
+ },
+ onViewLayout: function(e: LayoutEvent) {
+ debug('received view layout event\n', e.nativeEvent);
+ this.setState({viewLayout: e.nativeEvent.layout}, this.checkLayout);
+ },
+ onTextLayout: function(e: LayoutEvent) {
+ debug('received text layout event\n', e.nativeEvent);
+ this.setState({textLayout: e.nativeEvent.layout}, this.checkLayout);
+ },
+ onImageLayout: function(e: LayoutEvent) {
+ debug('received image layout event\n', e.nativeEvent);
+ this.setState({imageLayout: e.nativeEvent.layout}, this.checkLayout);
+ },
+ render: function() {
+ var viewStyle = [styles.view, this.state.viewStyle];
+ var textLayout = this.state.textLayout || {width: '?', height: '?'};
+ var imageLayout = this.state.imageLayout || {x: '?', y: '?'};
+ return (
+
+
+
+
+ ViewLayout: {JSON.stringify(this.state.viewLayout, null, ' ') + '\n\n'}
+
+
+ A simple piece of text.{this.state.extraText}
+
+
+ {'\n'}
+ Text w/h: {textLayout.width}/{textLayout.height + '\n'}
+ Image x/y: {imageLayout.x}/{imageLayout.y}
+
+
+
+ );
+ }
+});
+
+var styles = StyleSheet.create({
+ container: {
+ margin: 40,
+ },
+ view: {
+ margin: 20,
+ padding: 12,
+ borderColor: 'black',
+ borderWidth: 0.5,
+ backgroundColor: 'transparent',
+ },
+ text: {
+ alignSelf: 'flex-start',
+ borderColor: 'rgba(0, 0, 255, 0.2)',
+ borderWidth: 0.5,
+ },
+ image: {
+ width: 50,
+ height: 50,
+ marginBottom: 10,
+ alignSelf: 'center',
+ },
+});
+
+module.exports = LayoutEventsTest;
diff --git a/Libraries/Components/View/View.js b/Libraries/Components/View/View.js
index c7ca2ee26..aa69ab0eb 100644
--- a/Libraries/Components/View/View.js
+++ b/Libraries/Components/View/View.js
@@ -90,6 +90,11 @@ var View = React.createClass({
onStartShouldSetResponder: PropTypes.func,
onStartShouldSetResponderCapture: PropTypes.func,
+ /**
+ * Invoked on mount and layout changes with {x, y, width, height}.
+ */
+ onLayout: PropTypes.func,
+
/**
* In the absence of `auto` property, `none` is much like `CSS`'s `none`
* value. `box-none` is as if you had applied the `CSS` class:
diff --git a/Libraries/ReactIOS/ReactIOSViewAttributes.js b/Libraries/ReactIOS/ReactIOSViewAttributes.js
index 069f00b24..489741b05 100644
--- a/Libraries/ReactIOS/ReactIOSViewAttributes.js
+++ b/Libraries/ReactIOS/ReactIOSViewAttributes.js
@@ -9,8 +9,7 @@
* @providesModule ReactIOSViewAttributes
* @flow
*/
-
-"use strict";
+'use strict';
var merge = require('merge');
@@ -21,6 +20,7 @@ ReactIOSViewAttributes.UIView = {
accessible: true,
accessibilityLabel: true,
testID: true,
+ onLayout: true,
};
ReactIOSViewAttributes.RCTView = merge(
@@ -31,7 +31,7 @@ ReactIOSViewAttributes.RCTView = merge(
// For this property to be effective, it must be applied to a view that contains
// many subviews that extend outside its bound. The subviews must also have
// overflow: hidden, as should the containing view (or one of its superviews).
- removeClippedSubviews: true
+ removeClippedSubviews: true,
});
module.exports = ReactIOSViewAttributes;
diff --git a/Libraries/ReactIOS/diffRawProperties.js b/Libraries/ReactIOS/diffRawProperties.js
index 3a5de284f..ddd6edbea 100644
--- a/Libraries/ReactIOS/diffRawProperties.js
+++ b/Libraries/ReactIOS/diffRawProperties.js
@@ -42,6 +42,16 @@ function diffRawProperties(
}
prevProp = prevProps && prevProps[propKey];
nextProp = nextProps[propKey];
+
+ // functions are converted to booleans as markers that the associated
+ // events should be sent from native.
+ if (typeof prevProp === 'function') {
+ prevProp = true;
+ }
+ if (typeof nextProp === 'function') {
+ nextProp = true;
+ }
+
if (prevProp !== nextProp) {
// If you want a property's diff to be detected, you must configure it
// to be so - *or* it must be a scalar property. For now, we'll allow
@@ -75,6 +85,16 @@ function diffRawProperties(
}
prevProp = prevProps[propKey];
nextProp = nextProps && nextProps[propKey];
+
+ // functions are converted to booleans as markers that the associated
+ // events should be sent from native.
+ if (typeof prevProp === 'function') {
+ prevProp = true;
+ }
+ if (typeof nextProp === 'function') {
+ nextProp = true;
+ }
+
if (prevProp !== nextProp) {
if (nextProp === undefined) {
nextProp = null; // null is a sentinel we explicitly send to native
diff --git a/React/Modules/RCTUIManager.m b/React/Modules/RCTUIManager.m
index eac3a7735..df90ff150 100644
--- a/React/Modules/RCTUIManager.m
+++ b/React/Modules/RCTUIManager.m
@@ -19,6 +19,7 @@
#import "RCTBridge.h"
#import "RCTConvert.h"
#import "RCTDefines.h"
+#import "RCTEventDispatcher.h"
#import "RCTLog.h"
#import "RCTProfile.h"
#import "RCTRootView.h"
@@ -420,17 +421,31 @@ static NSDictionary *RCTViewConfigForModule(Class managerClass, NSString *viewNa
[rootShadowView collectRootUpdatedFrames:viewsWithNewFrames
parentConstraint:(CGSize){CSS_UNDEFINED, CSS_UNDEFINED}];
- // Parallel arrays
+ // Parallel arrays are built and then handed off to main thread
NSMutableArray *frameReactTags = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count];
NSMutableArray *frames = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count];
NSMutableArray *areNew = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count];
NSMutableArray *parentsAreNew = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count];
+ NSMutableArray *onLayoutEvents = [NSMutableArray arrayWithCapacity:viewsWithNewFrames.count];
for (RCTShadowView *shadowView in viewsWithNewFrames) {
[frameReactTags addObject:shadowView.reactTag];
[frames addObject:[NSValue valueWithCGRect:shadowView.frame]];
[areNew addObject:@(shadowView.isNewView)];
[parentsAreNew addObject:@(shadowView.superview.isNewView)];
+ id event = [NSNull null];
+ if (shadowView.hasOnLayout) {
+ event = @{
+ @"target": shadowView.reactTag,
+ @"layout": @{
+ @"x": @(shadowView.frame.origin.x),
+ @"y": @(shadowView.frame.origin.y),
+ @"width": @(shadowView.frame.size.width),
+ @"height": @(shadowView.frame.size.height),
+ },
+ };
+ }
+ [onLayoutEvents addObject:event];
}
for (RCTShadowView *shadowView in viewsWithNewFrames) {
@@ -448,20 +463,30 @@ static NSDictionary *RCTViewConfigForModule(Class managerClass, NSString *viewNa
// Perform layout (possibly animated)
NSNumber *rootViewTag = rootShadowView.reactTag;
return ^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) {
+ RCTResponseSenderBlock callback = self->_layoutAnimation.callback;
+ __block NSInteger completionsCalled = 0;
for (NSUInteger ii = 0; ii < frames.count; ii++) {
NSNumber *reactTag = frameReactTags[ii];
UIView *view = viewRegistry[reactTag];
CGRect frame = [frames[ii] CGRectValue];
+ id event = onLayoutEvents[ii];
+
+ BOOL isNew = [areNew[ii] boolValue];
+ RCTAnimation *updateAnimation = isNew ? nil : _layoutAnimation.updateAnimation;
+ BOOL shouldAnimateCreation = isNew && ![parentsAreNew[ii] boolValue];
+ RCTAnimation *createAnimation = shouldAnimateCreation ? _layoutAnimation.createAnimation : nil;
void (^completion)(BOOL finished) = ^(BOOL finished) {
- if (self->_layoutAnimation.callback) {
- self->_layoutAnimation.callback(@[@(finished)]);
+ completionsCalled++;
+ if (event != [NSNull null]) {
+ [self.bridge.eventDispatcher sendInputEventWithName:@"topLayout" body:event];
+ }
+ if (callback && completionsCalled == frames.count - 1) {
+ callback(@[@(finished)]);
}
};
// Animate view update
- BOOL isNew = [areNew[ii] boolValue];
- RCTAnimation *updateAnimation = isNew ? nil: _layoutAnimation.updateAnimation;
if (updateAnimation) {
[updateAnimation performAnimations:^{
[view reactSetFrame:frame];
@@ -478,9 +503,7 @@ static NSDictionary *RCTViewConfigForModule(Class managerClass, NSString *viewNa
}
// Animate view creation
- BOOL shouldAnimateCreation = isNew && ![parentsAreNew[ii] boolValue];
- RCTAnimation *createAnimation = _layoutAnimation.createAnimation;
- if (shouldAnimateCreation && createAnimation) {
+ if (createAnimation) {
if ([createAnimation.property isEqualToString:@"scaleXY"]) {
view.layer.transform = CATransform3DMakeScale(0, 0, 0);
} else if ([createAnimation.property isEqualToString:@"opacity"]) {
@@ -1262,6 +1285,9 @@ RCT_EXPORT_METHOD(clearJSResponder)
@"topScrollAnimationEnd": @{
@"registrationName": @"onScrollAnimationEnd"
},
+ @"topLayout": @{
+ @"registrationName": @"onLayout"
+ },
@"topSelectionChange": @{
@"registrationName": @"onSelectionChange"
},
diff --git a/React/Views/RCTShadowView.h b/React/Views/RCTShadowView.h
index 8d68855f7..83350ac46 100644
--- a/React/Views/RCTShadowView.h
+++ b/React/Views/RCTShadowView.h
@@ -41,6 +41,7 @@ typedef void (^RCTApplierBlock)(RCTSparseArray *);
@property (nonatomic, assign) BOOL isBGColorExplicitlySet; // Used to propagate to children
@property (nonatomic, strong) UIColor *backgroundColor; // Used to propagate to children
@property (nonatomic, assign) RCTUpdateLifecycle layoutLifecycle;
+@property (nonatomic, assign) BOOL hasOnLayout;
/**
* isNewView - Used to track the first time the view is introduced into the hierarchy. It is initialized YES, then is
diff --git a/React/Views/RCTViewManager.m b/React/Views/RCTViewManager.m
index 62fb29116..4dfb296fd 100644
--- a/React/Views/RCTViewManager.m
+++ b/React/Views/RCTViewManager.m
@@ -198,4 +198,6 @@ RCT_CUSTOM_SHADOW_PROPERTY(backgroundColor, UIColor, RCTShadowView)
view.isBGColorExplicitlySet = json ? YES : defaultView.isBGColorExplicitlySet;
}
+RCT_REMAP_SHADOW_PROPERTY(onLayout, hasOnLayout, BOOL)
+
@end