diff --git a/packages/flutter/lib/src/cupertino/app.dart b/packages/flutter/lib/src/cupertino/app.dart index cfb848dc76..772314f8e3 100644 --- a/packages/flutter/lib/src/cupertino/app.dart +++ b/packages/flutter/lib/src/cupertino/app.dart @@ -534,18 +534,51 @@ class _CupertinoAppState extends State { ]; } - Widget _inspectorSelectButtonBuilder(BuildContext context, VoidCallback onPressed) { - return CupertinoButton.filled( + Widget _exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { + return CupertinoButton( + key: key, + color: _widgetSelectionButtonsBackgroundColor(context), padding: EdgeInsets.zero, onPressed: onPressed, - child: const Icon( - CupertinoIcons.search, + child: Icon( + CupertinoIcons.xmark, size: 28.0, - color: CupertinoColors.white, + color: _widgetSelectionButtonsForegroundColor(context), + semanticLabel: 'Exit Select Widget mode.', ), ); } + Widget _moveExitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + bool isLeftAligned = true, + }) { + return CupertinoButton( + onPressed: onPressed, + padding: EdgeInsets.zero, + child: Icon( + isLeftAligned ? CupertinoIcons.arrow_right : CupertinoIcons.arrow_left, + size: 32.0, + color: _widgetSelectionButtonsBackgroundColor(context), + semanticLabel: + 'Move "Exit Select Widget mode" button to the ${isLeftAligned ? 'right' : 'left'}.', + ), + ); + } + + Color _widgetSelectionButtonsForegroundColor(BuildContext context) { + return CupertinoTheme.of(context).primaryContrastingColor; + } + + Color _widgetSelectionButtonsBackgroundColor(BuildContext context) { + return CupertinoTheme.of(context).primaryColor; + } + WidgetsApp _buildWidgetApp(BuildContext context) { final CupertinoThemeData effectiveThemeData = CupertinoTheme.of(context); final Color color = CupertinoDynamicColor.resolve(widget.color ?? effectiveThemeData.primaryColor, context); @@ -572,7 +605,9 @@ class _CupertinoAppState extends State { showPerformanceOverlay: widget.showPerformanceOverlay, showSemanticsDebugger: widget.showSemanticsDebugger, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, - inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: + _moveExitWidgetSelectionButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, @@ -606,7 +641,9 @@ class _CupertinoAppState extends State { showPerformanceOverlay: widget.showPerformanceOverlay, showSemanticsDebugger: widget.showSemanticsDebugger, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, - inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: + _moveExitWidgetSelectionButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index 3b871d56a2..b8695a5409 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -22,6 +22,7 @@ import 'package:flutter/services.dart'; import 'arc.dart'; import 'colors.dart'; import 'floating_action_button.dart'; +import 'icon_button.dart'; import 'icons.dart'; import 'material_localizations.dart'; import 'page.dart'; @@ -904,6 +905,9 @@ class MaterialScrollBehavior extends ScrollBehavior { } class _MaterialAppState extends State { + static const double _moveExitWidgetSelectionIconSize = 32; + static const double _moveExitWidgetSelectionTargetSize = 40; + late HeroController _heroController; bool get _usesRouter => widget.routerDelegate != null || widget.routerConfig != null; @@ -934,14 +938,65 @@ class _MaterialAppState extends State { ]; } - Widget _inspectorSelectButtonBuilder(BuildContext context, VoidCallback onPressed) { + Widget _exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { return FloatingActionButton( + key: key, onPressed: onPressed, mini: true, - child: const Icon(Icons.search), + backgroundColor: _widgetSelectionButtonsBackgroundColor(context), + foregroundColor: _widgetSelectionButtonsForegroundColor(context), + child: const Icon( + Icons.close, + semanticLabel: 'Exit Select Widget mode.', + ), ); } + Widget _moveExitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + bool isLeftAligned = true, + }) { + return IconButton( + color: _widgetSelectionButtonsBackgroundColor(context), + padding: EdgeInsets.zero, + iconSize: _moveExitWidgetSelectionIconSize, + onPressed: onPressed, + constraints: const BoxConstraints( + minWidth: _moveExitWidgetSelectionTargetSize, + minHeight: _moveExitWidgetSelectionTargetSize, + ), + icon: Icon( + isLeftAligned ? Icons.arrow_right : Icons.arrow_left, + semanticLabel: + 'Move "Exit Select Widget mode" button to the ${isLeftAligned ? 'right' : 'left'}.', + )); + } + + Color _widgetSelectionButtonsForegroundColor(BuildContext context) { + final ThemeData theme = Theme.of(context); + return _isDarkTheme(context) + ? theme.colorScheme.onPrimaryContainer + : theme.colorScheme.primaryContainer; + } + + Color _widgetSelectionButtonsBackgroundColor(BuildContext context) { + final ThemeData theme = Theme.of(context); + return _isDarkTheme(context) + ? theme.colorScheme.primaryContainer + : theme.colorScheme.onPrimaryContainer; + } + + bool _isDarkTheme(BuildContext context) { + return widget.themeMode == ThemeMode.dark || + widget.themeMode == ThemeMode.system && + MediaQuery.platformBrightnessOf(context) == Brightness.dark; + } + ThemeData _themeBuilder(BuildContext context) { ThemeData? theme; // Resolve which theme to use based on brightness and high contrast. @@ -1044,7 +1099,9 @@ class _MaterialAppState extends State { showPerformanceOverlay: widget.showPerformanceOverlay, showSemanticsDebugger: widget.showSemanticsDebugger, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, - inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: + _moveExitWidgetSelectionButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, @@ -1078,7 +1135,9 @@ class _MaterialAppState extends State { showPerformanceOverlay: widget.showPerformanceOverlay, showSemanticsDebugger: widget.showSemanticsDebugger, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, - inspectorSelectButtonBuilder: _inspectorSelectButtonBuilder, + exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: + _moveExitWidgetSelectionButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index 88720bd71c..872f0319c3 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -341,7 +341,8 @@ class WidgetsApp extends StatefulWidget { this.showSemanticsDebugger = false, this.debugShowWidgetInspector = false, this.debugShowCheckedModeBanner = true, - this.inspectorSelectButtonBuilder, + this.exitWidgetSelectionButtonBuilder, + this.moveExitWidgetSelectionButtonBuilder, this.shortcuts, this.actions, this.restorationScopeId, @@ -439,7 +440,8 @@ class WidgetsApp extends StatefulWidget { this.showSemanticsDebugger = false, this.debugShowWidgetInspector = false, this.debugShowCheckedModeBanner = true, - this.inspectorSelectButtonBuilder, + this.exitWidgetSelectionButtonBuilder, + this.moveExitWidgetSelectionButtonBuilder, this.shortcuts, this.actions, this.restorationScopeId, @@ -1026,13 +1028,20 @@ class WidgetsApp extends StatefulWidget { /// debug mode. final bool debugShowWidgetInspector; - /// Builds the widget the [WidgetInspector] uses to switch between view and - /// inspect modes. + /// Builds the widget the [WidgetInspector] uses to exit selection mode. /// - /// This lets [MaterialApp] to use a Material Design button to toggle the + /// This lets [MaterialApp] to use a Material Design button to exit the /// inspector select mode without requiring [WidgetInspector] to depend on the - /// material package. - final InspectorSelectButtonBuilder? inspectorSelectButtonBuilder; + /// Material package. + final ExitWidgetSelectionButtonBuilder? exitWidgetSelectionButtonBuilder; + + /// Builds the widget the [WidgetInspector] uses to move the exit selection + /// mode button. + /// + /// This lets [MaterialApp] to use a Material Design button to change the + /// alignment without requiring [WidgetInspector] to depend on the Material + /// package. + final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder; /// {@template flutter.widgets.widgetsApp.debugShowCheckedModeBanner} /// Turns on a little "DEBUG" banner in debug mode to indicate @@ -1755,7 +1764,8 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { builder: (BuildContext context, bool debugShowWidgetInspectorOverride, Widget? child) { if (widget.debugShowWidgetInspector || debugShowWidgetInspectorOverride) { return WidgetInspector( - selectButtonBuilder: widget.inspectorSelectButtonBuilder, + exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder, child: child!, ); } diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index 072b94e920..5e8f9e92d4 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -37,8 +37,20 @@ import 'service_extensions.dart'; import 'view.dart'; /// Signature for the builder callback used by -/// [WidgetInspector.selectButtonBuilder]. -typedef InspectorSelectButtonBuilder = Widget Function(BuildContext context, VoidCallback onPressed); +/// [WidgetInspector.exitWidgetSelectionButtonBuilder]. +typedef ExitWidgetSelectionButtonBuilder = Widget Function( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, +}); + +/// Signature for the builder callback used by +/// [WidgetInspector.moveExitWidgetSelectionButtonBuilder]. +typedef MoveExitWidgetSelectionButtonBuilder = Widget Function( + BuildContext context, { + required VoidCallback onPressed, + bool isLeftAligned, +}); /// Signature for a method that registers the service extension `callback` with /// the given `name`. @@ -774,13 +786,15 @@ mixin WidgetInspectorService { static WidgetInspectorService get instance => _instance; static WidgetInspectorService _instance = _WidgetInspectorService(); - /// Whether the inspector is in select mode. + /// Enables select mode for the Inspector. /// /// 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 isSelectMode = ValueNotifier(true); + set isSelectMode(bool enabled) { + _changeWidgetSelectionMode(enabled); + } @protected static set instance(WidgetInspectorService instance) { @@ -1622,6 +1636,13 @@ mixin WidgetInspectorService { } } + /// Changes whether widget selection mode is [enabled]. + void _changeWidgetSelectionMode(bool enabled) { + WidgetsBinding.instance.debugShowWidgetInspectorOverride = enabled; + _postExtensionStateChangedEvent( + WidgetInspectorServiceExtensions.show.name, enabled); + } + /// Returns a DevTools uri linking to a specific element on the inspector page. String? _devToolsInspectorUriForElement(Element element) { if (activeDevToolsServerAddress != null && connectedVmServiceUri != null) { @@ -2758,17 +2779,28 @@ class WidgetInspector extends StatefulWidget { const WidgetInspector({ super.key, required this.child, - required this.selectButtonBuilder, + required this.exitWidgetSelectionButtonBuilder, + required this.moveExitWidgetSelectionButtonBuilder, }); /// The widget that is being inspected. final Widget child; - /// A builder that is called to create the select button. + /// A builder that is called to create the exit select-mode button. + /// + /// The `onPressed` callback and key passed as arguments to the builder should + /// be hooked up to the returned widget. + final ExitWidgetSelectionButtonBuilder? exitWidgetSelectionButtonBuilder; + + /// A builder that is called to create the button that moves the exit select- + /// mode button to the right or left. /// /// The `onPressed` callback passed as an argument to the builder should be /// hooked up to the returned widget. - final InspectorSelectButtonBuilder? selectButtonBuilder; + /// + /// The button UI should respond to the `leftAligned` argument. + final MoveExitWidgetSelectionButtonBuilder? + moveExitWidgetSelectionButtonBuilder; @override State createState() => _WidgetInspectorState(); @@ -2797,24 +2829,24 @@ class _WidgetInspectorState extends State WidgetInspectorService.instance.selection .addListener(_selectionInformationChanged); - WidgetInspectorService.instance.isSelectMode + WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier .addListener(_selectionInformationChanged); selection = WidgetInspectorService.instance.selection; - isSelectMode = WidgetInspectorService.instance.isSelectMode.value; + isSelectMode = WidgetsBinding.instance.debugShowWidgetInspectorOverride; } @override void dispose() { WidgetInspectorService.instance.selection .removeListener(_selectionInformationChanged); - WidgetInspectorService.instance.isSelectMode + WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier .removeListener(_selectionInformationChanged); super.dispose(); } void _selectionInformationChanged() => setState((){ selection = WidgetInspectorService.instance.selection; - isSelectMode = WidgetInspectorService.instance.isSelectMode.value; + isSelectMode = WidgetsBinding.instance.debugShowWidgetInspectorOverride; }); bool _hitTestHelper( @@ -2937,22 +2969,13 @@ class _WidgetInspectorState extends State _inspectAt(_lastPointerLocation!); WidgetInspectorService.instance._sendInspectEvent(selection.current); } - - // Only exit select mode if there is a button to return to select mode. - if (widget.selectButtonBuilder != null) { - WidgetInspectorService.instance.isSelectMode.value = false; - } - } - - void _handleEnableSelect() { - WidgetInspectorService.instance.isSelectMode.value = true; } @override Widget build(BuildContext context) { // Be careful changing this build method. The _InspectorOverlayLayer // assumes the root RenderObject for the WidgetInspector will be - // a RenderStack with a _RenderInspectorOverlay as the last child. + // a RenderStack containing a _RenderInspectorOverlay as a child. return Stack(children: [ GestureDetector( onTap: _handleTap, @@ -2967,13 +2990,14 @@ class _WidgetInspectorState extends State child: widget.child, ), ), - if (!isSelectMode && widget.selectButtonBuilder != null) - Positioned( - left: _kInspectButtonMargin, - bottom: _kInspectButtonMargin, - child: widget.selectButtonBuilder!(context, _handleEnableSelect), - ), _InspectorOverlay(selection: selection), + if (isSelectMode && widget.exitWidgetSelectionButtonBuilder != null) + _ExitWidgetSelectionButtonGroup( + exitWidgetSelectionButtonBuilder: + widget.exitWidgetSelectionButtonBuilder!, + moveExitWidgetSelectionButtonBuilder: + widget.moveExitWidgetSelectionButtonBuilder, + ), ]); } } @@ -3429,7 +3453,10 @@ class _InspectorOverlayLayer extends Layer { while (current != null) { // We found the widget inspector render object. if (current is RenderStack - && current.lastChild is _RenderInspectorOverlay) { + && + current.getChildrenAsList().any( + (RenderBox child) => child is _RenderInspectorOverlay, + )) { return rootRenderObject == current; } current = current.parent; @@ -3440,7 +3467,6 @@ class _InspectorOverlayLayer extends Layer { const double _kScreenEdgeMargin = 10.0; const double _kTooltipPadding = 5.0; -const double _kInspectButtonMargin = 10.0; /// Interpret pointer up events within with this margin as indicating the /// pointer is moving off the device. @@ -3452,6 +3478,304 @@ const TextStyle _messageStyle = TextStyle( height: 1.2, ); +class _ExitWidgetSelectionButtonGroup extends StatefulWidget { + const _ExitWidgetSelectionButtonGroup({ + required this.exitWidgetSelectionButtonBuilder, + required this.moveExitWidgetSelectionButtonBuilder, + }); + + final ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder; + final MoveExitWidgetSelectionButtonBuilder? + moveExitWidgetSelectionButtonBuilder; + + @override + State<_ExitWidgetSelectionButtonGroup> createState() => + _ExitWidgetSelectionButtonGroupState(); +} + +class _ExitWidgetSelectionButtonGroupState + extends State<_ExitWidgetSelectionButtonGroup> { + static const double _kExitWidgetSelectionButtonPadding = 4.0; + static const double _kExitWidgetSelectionButtonMargin = 10.0; + + final GlobalKey _exitWidgetSelectionButtonKey = GlobalKey( + debugLabel: 'Exit Widget Selection button', + ); + + String? _tooltipMessage; + + bool _leftAligned = true; + + @override + Widget build(BuildContext context) { + final Widget? moveExitWidgetSelectionButton = + widget.moveExitWidgetSelectionButtonBuilder != null + ? Padding( + padding: EdgeInsets.only( + left: _leftAligned ? _kExitWidgetSelectionButtonPadding : 0.0, + right: + _leftAligned ? 0.0 : _kExitWidgetSelectionButtonPadding, + ), + child: _TooltipGestureDetector( + button: widget.moveExitWidgetSelectionButtonBuilder!( + context, + onPressed: () { + _changeButtonGroupAlignment(); + _onTooltipHidden(); + }, + isLeftAligned: _leftAligned, + ), + onTooltipVisible: () { + _changeTooltipMessage( + 'Move to the ${_leftAligned ? 'right' : 'left'}', + ); + }, + onTooltipHidden: _onTooltipHidden, + ), + ) + : null; + + final Widget buttonGroup = Stack( + alignment: AlignmentDirectional.topCenter, + children: [ + CustomPaint( + painter: _ExitWidgetSelectionTooltipPainter( + tooltipMessage: _tooltipMessage, + buttonKey: _exitWidgetSelectionButtonKey, + isLeftAligned: _leftAligned, + ), + ), + Row( + children: [ + if (!_leftAligned && moveExitWidgetSelectionButton != null) + moveExitWidgetSelectionButton, + _TooltipGestureDetector( + button: widget.exitWidgetSelectionButtonBuilder( + context, + onPressed: _exitWidgetSelectionMode, + key: _exitWidgetSelectionButtonKey, + ), + onTooltipVisible: () { + _changeTooltipMessage('Exit Select Widget mode'); + }, + onTooltipHidden: _onTooltipHidden, + ), + if (_leftAligned && moveExitWidgetSelectionButton != null) + moveExitWidgetSelectionButton, + ], + ), + ], + ); + + return Positioned( + left: _leftAligned ? _kExitWidgetSelectionButtonMargin : null, + right: _leftAligned ? null : _kExitWidgetSelectionButtonMargin, + bottom: _kExitWidgetSelectionButtonMargin, + child: buttonGroup, + ); + } + + void _exitWidgetSelectionMode() { + WidgetInspectorService.instance._changeWidgetSelectionMode(false); + } + + void _changeButtonGroupAlignment() { + if (mounted) { + setState(() { + _leftAligned = !_leftAligned; + }); + } + } + + void _onTooltipHidden() { + _changeTooltipMessage(null); + } + + void _changeTooltipMessage(String? message) { + if (mounted) { + setState(() { + _tooltipMessage = message; + }); + } + } +} + +class _TooltipGestureDetector extends StatefulWidget { + const _TooltipGestureDetector({ + required this.button, + required this.onTooltipVisible, + required this.onTooltipHidden, + }); + + final Widget button; + final void Function() onTooltipVisible; + final void Function() onTooltipHidden; + + static const Duration _tooltipShownOnLongPressDuration = Duration( + milliseconds: 1500, + ); + static const Duration _tooltipDelayDuration = Duration( + milliseconds: 100, + ); + + @override + State<_TooltipGestureDetector> createState() => + _TooltipGestureDetectorState(); +} + +class _TooltipGestureDetectorState extends State<_TooltipGestureDetector> { + Timer? _tooltipVisibleTimer; + Timer? _tooltipHiddenTimer; + + @override + void dispose() { + _tooltipVisibleTimer?.cancel(); + _tooltipVisibleTimer = null; + _tooltipHiddenTimer?.cancel(); + _tooltipHiddenTimer = null; + super.dispose(); + } + + @override + Widget build(BuildContext context) { + return Stack( + alignment: AlignmentDirectional.topCenter, + children: [ + GestureDetector( + onLongPress: () { + _tooltipVisibleAfter(_TooltipGestureDetector._tooltipDelayDuration); + _tooltipHiddenAfter( + _TooltipGestureDetector._tooltipShownOnLongPressDuration + + _TooltipGestureDetector._tooltipDelayDuration, + ); + }, + child: MouseRegion( + onEnter: (_) { + _tooltipVisibleAfter( + _TooltipGestureDetector._tooltipDelayDuration); + }, + onExit: (_) { + _tooltipHiddenAfter( + _TooltipGestureDetector._tooltipDelayDuration); + }, + child: widget.button, + ), + ), + ], + ); + } + + void _tooltipVisibleAfter(Duration duration) { + _tooltipVisibilityChangedAfter(duration, isVisible: true); + } + + void _tooltipHiddenAfter(Duration duration) { + _tooltipVisibilityChangedAfter(duration, isVisible: false); + } + + void _tooltipVisibilityChangedAfter( + Duration duration, { + required bool isVisible, + }) { + final Timer? timer = isVisible ? _tooltipVisibleTimer : _tooltipHiddenTimer; + if (timer?.isActive ?? false) { + timer!.cancel(); + } + + if (isVisible) { + _tooltipVisibleTimer = Timer(duration, () { + widget.onTooltipVisible(); + }); + } else { + _tooltipHiddenTimer = Timer(duration, () { + widget.onTooltipHidden(); + }); + } + } +} + +class _ExitWidgetSelectionTooltipPainter extends CustomPainter { + _ExitWidgetSelectionTooltipPainter({ + required this.tooltipMessage, + required this.buttonKey, + required this.isLeftAligned, + }); + + final String? tooltipMessage; + final GlobalKey buttonKey; + final bool isLeftAligned; + + @override + void paint(Canvas canvas, Size size) { + // Do not paint the tooltip if it is currently hidden. + final bool isVisible = tooltipMessage != null; + if (!isVisible) { + return; + } + + // Do not paint the tooltip if the exit select mode button is not rendered. + final RenderObject? buttonRenderObject = + buttonKey.currentContext?.findRenderObject(); + if (buttonRenderObject == null) { + return; + } + + // Define tooltip appearance. + const double tooltipPadding = 4.0; + const double tooltipSpacing = 6.0; + + final TextPainter tooltipTextPainter = TextPainter() + ..maxLines = 1 + ..ellipsis = '...' + ..text = TextSpan( + text: tooltipMessage, + style: _messageStyle, + ) + ..textDirection = TextDirection.ltr + ..layout(); + + final Paint tooltipPaint = Paint() + ..style = PaintingStyle.fill + ..color = _kTooltipBackgroundColor; + + // Determine tooltip position. + final double buttonWidth = buttonRenderObject.paintBounds.width; + final Size textSize = tooltipTextPainter.size; + final double textWidth = textSize.width; + final double textHeight = textSize.height; + final double tooltipWidth = textWidth + (tooltipPadding * 2); + final double tooltipHeight = textHeight + (tooltipPadding * 2); + + final double tooltipXOffset = + isLeftAligned ? 0 - buttonWidth : 0 - (tooltipWidth - buttonWidth); + final double tooltipYOffset = 0 - tooltipHeight - tooltipSpacing; + + // Draw tooltip background. + canvas.drawRect( + Rect.fromLTWH( + tooltipXOffset, + tooltipYOffset, + tooltipWidth, + tooltipHeight, + ), + tooltipPaint, + ); + + // Draw tooltip text. + tooltipTextPainter.paint( + canvas, + Offset( + tooltipXOffset + tooltipPadding, + tooltipYOffset + tooltipPadding, + )); + } + + @override + bool shouldRepaint(covariant _ExitWidgetSelectionTooltipPainter oldDelegate) { + return tooltipMessage != oldDelegate.tooltipMessage; + } +} + /// Interface for classes that track the source code location the their /// constructor was called from. /// diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index e4e9c2fb5c..71a15cbdb7 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -285,9 +285,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { static void runTests() { final TestWidgetInspectorService service = TestWidgetInspectorService(); WidgetInspectorService.instance = service; - setUp(() { - WidgetInspectorService.instance.isSelectMode.value = true; - }); tearDown(() async { service.resetAllState(); @@ -357,7 +354,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { const Directionality( textDirection: TextDirection.ltr, child: WidgetInspector( - selectButtonBuilder: null, + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, child: Stack( children: [ Text('a', textDirection: TextDirection.ltr), @@ -373,13 +371,23 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets('WidgetInspector interaction test', (WidgetTester tester) async { + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + final List log = []; - final GlobalKey selectButtonKey = GlobalKey(); + late GlobalKey exitWidgetSelectionButtonKey; final GlobalKey inspectorKey = GlobalKey(); final GlobalKey topButtonKey = GlobalKey(); + final GlobalKey bottomButtonKey = GlobalKey(); - Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) { - return Material(child: ElevatedButton(onPressed: onPressed, key: selectButtonKey, child: null)); + Widget exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { + exitWidgetSelectionButtonKey = key; + return Material( + child: ElevatedButton(onPressed: onPressed, key: key, child: null)); } String paragraphText(RenderParagraph paragraph) { @@ -387,12 +395,32 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { return textSpan.text!; } + + Future tapAndVerifyWidgetSelection( + Finder widgetFinder, { + required bool isSelected, + required GlobalKey widgetKey, + }) async { + // Tap on the widget. + await tester.tap(widgetFinder, warnIfMissed: false); + await tester.pump(); + + // Verify the tap was intercepted by the Widget Inspector. + final RenderObject renderObject = + find.byKey(widgetKey).evaluate().first.renderObject!; + expect( + WidgetInspectorService.instance.selection.candidates, + contains(renderObject), + ); + } + await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: WidgetInspector( key: inspectorKey, - selectButtonBuilder: selectButtonBuilder, + exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: null, child: Material( child: ListView( children: [ @@ -404,6 +432,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: const Text('TOP'), ), ElevatedButton( + key: bottomButtonKey, onPressed: () { log.add('bottom'); }, @@ -417,9 +446,13 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { ); expect(WidgetInspectorService.instance.selection.current, isNull); - await tester.tap(find.text('TOP'), warnIfMissed: false); - await tester.pump(); - // Tap intercepted by the inspector + + // Tap on the top button and verify it's selected in the Inspector. + await tapAndVerifyWidgetSelection( + find.text('TOP'), + isSelected: true, + widgetKey: topButtonKey, + ); expect(log, equals([])); expect( paragraphText( @@ -427,35 +460,30 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { ), equals('TOP'), ); - final RenderObject topButton = find.byKey(topButtonKey).evaluate().first.renderObject!; - expect( - WidgetInspectorService.instance.selection.candidates, - contains(topButton), - ); + // Tap on the bottom button and verify it's selected in the Inspector. + await tapAndVerifyWidgetSelection( + find.text('BOTTOM'), + isSelected: true, + widgetKey: bottomButtonKey, + ); + expect( + paragraphText( + WidgetInspectorService.instance.selection.current! as RenderParagraph, + ), + equals('BOTTOM'), + ); + expect(log, equals([])); + + // Now exit selection mode by tapping the Exit Selection Mode button. + await tester.tap(find.byKey(exitWidgetSelectionButtonKey)); + await tester.pump(); + + // Tap on the top button and verify it is not selected in the Inspector. await tester.tap(find.text('TOP')); expect(log, equals(['top'])); - log.clear(); - await tester.tap(find.text('BOTTOM')); - expect(log, equals(['bottom'])); - log.clear(); - // Ensure the inspector selection has not changed to bottom. - expect( - paragraphText( - WidgetInspectorService.instance.selection.current! as RenderParagraph, - ), - 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'), warnIfMissed: false); - expect(log, equals([])); - log.clear(); + // Ensure the inspector selection is still BOTTOM (not TOP). expect( paragraphText( WidgetInspectorService.instance.selection.current! as RenderParagraph, @@ -469,7 +497,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Directionality( textDirection: TextDirection.ltr, child: WidgetInspector( - selectButtonBuilder: null, + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, child: Transform( transform: Matrix4.identity()..scale(0.0), child: const Stack( @@ -490,12 +519,21 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets('WidgetInspector scroll test', (WidgetTester tester) async { - final Key childKey = UniqueKey(); - final GlobalKey selectButtonKey = GlobalKey(); - final GlobalKey inspectorKey = GlobalKey(); + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; - Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) { - return Material(child: ElevatedButton(onPressed: onPressed, key: selectButtonKey, child: null)); + final Key childKey = UniqueKey(); + final GlobalKey inspectorKey = GlobalKey(); + late GlobalKey exitWidgetSelectionButtonKey; + + Widget exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { + exitWidgetSelectionButtonKey = key; + return Material( + child: ElevatedButton(onPressed: onPressed, key: key, child: null)); } await tester.pumpWidget( @@ -503,7 +541,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { textDirection: TextDirection.ltr, child: WidgetInspector( key: inspectorKey, - selectButtonBuilder: selectButtonBuilder, + exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: null, child: ListView( dragStartBehavior: DragStartBehavior.down, children: [ @@ -534,7 +573,11 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { await tester.pump(); expect(WidgetInspectorService.instance.selection.current, isNotNull); - // Now out of inspect mode due to the click. + // Now exit selection mode by tapping the Exit Selection Mode button. + await tester.tap(find.byKey(exitWidgetSelectionButtonKey)); + await tester.pump(); + + // Now out of inspect mode due to clicking the Exit Selection Mode button. await tester.fling(find.byType(ListView), const Offset(0.0, -200.0), 200.0); await tester.pump(); @@ -547,13 +590,17 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets('WidgetInspector long press', (WidgetTester tester) async { + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + bool didLongPress = false; await tester.pumpWidget( Directionality( textDirection: TextDirection.ltr, child: WidgetInspector( - selectButtonBuilder: null, + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, child: GestureDetector( onLongPress: () { expect(didLongPress, isFalse); @@ -571,6 +618,9 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets('WidgetInspector offstage', (WidgetTester tester) async { + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + final GlobalKey inspectorKey = GlobalKey(); final GlobalKey clickTarget = GlobalKey(); @@ -601,7 +651,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { textDirection: TextDirection.ltr, child: WidgetInspector( key: inspectorKey, - selectButtonBuilder: null, + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, child: Overlay( initialEntries: [ entry1 = OverlayEntry( @@ -642,6 +693,9 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets('WidgetInspector with Transform above', (WidgetTester tester) async { + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + final GlobalKey childKey = GlobalKey(); final GlobalKey repaintBoundaryKey = GlobalKey(); @@ -660,7 +714,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: Directionality( textDirection: TextDirection.ltr, child: WidgetInspector( - selectButtonBuilder: null, + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, child: ColoredBox( color: Colors.white, child: Center( @@ -689,6 +744,9 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets('Multiple widget inspectors', (WidgetTester tester) async { + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + // This test verifies that interacting with different inspectors // works correctly. This use case may be an app that displays multiple // apps inside (i.e. a storyboard). @@ -701,8 +759,13 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { final GlobalKey child1Key = GlobalKey(); final GlobalKey child2Key = GlobalKey(); - InspectorSelectButtonBuilder selectButtonBuilder(Key key) { - return (BuildContext context, VoidCallback onPressed) { + ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder( + Key key) { + return ( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null)); }; } @@ -720,7 +783,9 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Flexible( child: WidgetInspector( key: inspector1Key, - selectButtonBuilder: selectButtonBuilder(selectButton1Key), + exitWidgetSelectionButtonBuilder: + exitWidgetSelectionButtonBuilder(selectButton1Key), + moveExitWidgetSelectionButtonBuilder: null, child: Container( key: child1Key, child: const Text('Child 1'), @@ -730,7 +795,9 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Flexible( child: WidgetInspector( key: inspector2Key, - selectButtonBuilder: selectButtonBuilder(selectButton2Key), + exitWidgetSelectionButtonBuilder: + exitWidgetSelectionButtonBuilder(selectButton2Key), + moveExitWidgetSelectionButtonBuilder: null, child: Container( key: child2Key, child: const Text('Child 2'), @@ -751,10 +818,6 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { 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( @@ -766,14 +829,20 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { }); testWidgets( - 'WidgetInspector selectButton inspection for tap', + 'WidgetInspector Exit Selection Mode button', (WidgetTester tester) async { - final GlobalKey selectButtonKey = GlobalKey(); + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + final GlobalKey inspectorKey = GlobalKey(); setupDefaultPubRootDirectory(service); - Widget selectButtonBuilder(BuildContext context, VoidCallback onPressed) { - return Material(child: ElevatedButton(onPressed: onPressed, key: selectButtonKey, child: null)); + Widget exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { + return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null)); } await tester.pumpWidget( @@ -781,7 +850,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { textDirection: TextDirection.ltr, child: WidgetInspector( key: inspectorKey, - selectButtonBuilder: selectButtonBuilder, + exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: null, child: const Text('Child 1'), ), ), @@ -815,6 +885,94 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { skip: !WidgetInspectorService.instance.isWidgetCreationTracked() // [intended] Test requires --track-widget-creation flag. ); + testWidgets( + 'WidgetInspector Move Exit Selection Mode button to the right / left', + (WidgetTester tester) async { + // Enable widget selection mode. + WidgetInspectorService.instance.isSelectMode = true; + + final GlobalKey inspectorKey = GlobalKey(); + setupDefaultPubRootDirectory(service); + + Widget exitWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required GlobalKey key, + }) { + return Material( + child: ElevatedButton( + onPressed: onPressed, + key: key, + child: const Text('EXIT SELECT MODE'), + ), + ); + } + + Widget moveWidgetSelectionButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + bool isLeftAligned = true, + }) { + return Material( + child: ElevatedButton( + onPressed: onPressed, + child: Text(isLeftAligned ? 'MOVE RIGHT' : 'MOVE LEFT'), + ), + ); + } + + Finder buttonFinder(String buttonText) { + return find.ancestor( + of: find.text(buttonText), + matching: find.byType(ElevatedButton), + ); + } + + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: WidgetInspector( + key: inspectorKey, + exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, + moveExitWidgetSelectionButtonBuilder: + moveWidgetSelectionButtonBuilder, + child: const Text('APP'), + ), + ), + ); + + // Intitially the exit select button is on the left. + final Finder exitButton = buttonFinder('EXIT SELECT MODE'); + expect(exitButton, findsOneWidget); + final Finder moveRightButton = buttonFinder('MOVE RIGHT'); + expect(moveRightButton, findsOneWidget); + final double initialExitButtonX = tester.getCenter(exitButton).dx; + + // Move the button to the right. + await tester.tap(moveRightButton); + await tester.pump(); + + // Verify the button is now on the right. + expect(moveRightButton, findsNothing); + final Finder moveLeftButton = buttonFinder('MOVE LEFT'); + expect(moveLeftButton, findsOneWidget); + final double exitButtonXAfterMovingRight = + tester.getCenter(exitButton).dx; + expect(initialExitButtonX, lessThan(exitButtonXAfterMovingRight)); + + // Move the button to the left again. + await tester.tap(moveLeftButton); + await tester.pump(); + + // Verify the button is in its original position. + expect(moveLeftButton, findsNothing); + expect(moveRightButton, findsOneWidget); + final double exitButtonXAfterMovingLeft = tester.getCenter(exitButton).dx; + expect(exitButtonXAfterMovingLeft, equals(initialExitButtonX)); + }, + skip: !WidgetInspectorService.instance.isWidgetCreationTracked() // [intended] Test requires --track-widget-creation flag. + ); + testWidgets('test transformDebugCreator will re-order if after stack trace', (WidgetTester tester) async { final bool widgetTracked = WidgetInspectorService.instance.isWidgetCreationTracked(); await tester.pumpWidget( @@ -3727,7 +3885,8 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { final Widget widget = Directionality( textDirection: TextDirection.ltr, child: WidgetInspector( - selectButtonBuilder: null, + exitWidgetSelectionButtonBuilder: null, + moveExitWidgetSelectionButtonBuilder: null, child: _applyConstructor(_TrivialWidget.new), ), );