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