Unverified Commit f0163c0c authored by Daniel Chevalier's avatar Daniel Chevalier Committed by GitHub

Shared state to support multi screen inspection (#129452)

![](https://media.giphy.com/media/KY2dtJNlGPH08w41FN/giphy.gif)

Fixes https://github.com/flutter/devtools/issues/5931

With Multi View applications on the way, we need to be able to manage
the state of multiple Inspector widgets in a consistent way.

Previously each Widget inspector would manage the state of it's own
inspection. This made for a confusing and inconsistent experience when
clicking on the widget inspector of different views.

This PR changes the state management to the WidgetInspectorService
static instance so that all widget inspectors can share that state.

# Demo


https://github.com/flutter/flutter/assets/1386322/70fd18dc-5827-4dcd-8cb7-ef20e6221291
parent 6b6f5f06
......@@ -716,7 +716,15 @@ class InspectorReferenceData {
}
// Production implementation of [WidgetInspectorService].
class _WidgetInspectorService = Object with WidgetInspectorService;
class _WidgetInspectorService with WidgetInspectorService {
_WidgetInspectorService() {
selection.addListener(() {
if (selectionChangedCallback != null) {
selectionChangedCallback!();
}
});
}
}
/// Service used by GUI tools to interact with the [WidgetInspector].
///
......@@ -747,6 +755,15 @@ mixin WidgetInspectorService {
/// The current [WidgetInspectorService].
static WidgetInspectorService get instance => _instance;
static WidgetInspectorService _instance = _WidgetInspectorService();
/// Whether the inspector is in select mode.
///
/// In select mode, pointer interactions trigger widget selection instead of
/// normal interactions. Otherwise the previously selected widget is
/// highlighted but the application can be interacted with normally.
@visibleForTesting
final ValueNotifier<bool> isSelectMode = ValueNotifier<bool>(true);
@protected
static set instance(WidgetInspectorService instance) {
_instance = instance;
......@@ -1562,18 +1579,7 @@ mixin WidgetInspectorService {
selection.current = object! as RenderObject;
_sendInspectEvent(selection.current);
}
if (selectionChangedCallback != null) {
if (SchedulerBinding.instance.schedulerPhase == SchedulerPhase.idle) {
selectionChangedCallback!();
} else {
// It isn't safe to trigger the selection change callback if we are in
// the middle of rendering the frame.
SchedulerBinding.instance.scheduleTask(
selectionChangedCallback!,
Priority.touch,
);
}
}
return true;
}
return false;
......@@ -2675,18 +2681,13 @@ class WidgetInspector extends StatefulWidget {
class _WidgetInspectorState extends State<WidgetInspector>
with WidgetsBindingObserver {
_WidgetInspectorState() : selection = WidgetInspectorService.instance.selection;
_WidgetInspectorState();
Offset? _lastPointerLocation;
final InspectorSelection selection;
late InspectorSelection selection;
/// Whether the inspector is in select mode.
///
/// In select mode, pointer interactions trigger widget selection instead of
/// normal interactions. Otherwise the previously selected widget is
/// highlighted but the application can be interacted with normally.
bool isSelectMode = true;
late bool isSelectMode;
final GlobalKey _ignorePointerKey = GlobalKey();
......@@ -2694,28 +2695,32 @@ class _WidgetInspectorState extends State<WidgetInspector>
/// as selecting the edge of the bounding box.
static const double _edgeHitMargin = 2.0;
InspectorSelectionChangedCallback? _selectionChangedCallback;
@override
void initState() {
super.initState();
_selectionChangedCallback = () {
setState(() {
// The [selection] property which the build method depends on has
// changed.
});
};
WidgetInspectorService.instance.selectionChangedCallback = _selectionChangedCallback;
WidgetInspectorService.instance.selection
.addListener(_selectionInformationChanged);
WidgetInspectorService.instance.isSelectMode
.addListener(_selectionInformationChanged);
selection = WidgetInspectorService.instance.selection;
isSelectMode = WidgetInspectorService.instance.isSelectMode.value;
}
@override
void dispose() {
if (WidgetInspectorService.instance.selectionChangedCallback == _selectionChangedCallback) {
WidgetInspectorService.instance.selectionChangedCallback = null;
}
WidgetInspectorService.instance.selection
.removeListener(_selectionInformationChanged);
WidgetInspectorService.instance.isSelectMode
.removeListener(_selectionInformationChanged);
super.dispose();
}
void _selectionInformationChanged() => setState((){
selection = WidgetInspectorService.instance.selection;
isSelectMode = WidgetInspectorService.instance.isSelectMode.value;
});
bool _hitTestHelper(
List<RenderObject> hits,
List<RenderObject> edgeHits,
......@@ -2802,9 +2807,7 @@ class _WidgetInspectorState extends State<WidgetInspector>
final RenderObject userRender = ignorePointer.child!;
final List<RenderObject> selected = hitTest(position, userRender);
setState(() {
selection.candidates = selected;
});
}
void _handlePanDown(DragDownDetails event) {
......@@ -2826,9 +2829,7 @@ class _WidgetInspectorState extends State<WidgetInspector>
final ui.FlutterView view = View.of(context);
final Rect bounds = (Offset.zero & (view.physicalSize / view.devicePixelRatio)).deflate(_kOffScreenMargin);
if (!bounds.contains(_lastPointerLocation!)) {
setState(() {
selection.clear();
});
}
}
......@@ -2840,18 +2841,15 @@ class _WidgetInspectorState extends State<WidgetInspector>
_inspectAt(_lastPointerLocation!);
WidgetInspectorService.instance._sendInspectEvent(selection.current);
}
setState(() {
// Only exit select mode if there is a button to return to select mode.
if (widget.selectButtonBuilder != null) {
isSelectMode = false;
WidgetInspectorService.instance.isSelectMode.value = false;
}
});
}
void _handleEnableSelect() {
setState(() {
isSelectMode = true;
});
WidgetInspectorService.instance.isSelectMode.value = true;
}
@override
......@@ -2885,7 +2883,7 @@ class _WidgetInspectorState extends State<WidgetInspector>
}
/// Mutable selection state of the inspector.
class InspectorSelection {
class InspectorSelection with ChangeNotifier {
/// Render objects that are candidates to be selected.
///
/// Tools may wish to iterate through the list of candidates.
......@@ -2924,6 +2922,7 @@ class InspectorSelection {
if (_current != value) {
_current = value;
_currentElement = (value?.debugCreator as DebugCreator?)?.element;
notifyListeners();
}
}
......@@ -2941,11 +2940,13 @@ class InspectorSelection {
if (element?.debugIsDefunct ?? false) {
_currentElement = null;
_current = null;
notifyListeners();
return;
}
if (currentElement != element) {
_currentElement = element;
_current = element!.findRenderObject();
notifyListeners();
}
}
......@@ -2953,9 +2954,11 @@ class InspectorSelection {
if (_index < candidates.length) {
_current = candidates[index];
_currentElement = (_current?.debugCreator as DebugCreator?)?.element;
notifyListeners();
} else {
_current = null;
_currentElement = null;
notifyListeners();
}
}
......@@ -3234,7 +3237,10 @@ class _InspectorOverlayLayer extends Layer {
Rect targetRect,
) {
canvas.save();
final double maxWidth = size.width - 2 * (_kScreenEdgeMargin + _kTooltipPadding);
final double maxWidth = math.max(
size.width - 2 * (_kScreenEdgeMargin + _kTooltipPadding),
0,
);
final TextSpan? textSpan = _textPainter?.text as TextSpan?;
if (_textPainter == null || textSpan!.text != message || _textPainterMaxWidth != maxWidth) {
_textPainterMaxWidth = maxWidth;
......
......@@ -284,7 +284,9 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
static void runTests() {
final TestWidgetInspectorService service = TestWidgetInspectorService();
WidgetInspectorService.instance = service;
setUp(() {
WidgetInspectorService.instance.isSelectMode.value = true;
});
tearDown(() async {
service.resetAllState();
......@@ -358,8 +360,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) {
return Material(child: ElevatedButton(onPressed: onPressed, key: selectButtonKey, child: null));
}
// State type is private, hence using dynamic.
dynamic getInspectorState() => inspectorKey.currentState;
String paragraphText(RenderParagraph paragraph) {
final TextSpan textSpan = paragraph.text as TextSpan;
return textSpan.text!;
......@@ -394,16 +395,23 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
),
);
expect(getInspectorState().selection.current, isNull); // ignore: avoid_dynamic_calls
expect(WidgetInspectorService.instance.selection.current, isNull);
await tester.tap(find.text('TOP'), warnIfMissed: false);
await tester.pump();
// Tap intercepted by the inspector
expect(log, equals(<String>[]));
// ignore: avoid_dynamic_calls
final InspectorSelection selection = getInspectorState().selection as InspectorSelection;
expect(paragraphText(selection.current! as RenderParagraph), equals('TOP'));
expect(
paragraphText(
WidgetInspectorService.instance.selection.current! as RenderParagraph,
),
equals('TOP'),
);
final RenderObject topButton = find.byKey(topButtonKey).evaluate().first.renderObject!;
expect(selection.candidates, contains(topButton));
expect(
WidgetInspectorService.instance.selection.candidates,
contains(topButton),
);
await tester.tap(find.text('TOP'));
expect(log, equals(<String>['top']));
......@@ -414,7 +422,12 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
log.clear();
// Ensure the inspector selection has not changed to bottom.
// ignore: avoid_dynamic_calls
expect(paragraphText(getInspectorState().selection.current as RenderParagraph), equals('TOP'));
expect(
paragraphText(
WidgetInspectorService.instance.selection.current! as RenderParagraph,
),
equals('TOP'),
);
await tester.tap(find.byKey(selectButtonKey));
await tester.pump();
......@@ -425,7 +438,12 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
expect(log, equals(<String>[]));
log.clear();
// ignore: avoid_dynamic_calls
expect(paragraphText(getInspectorState().selection.current as RenderParagraph), equals('BOTTOM'));
expect(
paragraphText(
WidgetInspectorService.instance.selection.current! as RenderParagraph,
),
equals('BOTTOM'),
);
});
testWidgets('WidgetInspector non-invertible transform regression test', (WidgetTester tester) async {
......@@ -461,8 +479,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) {
return Material(child: ElevatedButton(onPressed: onPressed, key: selectButtonKey, child: null));
}
// State type is private, hence using dynamic.
dynamic getInspectorState() => inspectorKey.currentState;
await tester.pumpWidget(
Directionality(
......@@ -498,7 +514,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
await tester.tap(find.byType(ListView), warnIfMissed: false);
await tester.pump();
expect(getInspectorState().selection.current, isNotNull); // ignore: avoid_dynamic_calls
expect(WidgetInspectorService.instance.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);
......@@ -582,17 +598,23 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
);
await tester.longPress(find.byKey(clickTarget), warnIfMissed: false);
// 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.
// ignore: avoid_dynamic_calls
expect(inspectorState.selection.current.semanticBounds.width, equals(95.0));
expect(
WidgetInspectorService.instance.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.
// ignore: avoid_dynamic_calls
expect(inspectorState.selection.candidates.where((RenderObject object) => object is RenderParagraph).length, equals(2));
expect(
WidgetInspectorService.instance.selection.candidates
.whereType<RenderParagraph>()
.length,
equals(2),
);
});
testWidgets('WidgetInspector with Transform above', (WidgetTester tester) async {
......@@ -661,9 +683,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
};
}
// State type is private, hence using dynamic.
// The inspector state is static, so it's enough with reading one of them.
dynamic getInspectorState() => inspector1Key.currentState;
String paragraphText(RenderParagraph paragraph) {
final TextSpan textSpan = paragraph.text as TextSpan;
return textSpan.text!;
......@@ -699,18 +718,27 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
),
);
// ignore: avoid_dynamic_calls
final InspectorSelection selection = getInspectorState().selection as InspectorSelection;
// The selection is static, so it may be initialized from previous tests.
selection.clear();
await tester.tap(find.text('Child 1'), warnIfMissed: false);
await tester.pump();
expect(paragraphText(selection.current! as RenderParagraph), equals('Child 1'));
expect(
paragraphText(
WidgetInspectorService.instance.selection.current! as RenderParagraph,
),
equals('Child 1'),
);
// Re-enable select mode since it's state is shared between the
// WidgetInspectors
WidgetInspectorService.instance.isSelectMode.value = true;
await tester.tap(find.text('Child 2'), warnIfMissed: false);
await tester.pump();
expect(paragraphText(selection.current! as RenderParagraph), equals('Child 2'));
expect(
paragraphText(
WidgetInspectorService.instance.selection.current! as RenderParagraph,
),
equals('Child 2'),
);
});
test('WidgetInspectorService null id', () {
......@@ -2001,6 +2029,52 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(), // [intended] Test requires --track-widget-creation flag.
);
group('InspectorSelection', () {
testWidgets('receives notifications when selection changes',
(WidgetTester tester) async {
await tester.pumpWidget(
const Directionality(
textDirection: TextDirection.ltr,
child: Stack(
children: <Widget>[
Text('a'),
Text('b'),
],
),
),
);
final InspectorSelection selection = InspectorSelection();
int count = 0;
selection.addListener(() {
count++;
});
final RenderParagraph renderObjectA =
tester.renderObject<RenderParagraph>(find.text('a'));
final RenderParagraph renderObjectB =
tester.renderObject<RenderParagraph>(find.text('b'));
final Element elementA = find.text('a').evaluate().first;
selection.candidates = <RenderObject>[renderObjectA, renderObjectB];
await tester.pump();
expect(count, equals(1));
selection.index = 1;
await tester.pump();
expect(count, equals(2));
selection.clear();
await tester.pump();
expect(count, equals(3));
selection.current = renderObjectA;
await tester.pump();
expect(count, equals(4));
selection.currentElement = elementA;
expect(count, equals(5));
});
});
test('ext.flutter.inspector.disposeGroup', () async {
final Object a = Object();
const String group1 = 'group-1';
......
......@@ -37,6 +37,13 @@ class DispatchedEventKey {
}
class TestWidgetInspectorService extends Object with WidgetInspectorService {
TestWidgetInspectorService() {
selection.addListener(() {
if (selectionChangedCallback != null) {
selectionChangedCallback!();
}
});
}
final Map<String, ServiceExtensionCallback> extensions = <String, ServiceExtensionCallback>{};
final Map<DispatchedEventKey, List<Map<Object, Object?>>> eventsDispatched =
......
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