[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
This commit is contained in:
parent
4273af9e29
commit
cfa4b13472
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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 @@ RCT_EXPORT_METHOD(show)
|
|||
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 @@ RCT_EXPORT_METHOD(reload)
|
|||
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 @@ RCT_EXPORT_METHOD(blur:(NSNumber *)reactTag)
|
|||
}];
|
||||
}
|
||||
|
||||
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
|
||||
|
|
Loading…
Reference in New Issue