Permalink
Browse files

[ReactNative] Element Inspector

Summary:
This adds new development feature to React Native that provides information
about selected element (see the demo in Test Plan).

This is how it works:

App's root component is rendered to a container that also has a hidden layer called
`<InspectorOverlay/>`. When activated, it shows full screen view and captures all
touches. On every touch we ask UIManager to find a view for given {x,y} coordinates.

Then, we use React's internals to find corresponding React component. `setRootInstance`
is used to remember the top level component to start search from, lmk if you have a
better idea how to do this. Given a component, we can climb up its owners tree
to provice more context on how/where the component is used.

In future we could use the `hierarchy` array to inspect and print their props/state.

Known bugs and limitations:
* InspectorOverlay sometimes receives touches with incorrect coordinates (wtf)
* Not integrated with React Chrome Devtools (maybe in followup diffs)
* Doesn't work with popovers (maybe put the element inspector into an `<Overlay/>`?)

@public

Test Plan:
https://www.facebook.com/pxlcld/mn5k
Works nicely with scrollviews
  • Loading branch information...
frantic committed May 26, 2015
1 parent 4273af9 commit cfa4b1347291a099ccbd759e8351eab64e6fb01b
View
@@ -0,0 +1,62 @@
/**
* 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 Inspector
*/
'use strict';
var ReactInstanceHandles = require('ReactInstanceHandles');
var ReactInstanceMap = require('ReactInstanceMap');
var ReactNativeMount = require('ReactNativeMount');
var ReactNativeTagHandles = require('ReactNativeTagHandles');
function traverseOwnerTreeUp(hierarchy, instance) {
if (instance) {
hierarchy.unshift(instance);
traverseOwnerTreeUp(hierarchy, instance._currentElement._owner);
}
}
function findInstance(component, targetID) {
if (targetID === findRootNodeID(component)) {
return component;
}
if (component._renderedComponent) {
return findInstance(component._renderedComponent, targetID);
} else {
for (var key in component._renderedChildren) {
var child = component._renderedChildren[key];
if (ReactInstanceHandles.isAncestorIDOf(findRootNodeID(child), targetID)) {
var instance = findInstance(child, targetID);
if (instance) {
return instance;
}
}
}
}
}
function findRootNodeID(component) {
var internalInstance = ReactInstanceMap.get(component);
return internalInstance ? internalInstance._rootNodeID : component._rootNodeID;
}
function findInstanceByNativeTag(rootTag, nativeTag) {
var containerID = ReactNativeTagHandles.tagToRootNodeID[rootTag];
var rootInstance = ReactNativeMount._instancesByContainerID[containerID];
var targetID = ReactNativeTagHandles.tagToRootNodeID[nativeTag];
return findInstance(rootInstance, targetID);
}
function getOwnerHierarchy(instance) {
var hierarchy = [];
traverseOwnerTreeUp(hierarchy, instance);
return hierarchy;
}
module.exports = {findInstanceByNativeTag, getOwnerHierarchy};
@@ -0,0 +1,112 @@
/**
* 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 InspectorOverlay
*/
'use strict';
var Dimensions = require('Dimensions');
var Inspector = require('Inspector');
var React = require('React');
var StyleSheet = require('StyleSheet');
var Text = require('Text');
var UIManager = require('NativeModules').UIManager;
var View = require('View');
var InspectorOverlay = React.createClass({
getInitialState: function() {
return {
frame: null,
hierarchy: [],
};
},
findViewForTouchEvent: function(e) {
var {locationX, locationY} = e.nativeEvent.touches[0];
UIManager.findSubviewIn(
this.props.inspectedViewTag,
[locationX, locationY],
(nativeViewTag, left, top, width, height) => {
var instance = Inspector.findInstanceByNativeTag(this.props.rootTag, nativeViewTag);
var hierarchy = Inspector.getOwnerHierarchy(instance);
this.setState({
hierarchy,
frame: {left, top, width, height}
});
}
);
},
shouldSetResponser: function(e) {
this.findViewForTouchEvent(e);
return true;
},
render: function() {
var content = [];
if (this.state.frame) {
var distanceToTop = this.state.frame.top;
var distanceToBottom = Dimensions.get('window').height -
(this.state.frame.top + this.state.frame.height);
var justifyContent = distanceToTop > distanceToBottom
? 'flex-start'
: 'flex-end';
content.push(<View style={[styles.frame, this.state.frame]} />);
content.push(<ElementProperties hierarchy={this.state.hierarchy} />);
}
return (
<View
onStartShouldSetResponder={this.shouldSetResponser}
onResponderMove={this.findViewForTouchEvent}
style={[styles.inspector, {justifyContent}]}>
{content}
</View>
);
}
});
var ElementProperties = React.createClass({
render: function() {
var path = this.props.hierarchy.map((instance) => instance.getName()).join(' > ');
return (
<View style={styles.info}>
<Text style={styles.path}>
{path}
</Text>
</View>
);
}
});
var styles = StyleSheet.create({
inspector: {
backgroundColor: 'rgba(255,255,255,0.8)',
position: 'absolute',
left: 0,
top: 0,
right: 0,
bottom: 0,
},
frame: {
position: 'absolute',
backgroundColor: 'rgba(155,155,255,0.3)',
},
info: {
backgroundColor: 'rgba(0, 0, 0, 0.7)',
padding: 10,
},
path: {
color: 'white',
fontSize: 9,
}
});
module.exports = InspectorOverlay;
@@ -7,17 +7,59 @@
* of patent rights can be found in the PATENTS file in the same directory.
*
* @providesModule renderApplication
* @flow
*/
'use strict';
var InspectorOverlay = require('InspectorOverlay');
var RCTDeviceEventEmitter = require('RCTDeviceEventEmitter');
var React = require('React');
var StyleSheet = require('StyleSheet');
var Subscribable = require('Subscribable');
var View = require('View');
var WarningBox = require('WarningBox');
var invariant = require('invariant');
var AppContainer = React.createClass({
mixins: [Subscribable.Mixin],
getInitialState: function() {
return { inspector: null };
},
toggleElementInspector: function() {
var inspector = this.state.inspector
? null
: <InspectorOverlay
rootTag={this.props.rootTag}
inspectedViewTag={React.findNodeHandle(this.refs.main)}
/>;
this.setState({inspector});
},
componentDidMount: function() {
this.addListenerOn(
RCTDeviceEventEmitter,
'toggleElementInspector',
this.toggleElementInspector
);
},
render: function() {
var shouldRenderWarningBox = __DEV__ && console.yellowBoxEnabled;
var warningBox = shouldRenderWarningBox ? <WarningBox /> : null;
return (
<View style={styles.appContainer}>
<View style={styles.appContainer} ref="main">
{this.props.children}
</View>
{warningBox}
{this.state.inspector}
</View>
);
}
});
function renderApplication<D, P, S>(
RootComponent: ReactClass<D, P, S>,
initialProps: P,
@@ -27,15 +69,12 @@ function renderApplication<D, P, S>(
rootTag,
'Expect to have a valid rootTag, instead got ', rootTag
);
var shouldRenderWarningBox = __DEV__ && console.yellowBoxEnabled;
var warningBox = shouldRenderWarningBox ? <WarningBox /> : null;
React.render(
<View style={styles.appContainer}>
<AppContainer rootTag={rootTag}>
<RootComponent
{...initialProps}
/>
{warningBox}
</View>,
</AppContainer>,
rootTag
);
}
View
@@ -11,6 +11,7 @@
#import "RCTBridge.h"
#import "RCTDefines.h"
#import "RCTEventDispatcher.h"
#import "RCTKeyCommands.h"
#import "RCTLog.h"
#import "RCTPerfStats.h"
@@ -241,6 +242,8 @@ - (void)toggle
destructiveButtonTitle:nil
otherButtonTitles:@"Reload", debugTitleChrome, debugTitleSafari, fpsMonitor, nil];
[actionSheet addButtonWithTitle:@"Inspect Element"];
if (_liveReloadURL) {
NSString *liveReloadTitle = _liveReloadEnabled ? @"Disable Live Reload" : @"Enable Live Reload";
@@ -300,10 +303,14 @@ - (void)actionSheet:(UIActionSheet *)actionSheet clickedButtonAtIndex:(NSInteger
break;
}
case 4: {
self.liveReloadEnabled = !_liveReloadEnabled;
[_bridge.eventDispatcher sendDeviceEventWithName:@"toggleElementInspector" body:nil];
break;
}
case 5: {
self.liveReloadEnabled = !_liveReloadEnabled;
break;
}
case 6: {
self.profilingEnabled = !_profilingEnabled;
break;
}
@@ -882,6 +882,31 @@ static void RCTSetShadowViewProps(NSDictionary *props, RCTShadowView *shadowView
}];
}
RCT_EXPORT_METHOD(findSubviewIn:(NSNumber *)reactTag atPoint:(CGPoint)point callback:(RCTResponseSenderBlock)callback) {
if (!reactTag) {
callback(@[[NSNull null]]);
return;
}
[self addUIBlock:^(RCTUIManager *uiManager, RCTSparseArray *viewRegistry) {
UIView *view = viewRegistry[reactTag];
UIView *target = [view hitTest:point withEvent:nil];
CGRect frame = [target convertRect:target.bounds toView:view];
while (target.reactTag == nil && target.superview != nil) {
target = [target superview];
}
callback(@[
target.reactTag ?: [NSNull null],
@(frame.origin.x),
@(frame.origin.y),
@(frame.size.width),
@(frame.size.height),
]);
}];
}
- (void)batchDidComplete
{
// Gather blocks to be executed now that all view hierarchy manipulations have

2 comments on commit cfa4b13

@ccheever

This comment has been minimized.

Show comment
Hide comment
@ccheever

ccheever May 30, 2015

The test plan seems like its privacy restricted to Facebook. Any chance you could post the demo publicly?

ccheever replied May 30, 2015

The test plan seems like its privacy restricted to Facebook. Any chance you could post the demo publicly?

@sophiebits

This comment has been minimized.

Show comment
Hide comment
@sophiebits

sophiebits May 31, 2015

It's an internal app, but the video just demos doing ctrl-cmd-z, pressing Inspect Element, and tapping around.

sophiebits replied May 31, 2015

It's an internal app, but the video just demos doing ctrl-cmd-z, pressing Inspect Element, and tapping around.

Please sign in to comment.