Commit 5462ddb9 authored by Jacob Richman's avatar Jacob Richman Committed by GitHub

Bare bones widget inspector support. (#10332)

Bare bones widget inspector support.

Toggle the widget inspector from the flutter tool by pressing 'i'.
When the widget inspector is select mode:
Pointer down to to inspect a widget.
Pointer click to finalize selection of a widget. You can now interact
with the application as you normally would but with the inspected widget
highlighted.
Click the inspect icon in bottom left corner of screen to reactivate
select mode.
parent 9c04aa15
......@@ -29,3 +29,4 @@ export 'src/painting/text_painter.dart';
export 'src/painting/text_span.dart';
export 'src/painting/text_style.dart';
export 'src/painting/transforms.dart';
export 'src/painting/utils.dart';
......@@ -8,6 +8,8 @@ import 'package:flutter/widgets.dart';
import 'arc.dart';
import 'colors.dart';
import 'floating_action_button.dart';
import 'icons.dart';
import 'page.dart';
import 'theme.dart';
......@@ -373,6 +375,13 @@ class _MaterialAppState extends State<MaterialApp> {
checkerboardOffscreenLayers: widget.checkerboardOffscreenLayers,
showSemanticsDebugger: widget.showSemanticsDebugger,
debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
inspectorSelectButtonBuilder: (BuildContext context, VoidCallback onPressed) {
return new FloatingActionButton(
child: const Icon(Icons.search),
onPressed: onPressed,
mini: true,
);
},
)
);
......
......@@ -3,17 +3,16 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'feedback.dart';
import 'theme.dart';
import 'theme_data.dart';
const double _kScreenEdgeMargin = 10.0;
const Duration _kFadeDuration = const Duration(milliseconds: 200);
const Duration _kShowDuration = const Duration(milliseconds: 1500);
......@@ -198,15 +197,32 @@ class _TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
}
}
/// A delegate for computing the layout of a tooltip to be displayed above or
/// bellow a target specified in the global coordinate system.
class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
/// Creates a delegate for computing the layout of a tooltip.
///
/// The arguments must not be null.
_TooltipPositionDelegate({
this.target,
this.verticalOffset,
this.preferBelow
});
@required this.target,
@required this.verticalOffset,
@required this.preferBelow,
}) : assert(target != null),
assert(verticalOffset != null),
assert(preferBelow != null);
/// The offset of the target the tooltip is positioned near in the global
/// coordinate system.
final Offset target;
/// The amount of vertical distance between the target and the displayed
/// tooltip.
final double verticalOffset;
/// Whether the tooltip defaults to being displayed below the widget.
///
/// If there is insufficient space to display the tooltip in the preferred
/// direction, the tooltip will be displayed in the opposite direction.
final bool preferBelow;
@override
......@@ -214,26 +230,13 @@ class _TooltipPositionDelegate extends SingleChildLayoutDelegate {
@override
Offset getPositionForChild(Size size, Size childSize) {
// VERTICAL DIRECTION
final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - _kScreenEdgeMargin;
final bool fitsAbove = target.dy - verticalOffset - childSize.height >= _kScreenEdgeMargin;
final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow);
double y;
if (tooltipBelow)
y = math.min(target.dy + verticalOffset, size.height - _kScreenEdgeMargin);
else
y = math.max(target.dy - verticalOffset - childSize.height, _kScreenEdgeMargin);
// HORIZONTAL DIRECTION
final double normalizedTargetX = target.dx.clamp(_kScreenEdgeMargin, size.width - _kScreenEdgeMargin);
double x;
if (normalizedTargetX < _kScreenEdgeMargin + childSize.width / 2.0) {
x = _kScreenEdgeMargin;
} else if (normalizedTargetX > size.width - _kScreenEdgeMargin - childSize.width / 2.0) {
x = size.width - _kScreenEdgeMargin - childSize.width;
} else {
x = normalizedTargetX - childSize.width / 2.0;
}
return new Offset(x, y);
return positionDependentBox(
size: size,
childSize: childSize,
target: target,
verticalOffset: verticalOffset,
preferBelow: preferBelow,
);
}
@override
......
// Copyright 2017 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:math' as math;
import 'package:flutter/foundation.dart';
import 'basic_types.dart';
const double _kScreenEdgeMargin = 10.0;
/// Position a box either above or bellow a target box specified in the global
/// coordinate system.
///
/// The target box is specified by [size] and [target] and the box being
/// positioned is specified by [childSize]. [verticalOffset] is the amount of
/// vertical distance between the boxes.
///
/// Used by [Tooltip] to position a tooltip relative to its parent.
///
/// The arguments must not be null.
Offset positionDependentBox({
@required Size size,
@required Size childSize,
@required Offset target,
@required double verticalOffset,
@required bool preferBelow,
}) {
assert(size != null);
assert(childSize != null);
assert(target != null);
assert(verticalOffset != null);
assert(preferBelow != null);
// VERTICAL DIRECTION
final bool fitsBelow = target.dy + verticalOffset + childSize.height <= size.height - _kScreenEdgeMargin;
final bool fitsAbove = target.dy - verticalOffset - childSize.height >= _kScreenEdgeMargin;
final bool tooltipBelow = preferBelow ? fitsBelow || !fitsAbove : !(fitsAbove || !fitsBelow);
double y;
if (tooltipBelow)
y = math.min(target.dy + verticalOffset, size.height - _kScreenEdgeMargin);
else
y = math.max(target.dy - verticalOffset - childSize.height, _kScreenEdgeMargin);
// HORIZONTAL DIRECTION
final double normalizedTargetX = target.dx.clamp(_kScreenEdgeMargin, size.width - _kScreenEdgeMargin);
double x;
if (normalizedTargetX < _kScreenEdgeMargin + childSize.width / 2.0) {
x = _kScreenEdgeMargin;
} else if (normalizedTargetX > size.width - _kScreenEdgeMargin - childSize.width / 2.0) {
x = size.width - _kScreenEdgeMargin - childSize.width;
} else {
x = normalizedTargetX - childSize.width / 2.0;
}
return new Offset(x, y);
}
......@@ -2817,7 +2817,6 @@ abstract class RenderObject extends AbstractNode with DiagnosticableTreeMixin im
description.add(new FlagProperty('isSemanticBoundary', value: isSemanticBoundary, ifTrue: 'semantic boundary'));
}
@protected
@override
List<DiagnosticsNode> debugDescribeChildren() => <DiagnosticsNode>[];
......
......@@ -20,6 +20,7 @@ import 'performance_overlay.dart';
import 'semantics_debugger.dart';
import 'text.dart';
import 'title.dart';
import 'widget_inspector.dart';
/// Signature for a function that is called when the operating system changes the current locale.
///
......@@ -58,7 +59,9 @@ class WidgetsApp extends StatefulWidget {
this.checkerboardRasterCacheImages: false,
this.checkerboardOffscreenLayers: false,
this.showSemanticsDebugger: false,
this.debugShowCheckedModeBanner: true
this.debugShowWidgetInspector: false,
this.debugShowCheckedModeBanner: true,
this.inspectorSelectButtonBuilder,
}) : assert(onGenerateRoute != null),
assert(color != null),
assert(navigatorObservers != null),
......@@ -67,6 +70,7 @@ class WidgetsApp extends StatefulWidget {
assert(checkerboardOffscreenLayers != null),
assert(showSemanticsDebugger != null),
assert(debugShowCheckedModeBanner != null),
assert(debugShowWidgetInspector != null),
super(key: key);
/// A one-line description of this app for use in the window manager.
......@@ -148,6 +152,21 @@ class WidgetsApp extends StatefulWidget {
/// reported by the framework.
final bool showSemanticsDebugger;
/// Turns on an overlay that enables inspecting the widget tree.
///
/// The inspector is only available in checked mode as it depends on
/// [RenderObject.debugDescribeChildren] which should not be called outside of
/// checked mode.
final bool debugShowWidgetInspector;
/// Builds the widget the [WidgetInspector] uses to switch between view and
/// inspect modes.
///
/// This lets [MaterialApp] to use a material button to toggle the inspector
/// select mode without requiring [WidgetInspector] to depend on the the
/// material package.
final InspectorSelectButtonBuilder inspectorSelectButtonBuilder;
/// Turns on a "SLOW MODE" little banner in checked mode to indicate
/// that the app is in checked mode. This is on by default (in
/// checked mode), to turn it off, set the constructor argument to
......@@ -168,12 +187,22 @@ class WidgetsApp extends StatefulWidget {
/// If true, forces the performance overlay to be visible in all instances.
///
/// Used by `showPerformanceOverlay` observatory extension.
/// Used by the `showPerformanceOverlay` observatory extension.
static bool showPerformanceOverlayOverride = false;
/// If true, forces the widget inspector to be visible.
///
/// Used by the `debugShowWidgetInspector` debugging extension.
///
/// The inspector allows you to select a location on your device or emulator
/// and view what widgets and render objects associated with it. An outline of
/// the selected widget and some summary information is shown on device and
/// more detailed information is shown in the IDE or Observatory.
static bool debugShowWidgetInspectorOverride = false;
/// If false, prevents the debug banner from being visible.
///
/// Used by `debugAllowBanner` observatory extension.
/// Used by the `debugAllowBanner` observatory extension.
///
/// This is how `flutter run` turns off the banner when you take a screen shot
/// with "s".
......@@ -303,17 +332,24 @@ class _WidgetsAppState extends State<WidgetsApp> implements WidgetsBindingObserv
}
if (widget.showSemanticsDebugger) {
result = new SemanticsDebugger(
child: result
child: result,
);
}
assert(() {
if (widget.debugShowWidgetInspector || WidgetsApp.debugShowWidgetInspectorOverride) {
result = new WidgetInspector(
child: result,
selectButtonBuilder: widget.inspectorSelectButtonBuilder,
);
}
if (widget.debugShowCheckedModeBanner && WidgetsApp.debugAllowBannerOverride) {
result = new CheckedModeBanner(
child: result
child: result,
);
}
return true;
});
return result;
}
......
......@@ -219,6 +219,17 @@ abstract class WidgetsBinding extends BindingBase with GestureBinding, RendererB
return _forceRebuild();
}
);
registerBoolServiceExtension(
name: 'debugWidgetInspector',
getter: () async => WidgetsApp.debugShowWidgetInspectorOverride,
setter: (bool value) {
if (WidgetsApp.debugShowWidgetInspectorOverride == value)
return new Future<Null>.value();
WidgetsApp.debugShowWidgetInspectorOverride = value;
return _forceRebuild();
}
);
}
Future<Null> _forceRebuild() {
......
This diff is collapsed.
......@@ -91,4 +91,5 @@ export 'src/widgets/title.dart';
export 'src/widgets/transitions.dart';
export 'src/widgets/unique_widget.dart';
export 'src/widgets/viewport.dart';
export 'src/widgets/widget_inspector.dart';
export 'src/widgets/will_pop_scope.dart';
......@@ -433,6 +433,29 @@ void main() {
expect(binding.frameScheduled, isFalse);
});
test('Service extensions - debugWidgetInspector', () async {
Map<String, String> result;
expect(binding.frameScheduled, isFalse);
expect(WidgetsApp.debugShowWidgetInspectorOverride, false);
result = await binding.testExtension('debugWidgetInspector', <String, String>{});
expect(result, <String, String>{ 'enabled': 'false' });
expect(WidgetsApp.debugShowWidgetInspectorOverride, false);
result = await binding.testExtension('debugWidgetInspector', <String, String>{ 'enabled': 'true' });
expect(result, <String, String>{ 'enabled': 'true' });
expect(WidgetsApp.debugShowWidgetInspectorOverride, true);
result = await binding.testExtension('debugWidgetInspector', <String, String>{});
expect(result, <String, String>{ 'enabled': 'true' });
expect(WidgetsApp.debugShowWidgetInspectorOverride, true);
result = await binding.testExtension('debugWidgetInspector', <String, String>{ 'enabled': 'false' });
expect(result, <String, String>{ 'enabled': 'false' });
expect(WidgetsApp.debugShowWidgetInspectorOverride, false);
result = await binding.testExtension('debugWidgetInspector', <String, String>{});
expect(result, <String, String>{ 'enabled': 'false' });
expect(WidgetsApp.debugShowWidgetInspectorOverride, false);
expect(binding.frameScheduled, isFalse);
});
test('Service extensions - timeDilation', () async {
Map<String, String> result;
......@@ -459,7 +482,7 @@ void main() {
test('Service extensions - posttest', () async {
// If you add a service extension... TEST IT! :-)
// ...then increment this number.
expect(binding.extensions.length, 15);
expect(binding.extensions.length, 16);
expect(console, isEmpty);
debugPrint = debugPrintThrottled;
......
// Copyright 2015 The Chromium Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('WidgetInspector smoke test', (WidgetTester tester) async {
// This is a smoke test to verify that adding the inspector doesn't crash.
await tester.pumpWidget(
new Stack(
children: <Widget>[
const Text('a'),
const Text('b'),
const Text('c'),
],
),
);
await tester.pumpWidget(
new WidgetInspector(
selectButtonBuilder: null,
child: new Stack(
children: <Widget>[
const Text('a'),
const Text('b'),
const Text('c'),
],
),
),
);
expect(true, isTrue); // Expect that we reach here without crashing.
});
testWidgets('WidgetInspector interaction test', (WidgetTester tester) async {
final List<String> log = <String>[];
final GlobalKey selectButtonKey = new GlobalKey();
final GlobalKey inspectorKey = new GlobalKey();
final GlobalKey topButtonKey = new GlobalKey();
Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) {
return new Material(child: new RaisedButton(onPressed: onPressed, key: selectButtonKey));
}
// State type is private, hence using dynamic.
dynamic getInspectorState() => inspectorKey.currentState;
String paragraphText(RenderParagraph paragraph) => paragraph.text.text;
await tester.pumpWidget(
new WidgetInspector(
key: inspectorKey,
selectButtonBuilder: selectButtonBuilder,
child: new Material(
child: new ListView(
children: <Widget>[
new RaisedButton(
key: topButtonKey,
onPressed: () {
log.add('top');
},
child: const Text('TOP'),
),
new RaisedButton(
onPressed: () {
log.add('bottom');
},
child: const Text('BOTTOM'),
),
],
),
),
),
);
expect(getInspectorState().selection.current, isNull);
await tester.tap(find.text('TOP'));
await tester.pump();
// Tap intercepted by the inspector
expect(log, equals(<String>[]));
final InspectorSelection selection = getInspectorState().selection;
expect(paragraphText(selection.current), equals('TOP'));
final RenderObject topButton = find.byKey(topButtonKey).evaluate().first.renderObject;
expect(selection.candidates.contains(topButton), isTrue);
await tester.tap(find.text('TOP'));
expect(log, equals(<String>['top']));
log.clear();
await tester.tap(find.text('BOTTOM'));
expect(log, equals(<String>['bottom']));
log.clear();
// Ensure the inspector selection has not changed to bottom.
expect(paragraphText(getInspectorState().selection.current), equals('TOP'));
await tester.tap(find.byKey(selectButtonKey));
await tester.pump();
// We are now back in select mode so tapping the bottom button will have
// not trigger a click but will cause it to be selected.
await tester.tap(find.text('BOTTOM'));
expect(log, equals(<String>[]));
log.clear();
expect(paragraphText(getInspectorState().selection.current), equals('BOTTOM'));
});
testWidgets('WidgetInspector scroll test', (WidgetTester tester) async {
final Key childKey = new UniqueKey();
final GlobalKey selectButtonKey = new GlobalKey();
final GlobalKey inspectorKey = new GlobalKey();
Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) {
return new Material(child: new RaisedButton(onPressed: onPressed, key: selectButtonKey));
}
// State type is private, hence using dynamic.
dynamic getInspectorState() => inspectorKey.currentState;
await tester.pumpWidget(
new WidgetInspector(
key: inspectorKey,
selectButtonBuilder: selectButtonBuilder,
child: new ListView(
children: <Widget>[
new Container(
key: childKey,
height: 5000.0,
),
],
),
),
);
expect(tester.getTopLeft(find.byKey(childKey)).dy, equals(0.0));
await tester.fling(find.byType(ListView), const Offset(0.0, -200.0), 200.0);
await tester.pump();
// Fling does nothing as are in inspect mode.
expect(tester.getTopLeft(find.byKey(childKey)).dy, equals(0.0));
await tester.fling(find.byType(ListView), const Offset(200.0, 0.0), 200.0);
await tester.pump();
// Fling still does nothing as are in inspect mode.
expect(tester.getTopLeft(find.byKey(childKey)).dy, equals(0.0));
await tester.tap(find.byType(ListView));
await tester.pump();
expect(getInspectorState().selection.current, isNotNull);
// Now out of inspect mode due to the click.
await tester.fling(find.byType(ListView), const Offset(0.0, -200.0), 200.0);
await tester.pump();
expect(tester.getTopLeft(find.byKey(childKey)).dy, equals(-200.0));
await tester.fling(find.byType(ListView), const Offset(0.0, 200.0), 200.0);
await tester.pump();
expect(tester.getTopLeft(find.byKey(childKey)).dy, equals(0.0));
});
testWidgets('WidgetInspector long press', (WidgetTester tester) async {
bool didLongPress = false;
await tester.pumpWidget(
new WidgetInspector(
selectButtonBuilder: null,
child: new GestureDetector(
onLongPress: () {
expect(didLongPress, isFalse);
didLongPress = true;
},
child: const Text('target'),
),
),
);
await tester.longPress(find.text('target'));
// The inspector will swallow the long press.
expect(didLongPress, isFalse);
});
testWidgets('WidgetInspector offstage', (WidgetTester tester) async {
final GlobalKey inspectorKey = new GlobalKey();
final GlobalKey clickTarget = new GlobalKey();
Widget createSubtree({ double width, Key key }) {
return new Stack(
children: <Widget>[
new Positioned(
key: key,
left: 0.0,
top: 0.0,
width: width,
height: 100.0,
child: new Text(width.toString()),
),
]
);
}
await tester.pumpWidget(
new WidgetInspector(
key: inspectorKey,
selectButtonBuilder: null,
child: new Overlay(
initialEntries: <OverlayEntry>[
new OverlayEntry(
opaque: false,
maintainState: true,
builder: (BuildContext _) => createSubtree(width: 94.0),
),
new OverlayEntry(
opaque: true,
maintainState: true,
builder: (BuildContext _) => createSubtree(width: 95.0),
),
new OverlayEntry(
opaque: false,
maintainState: true,
builder: (BuildContext _) => createSubtree(width: 96.0, key: clickTarget),
),
],
),
),
);
await tester.longPress(find.byKey(clickTarget));
// State type is private, hence using dynamic.
final dynamic inspectorState = inspectorKey.currentState;
// The object with width 95.0 wins over the object with width 94.0 because
// the subtree with width 94.0 is offstage.
expect(inspectorState.selection.current.semanticBounds.width, equals(95.0));
// Exactly 2 out of the 3 text elements should be in the candidate list of
// objects to select as only 2 are onstage.
expect(inspectorState.selection.candidates.where((RenderObject object) => object is RenderParagraph).length, equals(2));
});
}
......@@ -160,6 +160,11 @@ class FlutterDevice {
await view.uiIsolate.flutterTogglePerformanceOverlayOverride();
}
Future<Null> toggleWidgetInspector() async {
for (FlutterView view in views)
await view.uiIsolate.flutterToggleWidgetInspector();
}
Future<String> togglePlatform({ String from }) async {
String to;
switch (from) {
......@@ -446,6 +451,12 @@ abstract class ResidentRunner {
await device.debugTogglePerformanceOverlayOverride();
}
Future<Null> _debugToggleWidgetInspector() async {
await refreshViews();
for (FlutterDevice device in flutterDevices)
await device.toggleWidgetInspector();
}
Future<Null> _screenshot(FlutterDevice device) async {
final Status status = logger.startProgress('Taking screenshot for ${device.device.name}...');
final File outputFile = getUniqueFile(fs.currentDirectory, 'flutter', 'png');
......@@ -609,6 +620,10 @@ abstract class ResidentRunner {
} else if (character == 'P') {
if (supportsServiceProtocol) {
await _debugTogglePerformanceOverlayOverride();
}
} else if (lower == 'i') {
if (supportsServiceProtocol) {
await _debugToggleWidgetInspector();
return true;
}
} else if (character == 's') {
......@@ -731,6 +746,7 @@ abstract class ResidentRunner {
printStatus('To dump the rendering tree of the app (debugDumpRenderTree), press "t".');
if (isRunningDebug) {
printStatus('For layers (debugDumpLayerTree), use "L"; accessibility (debugDumpSemantics), "S".');
printStatus('To toggle the widget inspector (WidgetsApp.showWidgetInspectorOverride), press "i".');
printStatus('To toggle the display of construction lines (debugPaintSizeEnabled), press "p".');
printStatus('To simulate different operating systems, (defaultTargetPlatform), press "o".');
} else {
......
......@@ -1090,6 +1090,8 @@ class Isolate extends ServiceObjectOwner {
Future<Map<String, dynamic>> flutterTogglePerformanceOverlayOverride() => _flutterToggle('showPerformanceOverlay');
Future<Map<String, dynamic>> flutterToggleWidgetInspector() => _flutterToggle('debugWidgetInspector');
Future<Null> flutterDebugAllowBanner(bool show) async {
await invokeFlutterExtensionRpcRaw(
'ext.flutter.debugAllowBanner',
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment