### CP request for https://github.com/flutter/flutter/pull/167677 into flutter-3.32-candidate.0

**Impacted Users:** Some subset of widget inspector users with a specific configuration of `package:go_router`, see upvotes and comments on https://github.com/flutter/flutter/issues/166118.

**Impact Description:** Depending on how users have configured their routes using `package:go_router`, enabling / disabling the widget inspector can be destructive to their app's routing state, preventing them from inspecting widgets on secondary screens of their app.

**Workaround:** No workaround available. 

**Risk:** Low

**Test Coverage:** Yes, tests were added and this has been manually tested as well

**Validation Steps:**
- Run a Flutter app
- Open the DevTools Inspector for the running app
- Toggle "Select widget mode"
- An additional button has been added to the on-device inspector that allows developers to both interact with their app (e.g. navigate to a new page) and select widgets while in Widget Selection mode. See gif below.

![new_on_device_inspector](https://github.com/user-attachments/assets/7202ccb3-05cd-4262-be70-9cd08513933a)
This commit is contained in:
Elliott Brooks 2025-05-06 12:29:22 -07:00 committed by GitHub
parent 87cd28951e
commit 8545480266
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 668 additions and 124 deletions

View File

@ -15,6 +15,7 @@ import 'package:flutter/widgets.dart';
import 'button.dart'; import 'button.dart';
import 'colors.dart'; import 'colors.dart';
import 'constants.dart';
import 'icons.dart'; import 'icons.dart';
import 'interface_level.dart'; import 'interface_level.dart';
import 'localizations.dart'; import 'localizations.dart';
@ -534,46 +535,44 @@ class _CupertinoAppState extends State<CupertinoApp> {
Widget _exitWidgetSelectionButtonBuilder( Widget _exitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}) { }) {
return CupertinoButton( return _CupertinoInspectorButton.filled(
key: key,
color: _widgetSelectionButtonsBackgroundColor(context),
padding: EdgeInsets.zero,
onPressed: onPressed, onPressed: onPressed,
child: Icon( semanticLabel: semanticLabel,
CupertinoIcons.xmark, icon: CupertinoIcons.xmark,
size: 28.0, buttonKey: key,
color: _widgetSelectionButtonsForegroundColor(context),
semanticLabel: 'Exit Select Widget mode.',
),
); );
} }
Widget _moveExitWidgetSelectionButtonBuilder( Widget _moveExitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
bool isLeftAligned = true, bool isLeftAligned = true,
}) { }) {
return CupertinoButton( return _CupertinoInspectorButton.iconOnly(
onPressed: onPressed, onPressed: onPressed,
padding: EdgeInsets.zero, semanticLabel: semanticLabel,
child: Icon( icon: isLeftAligned ? CupertinoIcons.arrow_right : CupertinoIcons.arrow_left,
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) { Widget _tapBehaviorButtonBuilder(
return CupertinoTheme.of(context).primaryContrastingColor; BuildContext context, {
} required VoidCallback onPressed,
required String semanticLabel,
Color _widgetSelectionButtonsBackgroundColor(BuildContext context) { required bool selectionOnTapEnabled,
return CupertinoTheme.of(context).primaryColor; }) {
return _CupertinoInspectorButton.toggle(
onPressed: onPressed,
semanticLabel: semanticLabel,
// This icon is also used for the Material-styled button and for DevTools.
// It should be updated in all 3 places if changed.
icon: CupertinoIcons.cursor_rays,
toggledOn: selectionOnTapEnabled,
);
} }
WidgetsApp _buildWidgetApp(BuildContext context) { WidgetsApp _buildWidgetApp(BuildContext context) {
@ -607,6 +606,7 @@ class _CupertinoAppState extends State<CupertinoApp> {
debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder,
shortcuts: widget.shortcuts, shortcuts: widget.shortcuts,
actions: widget.actions, actions: widget.actions,
restorationScopeId: widget.restorationScopeId, restorationScopeId: widget.restorationScopeId,
@ -642,6 +642,7 @@ class _CupertinoAppState extends State<CupertinoApp> {
debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder,
shortcuts: widget.shortcuts, shortcuts: widget.shortcuts,
actions: widget.actions, actions: widget.actions,
restorationScopeId: widget.restorationScopeId, restorationScopeId: widget.restorationScopeId,
@ -680,3 +681,83 @@ class _CupertinoAppState extends State<CupertinoApp> {
); );
} }
} }
class _CupertinoInspectorButton extends InspectorButton {
const _CupertinoInspectorButton.filled({
required super.onPressed,
required super.semanticLabel,
required super.icon,
super.buttonKey,
}) : super.filled();
const _CupertinoInspectorButton.toggle({
required super.onPressed,
required super.semanticLabel,
required super.icon,
super.toggledOn,
}) : super.toggle();
const _CupertinoInspectorButton.iconOnly({
required super.onPressed,
required super.semanticLabel,
required super.icon,
}) : super.iconOnly();
@override
Widget build(BuildContext context) {
final Icon buttonIcon = Icon(
icon,
semanticLabel: semanticLabel,
size: iconSizeForVariant,
color: foregroundColor(context),
);
return Padding(
key: buttonKey,
padding: const EdgeInsets.all(
(kMinInteractiveDimensionCupertino - InspectorButton.buttonSize) / 2,
),
child:
variant == InspectorButtonVariant.toggle && !toggledOn!
? CupertinoButton.tinted(
minSize: InspectorButton.buttonSize,
onPressed: onPressed,
padding: EdgeInsets.zero,
child: buttonIcon,
)
: CupertinoButton(
minSize: InspectorButton.buttonSize,
onPressed: onPressed,
padding: EdgeInsets.zero,
color: backgroundColor(context),
child: buttonIcon,
),
);
}
@override
Color foregroundColor(BuildContext context) {
final Color primaryColor = CupertinoTheme.of(context).primaryColor;
final Color secondaryColor = CupertinoTheme.of(context).primaryContrastingColor;
switch (variant) {
case InspectorButtonVariant.filled:
return secondaryColor;
case InspectorButtonVariant.iconOnly:
return primaryColor;
case InspectorButtonVariant.toggle:
return !toggledOn! ? primaryColor : secondaryColor;
}
}
@override
Color backgroundColor(BuildContext context) {
final Color primaryColor = CupertinoTheme.of(context).primaryColor;
switch (variant) {
case InspectorButtonVariant.filled:
case InspectorButtonVariant.toggle:
return primaryColor;
case InspectorButtonVariant.iconOnly:
return const Color(0x00000000);
}
}
}

View File

@ -20,8 +20,8 @@ import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'arc.dart'; import 'arc.dart';
import 'button_style.dart';
import 'colors.dart'; import 'colors.dart';
import 'floating_action_button.dart';
import 'icon_button.dart'; import 'icon_button.dart';
import 'icons.dart'; import 'icons.dart';
import 'material_localizations.dart'; import 'material_localizations.dart';
@ -29,6 +29,7 @@ import 'page.dart';
import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState; import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState;
import 'scrollbar.dart'; import 'scrollbar.dart';
import 'theme.dart'; import 'theme.dart';
import 'theme_data.dart';
import 'tooltip.dart'; import 'tooltip.dart';
// Examples can assume: // Examples can assume:
@ -903,9 +904,6 @@ class MaterialScrollBehavior extends ScrollBehavior {
} }
class _MaterialAppState extends State<MaterialApp> { class _MaterialAppState extends State<MaterialApp> {
static const double _moveExitWidgetSelectionIconSize = 32;
static const double _moveExitWidgetSelectionTargetSize = 40;
late HeroController _heroController; late HeroController _heroController;
bool get _usesRouter => widget.routerDelegate != null || widget.routerConfig != null; bool get _usesRouter => widget.routerDelegate != null || widget.routerConfig != null;
@ -938,52 +936,47 @@ class _MaterialAppState extends State<MaterialApp> {
Widget _exitWidgetSelectionButtonBuilder( Widget _exitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}) { }) {
return FloatingActionButton( return _MaterialInspectorButton.filled(
key: key,
onPressed: onPressed, onPressed: onPressed,
mini: true, semanticLabel: semanticLabel,
backgroundColor: _widgetSelectionButtonsBackgroundColor(context), icon: Icons.close,
foregroundColor: _widgetSelectionButtonsForegroundColor(context), isDarkTheme: _isDarkTheme(context),
child: const Icon(Icons.close, semanticLabel: 'Exit Select Widget mode.'), buttonKey: key,
); );
} }
Widget _moveExitWidgetSelectionButtonBuilder( Widget _moveExitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
bool isLeftAligned = true, bool isLeftAligned = true,
}) { }) {
return IconButton( return _MaterialInspectorButton.iconOnly(
color: _widgetSelectionButtonsBackgroundColor(context),
padding: EdgeInsets.zero,
iconSize: _moveExitWidgetSelectionIconSize,
onPressed: onPressed, onPressed: onPressed,
constraints: const BoxConstraints( semanticLabel: semanticLabel,
minWidth: _moveExitWidgetSelectionTargetSize, icon: isLeftAligned ? Icons.arrow_right : Icons.arrow_left,
minHeight: _moveExitWidgetSelectionTargetSize, isDarkTheme: _isDarkTheme(context),
),
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) { Widget _tapBehaviorButtonBuilder(
final ThemeData theme = Theme.of(context); BuildContext context, {
return _isDarkTheme(context) required VoidCallback onPressed,
? theme.colorScheme.onPrimaryContainer required String semanticLabel,
: theme.colorScheme.primaryContainer; required bool selectionOnTapEnabled,
} }) {
return _MaterialInspectorButton.toggle(
Color _widgetSelectionButtonsBackgroundColor(BuildContext context) { onPressed: onPressed,
final ThemeData theme = Theme.of(context); semanticLabel: semanticLabel,
return _isDarkTheme(context) // This icon is also used for the Cupertino-styled button and for DevTools.
? theme.colorScheme.primaryContainer // It should be updated in all 3 places if changed.
: theme.colorScheme.onPrimaryContainer; icon: CupertinoIcons.cursor_rays,
isDarkTheme: _isDarkTheme(context),
toggledOn: selectionOnTapEnabled,
);
} }
bool _isDarkTheme(BuildContext context) { bool _isDarkTheme(BuildContext context) {
@ -1100,6 +1093,7 @@ class _MaterialAppState extends State<MaterialApp> {
debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder,
shortcuts: widget.shortcuts, shortcuts: widget.shortcuts,
actions: widget.actions, actions: widget.actions,
restorationScopeId: widget.restorationScopeId, restorationScopeId: widget.restorationScopeId,
@ -1135,6 +1129,7 @@ class _MaterialAppState extends State<MaterialApp> {
debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner,
exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder,
shortcuts: widget.shortcuts, shortcuts: widget.shortcuts,
actions: widget.actions, actions: widget.actions,
restorationScopeId: widget.restorationScopeId, restorationScopeId: widget.restorationScopeId,
@ -1173,3 +1168,101 @@ class _MaterialAppState extends State<MaterialApp> {
); );
} }
} }
class _MaterialInspectorButton extends InspectorButton {
const _MaterialInspectorButton.filled({
required super.onPressed,
required super.semanticLabel,
required super.icon,
required this.isDarkTheme,
super.buttonKey,
}) : super.filled();
const _MaterialInspectorButton.toggle({
required super.onPressed,
required super.semanticLabel,
required super.icon,
required this.isDarkTheme,
super.toggledOn,
}) : super.toggle();
const _MaterialInspectorButton.iconOnly({
required super.onPressed,
required super.semanticLabel,
required super.icon,
required this.isDarkTheme,
}) : super.iconOnly();
final bool isDarkTheme;
static const EdgeInsets _buttonPadding = EdgeInsets.zero;
static const BoxConstraints _buttonConstraints = BoxConstraints.tightFor(
width: InspectorButton.buttonSize,
height: InspectorButton.buttonSize,
);
@override
Widget build(BuildContext context) {
return IconButton(
key: buttonKey,
onPressed: onPressed,
iconSize: iconSizeForVariant,
padding: _buttonPadding,
constraints: _buttonConstraints,
style: _selectionButtonsIconStyle(context),
icon: Icon(icon, semanticLabel: semanticLabel),
);
}
ButtonStyle _selectionButtonsIconStyle(BuildContext context) {
final Color foreground = foregroundColor(context);
final Color background = backgroundColor(context);
return IconButton.styleFrom(
foregroundColor: foreground,
backgroundColor: background,
side:
variant == InspectorButtonVariant.toggle && !toggledOn!
? BorderSide(color: foreground)
: null,
tapTargetSize: MaterialTapTargetSize.padded,
);
}
@override
Color foregroundColor(BuildContext context) {
final Color primaryColor = _primaryColor(context);
final Color secondaryColor = _secondaryColor(context);
switch (variant) {
case InspectorButtonVariant.filled:
return primaryColor;
case InspectorButtonVariant.iconOnly:
return secondaryColor;
case InspectorButtonVariant.toggle:
return !toggledOn! ? secondaryColor : primaryColor;
}
}
@override
Color backgroundColor(BuildContext context) {
final Color secondaryColor = _secondaryColor(context);
switch (variant) {
case InspectorButtonVariant.filled:
return secondaryColor;
case InspectorButtonVariant.iconOnly:
return Colors.transparent;
case InspectorButtonVariant.toggle:
return !toggledOn! ? Colors.transparent : secondaryColor;
}
}
Color _primaryColor(BuildContext context) {
final ThemeData theme = Theme.of(context);
return isDarkTheme ? theme.colorScheme.onPrimaryContainer : theme.colorScheme.primaryContainer;
}
Color _secondaryColor(BuildContext context) {
final ThemeData theme = Theme.of(context);
return isDarkTheme ? theme.colorScheme.primaryContainer : theme.colorScheme.onPrimaryContainer;
}
}

View File

@ -355,6 +355,7 @@ class WidgetsApp extends StatefulWidget {
this.debugShowCheckedModeBanner = true, this.debugShowCheckedModeBanner = true,
this.exitWidgetSelectionButtonBuilder, this.exitWidgetSelectionButtonBuilder,
this.moveExitWidgetSelectionButtonBuilder, this.moveExitWidgetSelectionButtonBuilder,
this.tapBehaviorButtonBuilder,
this.shortcuts, this.shortcuts,
this.actions, this.actions,
this.restorationScopeId, this.restorationScopeId,
@ -446,6 +447,7 @@ class WidgetsApp extends StatefulWidget {
this.debugShowCheckedModeBanner = true, this.debugShowCheckedModeBanner = true,
this.exitWidgetSelectionButtonBuilder, this.exitWidgetSelectionButtonBuilder,
this.moveExitWidgetSelectionButtonBuilder, this.moveExitWidgetSelectionButtonBuilder,
this.tapBehaviorButtonBuilder,
this.shortcuts, this.shortcuts,
this.actions, this.actions,
this.restorationScopeId, this.restorationScopeId,
@ -1041,19 +1043,27 @@ class WidgetsApp extends StatefulWidget {
/// Builds the widget the [WidgetInspector] uses to exit selection mode. /// Builds the widget the [WidgetInspector] uses to exit selection mode.
/// ///
/// This lets [MaterialApp] to use a Material Design button to exit the /// This lets [MaterialApp] and [CupertinoApp] use an appropriately styled
/// inspector select mode without requiring [WidgetInspector] to depend on the /// button for their design systems without requiring [WidgetInspector] to
/// Material package. /// depend on the Material or Cupertino packages.
final ExitWidgetSelectionButtonBuilder? exitWidgetSelectionButtonBuilder; final ExitWidgetSelectionButtonBuilder? exitWidgetSelectionButtonBuilder;
/// Builds the widget the [WidgetInspector] uses to move the exit selection /// Builds the widget the [WidgetInspector] uses to move the exit selection
/// mode button. /// mode button.
/// ///
/// This lets [MaterialApp] to use a Material Design button to change the /// This lets [MaterialApp] and [CupertinoApp] use an appropriately styled
/// alignment without requiring [WidgetInspector] to depend on the Material /// button for their design systems without requiring [WidgetInspector] to
/// package. /// depend on the Material or Cupertino packages.
final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder; final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder;
/// Builds the widget the [WidgetInspector] uses to change the default
/// behavior when tapping on widgets in the app.
///
/// This lets [MaterialApp] and [CupertinoApp] use an appropriately styled
/// button for their design systems without requiring [WidgetInspector] to
/// depend on the Material or Cupertino packages.
final TapBehaviorButtonBuilder? tapBehaviorButtonBuilder;
/// {@template flutter.widgets.widgetsApp.debugShowCheckedModeBanner} /// {@template flutter.widgets.widgetsApp.debugShowCheckedModeBanner}
/// Turns on a little "DEBUG" banner in debug mode to indicate /// Turns on a little "DEBUG" banner in debug mode to indicate
/// that the app is in debug mode. This is on by default (in /// that the app is in debug mode. This is on by default (in
@ -1824,6 +1834,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
return WidgetInspector( return WidgetInspector(
exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: widget.tapBehaviorButtonBuilder,
child: child!, child: child!,
); );
} }

View File

@ -492,6 +492,15 @@ mixin WidgetsBinding
_debugShowWidgetInspectorOverrideNotifierObject ??= ValueNotifier<bool>(false); _debugShowWidgetInspectorOverrideNotifierObject ??= ValueNotifier<bool>(false);
ValueNotifier<bool>? _debugShowWidgetInspectorOverrideNotifierObject; ValueNotifier<bool>? _debugShowWidgetInspectorOverrideNotifierObject;
/// The notifier for whether or not taps on the device are treated as widget
/// selections when the widget inspector is enabled.
///
/// - If true, taps in the app are intercepted by the widget inspector.
/// - If false, taps in the app are not intercepted by the widget inspector.
ValueNotifier<bool> get debugWidgetInspectorSelectionOnTapEnabled =>
_debugWidgetInspectorSelectionOnTapEnabledNotifierObject ??= ValueNotifier<bool>(true);
ValueNotifier<bool>? _debugWidgetInspectorSelectionOnTapEnabledNotifierObject;
@visibleForTesting @visibleForTesting
@override @override
void resetInternalState() { void resetInternalState() {
@ -499,6 +508,8 @@ mixin WidgetsBinding
super.resetInternalState(); super.resetInternalState();
_debugShowWidgetInspectorOverrideNotifierObject?.dispose(); _debugShowWidgetInspectorOverrideNotifierObject?.dispose();
_debugShowWidgetInspectorOverrideNotifierObject = null; _debugShowWidgetInspectorOverrideNotifierObject = null;
_debugWidgetInspectorSelectionOnTapEnabledNotifierObject?.dispose();
_debugWidgetInspectorSelectionOnTapEnabledNotifierObject = null;
} }
void _debugAddStackFilters() { void _debugAddStackFilters() {

View File

@ -34,6 +34,7 @@ import 'binding.dart';
import 'debug.dart'; import 'debug.dart';
import 'framework.dart'; import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'icon_data.dart';
import 'service_extensions.dart'; import 'service_extensions.dart';
import 'view.dart'; import 'view.dart';
@ -43,13 +44,29 @@ typedef ExitWidgetSelectionButtonBuilder =
Widget Function( Widget Function(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}); });
/// Signature for the builder callback used by /// Signature for the builder callback used by
/// [WidgetInspector.moveExitWidgetSelectionButtonBuilder]. /// [WidgetInspector.moveExitWidgetSelectionButtonBuilder].
typedef MoveExitWidgetSelectionButtonBuilder = typedef MoveExitWidgetSelectionButtonBuilder =
Widget Function(BuildContext context, {required VoidCallback onPressed, bool isLeftAligned}); Widget Function(
BuildContext context, {
required VoidCallback onPressed,
required String semanticLabel,
bool isLeftAligned,
});
/// Signature for the builder callback used by
/// [WidgetInspector.tapBehaviorButtonBuilder].
typedef TapBehaviorButtonBuilder =
Widget Function(
BuildContext context, {
required VoidCallback onPressed,
required String semanticLabel,
required bool selectionOnTapEnabled,
});
/// Signature for a method that registers the service extension `callback` with /// Signature for a method that registers the service extension `callback` with
/// the given `name`. /// the given `name`.
@ -2768,6 +2785,7 @@ class WidgetInspector extends StatefulWidget {
const WidgetInspector({ const WidgetInspector({
super.key, super.key,
required this.child, required this.child,
required this.tapBehaviorButtonBuilder,
required this.exitWidgetSelectionButtonBuilder, required this.exitWidgetSelectionButtonBuilder,
required this.moveExitWidgetSelectionButtonBuilder, required this.moveExitWidgetSelectionButtonBuilder,
}); });
@ -2790,6 +2808,15 @@ class WidgetInspector extends StatefulWidget {
/// The button UI should respond to the `leftAligned` argument. /// The button UI should respond to the `leftAligned` argument.
final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder; final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder;
/// A builder that is called to create the button that changes the default tap
/// behavior when Select Widget mode is enabled.
///
/// The `onPressed` callback passed as an argument to the builder should be
/// hooked up to the returned widget.
///
/// The button UI should respond to the `selectionOnTapEnabled` argument.
final TapBehaviorButtonBuilder? tapBehaviorButtonBuilder;
@override @override
State<WidgetInspector> createState() => _WidgetInspectorState(); State<WidgetInspector> createState() => _WidgetInspectorState();
} }
@ -2809,6 +2836,11 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
/// as selecting the edge of the bounding box. /// as selecting the edge of the bounding box.
static const double _edgeHitMargin = 2.0; static const double _edgeHitMargin = 2.0;
ValueNotifier<bool> get _selectionOnTapEnabled =>
WidgetsBinding.instance.debugWidgetInspectorSelectionOnTapEnabled;
bool get _isSelectModeWithSelectionOnTapEnabled => isSelectMode && _selectionOnTapEnabled.value;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -2817,6 +2849,7 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier.addListener( WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier.addListener(
_selectionInformationChanged, _selectionInformationChanged,
); );
_selectionOnTapEnabled.addListener(_selectionInformationChanged);
selection = WidgetInspectorService.instance.selection; selection = WidgetInspectorService.instance.selection;
isSelectMode = WidgetsBinding.instance.debugShowWidgetInspectorOverride; isSelectMode = WidgetsBinding.instance.debugShowWidgetInspectorOverride;
} }
@ -2827,6 +2860,7 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier.removeListener( WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier.removeListener(
_selectionInformationChanged, _selectionInformationChanged,
); );
_selectionOnTapEnabled.removeListener(_selectionInformationChanged);
super.dispose(); super.dispose();
} }
@ -2911,7 +2945,7 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
} }
void _inspectAt(Offset position) { void _inspectAt(Offset position) {
if (!isSelectMode) { if (!_isSelectModeWithSelectionOnTapEnabled) {
return; return;
} }
@ -2952,7 +2986,7 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
} }
void _handleTap() { void _handleTap() {
if (!isSelectMode) { if (!_isSelectModeWithSelectionOnTapEnabled) {
return; return;
} }
if (_lastPointerLocation != null) { if (_lastPointerLocation != null) {
@ -2975,11 +3009,16 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
onPanUpdate: _handlePanUpdate, onPanUpdate: _handlePanUpdate,
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
excludeFromSemantics: true, excludeFromSemantics: true,
child: IgnorePointer(ignoring: isSelectMode, key: _ignorePointerKey, child: widget.child), child: IgnorePointer(
ignoring: _isSelectModeWithSelectionOnTapEnabled,
key: _ignorePointerKey,
child: widget.child,
),
), ),
_InspectorOverlay(selection: selection), _InspectorOverlay(selection: selection),
if (isSelectMode && widget.exitWidgetSelectionButtonBuilder != null) if (isSelectMode && widget.exitWidgetSelectionButtonBuilder != null)
_ExitWidgetSelectionButtonGroup( _WidgetInspectorButtonGroup(
tapBehaviorButtonBuilder: widget.tapBehaviorButtonBuilder,
exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder!, exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder!,
moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder,
), ),
@ -2988,6 +3027,123 @@ class _WidgetInspectorState extends State<WidgetInspector> with WidgetsBindingOb
} }
} }
/// Defines the visual and behavioral variants for an [InspectorButton].
enum InspectorButtonVariant {
/// A standard button with a filled background and foreground icon.
filled,
/// A button that can be toggled on or off, visually representing its state.
///
/// The [InspectorButton.toggledOn] property determines its current state.
toggle,
/// A button that displays only an icon, typically with a transparent background.
iconOnly,
}
/// An abstract base class for creating Material or Cupertino-styled inspector
/// buttons.
///
/// Subclasses are responsible for implementing the design-specific rendering
/// logic in the [build] method and providing design-specific colors via
/// [foregroundColor] and [backgroundColor].
abstract class InspectorButton extends StatelessWidget {
/// Creates an inspector button.
///
/// This is the base constructor used by named constructors.
const InspectorButton({
super.key,
required this.onPressed,
required this.semanticLabel,
required this.icon,
this.buttonKey,
required this.variant,
this.toggledOn,
});
/// Creates an inspector button with the [InspectorButtonVariant.filled] style.
///
/// This button typically has a solid background color and a contrasting icon.
const InspectorButton.filled({
super.key,
required this.onPressed,
required this.semanticLabel,
required this.icon,
this.buttonKey,
}) : variant = InspectorButtonVariant.filled,
toggledOn = null;
/// Creates an inspector button with the [InspectorButtonVariant.toggle] style.
///
/// This button can be in an "on" or "off" state, visually indicated.
/// The [toggledOn] parameter defaults to `true`.
const InspectorButton.toggle({
super.key,
required this.onPressed,
required this.semanticLabel,
required this.icon,
bool this.toggledOn = true,
}) : buttonKey = null,
variant = InspectorButtonVariant.toggle;
/// Creates an inspector button with the [InspectorButtonVariant.iconOnly] style.
///
/// This button typically displays only an icon with a transparent background.
const InspectorButton.iconOnly({
super.key,
required this.onPressed,
required this.semanticLabel,
required this.icon,
}) : buttonKey = null,
variant = InspectorButtonVariant.iconOnly,
toggledOn = null;
/// The callback that is called when the button is tapped.
final VoidCallback onPressed;
/// The semantic label for the button, used for accessibility.
final String semanticLabel;
/// The icon to display within the button.
final IconData icon;
/// An optional key to identify the button widget.
final GlobalKey? buttonKey;
/// The visual and behavioral variant of the button.
///
/// See [InspectorButtonVariant] for available styles.
final InspectorButtonVariant variant;
/// For [InspectorButtonVariant.toggle] buttons, this determines if the button
/// is currently in the "on" (true) or "off" (false) state.
final bool? toggledOn;
/// The standard height and width for the button.
static const double buttonSize = 32.0;
/// The standard size for the icon when it's not the only element (e.g., in filled or toggle buttons).
///
/// For [InspectorButtonVariant.iconOnly], the icon typically takes up the full [buttonSize].
static const double buttonIconSize = 18.0;
/// Gets the appropriate icon size based on the button's [variant].
///
/// Returns [buttonSize] if the variant is [InspectorButtonVariant.iconOnly],
/// otherwise returns [buttonIconSize].
double get iconSizeForVariant =>
variant == InspectorButtonVariant.iconOnly ? buttonSize : buttonIconSize;
/// Provides the appropriate foreground color for the button's icon.
Color foregroundColor(BuildContext context);
/// Provides the appropriate background color for the button.
Color backgroundColor(BuildContext context);
@override
Widget build(BuildContext context);
}
/// Mutable selection state of the inspector. /// Mutable selection state of the inspector.
class InspectorSelection with ChangeNotifier { class InspectorSelection with ChangeNotifier {
/// Creates an instance of [InspectorSelection]. /// Creates an instance of [InspectorSelection].
@ -3455,22 +3611,24 @@ const double _kOffScreenMargin = 1.0;
const TextStyle _messageStyle = TextStyle(color: Color(0xFFFFFFFF), fontSize: 10.0, height: 1.2); const TextStyle _messageStyle = TextStyle(color: Color(0xFFFFFFFF), fontSize: 10.0, height: 1.2);
class _ExitWidgetSelectionButtonGroup extends StatefulWidget { class _WidgetInspectorButtonGroup extends StatefulWidget {
const _ExitWidgetSelectionButtonGroup({ const _WidgetInspectorButtonGroup({
required this.exitWidgetSelectionButtonBuilder, required this.exitWidgetSelectionButtonBuilder,
required this.moveExitWidgetSelectionButtonBuilder, required this.moveExitWidgetSelectionButtonBuilder,
required this.tapBehaviorButtonBuilder,
}); });
final ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder; final ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder;
final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder; final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder;
final TapBehaviorButtonBuilder? tapBehaviorButtonBuilder;
@override @override
State<_ExitWidgetSelectionButtonGroup> createState() => _ExitWidgetSelectionButtonGroupState(); State<_WidgetInspectorButtonGroup> createState() => _WidgetInspectorButtonGroupState();
} }
class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionButtonGroup> { class _WidgetInspectorButtonGroupState extends State<_WidgetInspectorButtonGroup> {
static const double _kExitWidgetSelectionButtonPadding = 4.0;
static const double _kExitWidgetSelectionButtonMargin = 10.0; static const double _kExitWidgetSelectionButtonMargin = 10.0;
static const bool _defaultSelectionOnTapEnabled = true;
final GlobalKey _exitWidgetSelectionButtonKey = GlobalKey( final GlobalKey _exitWidgetSelectionButtonKey = GlobalKey(
debugLabel: 'Exit Widget Selection button', debugLabel: 'Exit Widget Selection button',
@ -3480,31 +3638,78 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut
bool _leftAligned = true; bool _leftAligned = true;
ValueNotifier<bool> get _selectionOnTapEnabled =>
WidgetsBinding.instance.debugWidgetInspectorSelectionOnTapEnabled;
Widget? get _moveExitWidgetSelectionButton {
final MoveExitWidgetSelectionButtonBuilder? buttonBuilder =
widget.moveExitWidgetSelectionButtonBuilder;
if (buttonBuilder == null) {
return null;
}
final String buttonLabel = 'Move to the ${_leftAligned ? 'right' : 'left'}';
return _WidgetInspectorButton(
button: buttonBuilder(
context,
onPressed: () {
_changeButtonGroupAlignment();
_onTooltipHidden();
},
semanticLabel: buttonLabel,
isLeftAligned: _leftAligned,
),
onTooltipVisible: () {
_changeTooltipMessage(buttonLabel);
},
onTooltipHidden: _onTooltipHidden,
);
}
Widget get _exitWidgetSelectionButton {
const String buttonLabel = 'Exit Select Widget mode';
return _WidgetInspectorButton(
button: widget.exitWidgetSelectionButtonBuilder(
context,
onPressed: _exitWidgetSelectionMode,
semanticLabel: buttonLabel,
key: _exitWidgetSelectionButtonKey,
),
onTooltipVisible: () {
_changeTooltipMessage(buttonLabel);
},
onTooltipHidden: _onTooltipHidden,
);
}
Widget? get _tapBehaviorButton {
final TapBehaviorButtonBuilder? buttonBuilder = widget.tapBehaviorButtonBuilder;
if (buttonBuilder == null) {
return null;
}
return _WidgetInspectorButton(
button: buttonBuilder(
context,
onPressed: _changeSelectionOnTapMode,
semanticLabel: 'Change widget selection mode for taps',
selectionOnTapEnabled: _selectionOnTapEnabled.value,
),
onTooltipVisible: _changeSelectionOnTapTooltip,
onTooltipHidden: _onTooltipHidden,
);
}
bool get _tooltipVisible => _tooltipMessage != null;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Widget? moveExitWidgetSelectionButton = final Widget selectionModeButtons = Column(
widget.moveExitWidgetSelectionButtonBuilder != null children: <Widget>[
? Padding( if (_tapBehaviorButton != null) _tapBehaviorButton!,
padding: EdgeInsets.only( _exitWidgetSelectionButton,
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( final Widget buttonGroup = Stack(
alignment: AlignmentDirectional.topCenter, alignment: AlignmentDirectional.topCenter,
@ -3517,22 +3722,12 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut
), ),
), ),
Row( Row(
crossAxisAlignment: CrossAxisAlignment.end,
mainAxisAlignment: MainAxisAlignment.center,
children: <Widget>[ children: <Widget>[
if (!_leftAligned && moveExitWidgetSelectionButton != null) if (_leftAligned) selectionModeButtons,
moveExitWidgetSelectionButton, if (_moveExitWidgetSelectionButton != null) _moveExitWidgetSelectionButton!,
_TooltipGestureDetector( if (!_leftAligned) selectionModeButtons,
button: widget.exitWidgetSelectionButtonBuilder(
context,
onPressed: _exitWidgetSelectionMode,
key: _exitWidgetSelectionButtonKey,
),
onTooltipVisible: () {
_changeTooltipMessage('Exit Select Widget mode');
},
onTooltipHidden: _onTooltipHidden,
),
if (_leftAligned && moveExitWidgetSelectionButton != null)
moveExitWidgetSelectionButton,
], ],
), ),
], ],
@ -3548,6 +3743,25 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut
void _exitWidgetSelectionMode() { void _exitWidgetSelectionMode() {
WidgetInspectorService.instance._changeWidgetSelectionMode(false); WidgetInspectorService.instance._changeWidgetSelectionMode(false);
// Reset to default selection on tap behavior on exit.
_changeSelectionOnTapMode(selectionOnTapEnabled: _defaultSelectionOnTapEnabled);
}
void _changeSelectionOnTapMode({bool? selectionOnTapEnabled}) {
final bool newValue = selectionOnTapEnabled ?? !_selectionOnTapEnabled.value;
_selectionOnTapEnabled.value = newValue;
WidgetInspectorService.instance.selection.clear();
if (_tooltipVisible) {
_changeSelectionOnTapTooltip();
}
}
void _changeSelectionOnTapTooltip() {
_changeTooltipMessage(
_selectionOnTapEnabled.value
? 'Disable widget selection for taps'
: 'Enable widget selection for taps',
);
} }
void _changeButtonGroupAlignment() { void _changeButtonGroupAlignment() {
@ -3571,8 +3785,8 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut
} }
} }
class _TooltipGestureDetector extends StatefulWidget { class _WidgetInspectorButton extends StatefulWidget {
const _TooltipGestureDetector({ const _WidgetInspectorButton({
required this.button, required this.button,
required this.onTooltipVisible, required this.onTooltipVisible,
required this.onTooltipHidden, required this.onTooltipHidden,
@ -3586,10 +3800,10 @@ class _TooltipGestureDetector extends StatefulWidget {
static const Duration _tooltipDelayDuration = Duration(milliseconds: 100); static const Duration _tooltipDelayDuration = Duration(milliseconds: 100);
@override @override
State<_TooltipGestureDetector> createState() => _TooltipGestureDetectorState(); State<_WidgetInspectorButton> createState() => _WidgetInspectorButtonState();
} }
class _TooltipGestureDetectorState extends State<_TooltipGestureDetector> { class _WidgetInspectorButtonState extends State<_WidgetInspectorButton> {
Timer? _tooltipVisibleTimer; Timer? _tooltipVisibleTimer;
Timer? _tooltipHiddenTimer; Timer? _tooltipHiddenTimer;
@ -3609,18 +3823,18 @@ class _TooltipGestureDetectorState extends State<_TooltipGestureDetector> {
children: <Widget>[ children: <Widget>[
GestureDetector( GestureDetector(
onLongPress: () { onLongPress: () {
_tooltipVisibleAfter(_TooltipGestureDetector._tooltipDelayDuration); _tooltipVisibleAfter(_WidgetInspectorButton._tooltipDelayDuration);
_tooltipHiddenAfter( _tooltipHiddenAfter(
_TooltipGestureDetector._tooltipShownOnLongPressDuration + _WidgetInspectorButton._tooltipShownOnLongPressDuration +
_TooltipGestureDetector._tooltipDelayDuration, _WidgetInspectorButton._tooltipDelayDuration,
); );
}, },
child: MouseRegion( child: MouseRegion(
onEnter: (_) { onEnter: (_) {
_tooltipVisibleAfter(_TooltipGestureDetector._tooltipDelayDuration); _tooltipVisibleAfter(_WidgetInspectorButton._tooltipDelayDuration);
}, },
onExit: (_) { onExit: (_) {
_tooltipHiddenAfter(_TooltipGestureDetector._tooltipDelayDuration); _tooltipHiddenAfter(_WidgetInspectorButton._tooltipDelayDuration);
}, },
child: widget.button, child: widget.button,
), ),

View File

@ -343,6 +343,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
child: WidgetInspector( child: WidgetInspector(
exitWidgetSelectionButtonBuilder: null, exitWidgetSelectionButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: Stack( child: Stack(
children: <Widget>[ children: <Widget>[
Text('a', textDirection: TextDirection.ltr), Text('a', textDirection: TextDirection.ltr),
@ -370,6 +371,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget exitWidgetSelectionButtonBuilder( Widget exitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}) { }) {
exitWidgetSelectionButtonKey = key; exitWidgetSelectionButtonKey = key;
@ -422,6 +424,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
key: inspectorKey, key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: Material( child: Material(
child: ListView( child: ListView(
children: <Widget>[ children: <Widget>[
@ -515,6 +518,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
child: WidgetInspector( child: WidgetInspector(
exitWidgetSelectionButtonBuilder: null, exitWidgetSelectionButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: Transform( child: Transform(
transform: Matrix4.identity()..scale(0.0), transform: Matrix4.identity()..scale(0.0),
child: const Stack( child: const Stack(
@ -545,6 +549,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget exitWidgetSelectionButtonBuilder( Widget exitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}) { }) {
exitWidgetSelectionButtonKey = key; exitWidgetSelectionButtonKey = key;
@ -558,6 +563,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
key: inspectorKey, key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: ListView( child: ListView(
dragStartBehavior: DragStartBehavior.down, dragStartBehavior: DragStartBehavior.down,
children: <Widget>[Container(key: childKey, height: 5000.0)], children: <Widget>[Container(key: childKey, height: 5000.0)],
@ -621,6 +627,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
child: WidgetInspector( child: WidgetInspector(
exitWidgetSelectionButtonBuilder: null, exitWidgetSelectionButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: GestureDetector( child: GestureDetector(
onLongPress: () { onLongPress: () {
expect(didLongPress, isFalse); expect(didLongPress, isFalse);
@ -688,6 +695,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
key: inspectorKey, key: inspectorKey,
exitWidgetSelectionButtonBuilder: null, exitWidgetSelectionButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: Overlay( child: Overlay(
initialEntries: <OverlayEntry>[ initialEntries: <OverlayEntry>[
entry1 = OverlayEntry( entry1 = OverlayEntry(
@ -747,6 +755,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
child: WidgetInspector( child: WidgetInspector(
exitWidgetSelectionButtonBuilder: null, exitWidgetSelectionButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: ColoredBox( child: ColoredBox(
color: Colors.white, color: Colors.white,
child: Center( child: Center(
@ -791,7 +800,12 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
final GlobalKey child2Key = GlobalKey(); final GlobalKey child2Key = GlobalKey();
ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder(Key key) { ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder(Key key) {
return (BuildContext context, {required VoidCallback onPressed, required GlobalKey key}) { return (
BuildContext context, {
required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key,
}) {
return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null)); return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null));
}; };
} }
@ -813,6 +827,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
selectButton1Key, selectButton1Key,
), ),
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: Container(key: child1Key, child: const Text('Child 1')), child: Container(key: child1Key, child: const Text('Child 1')),
), ),
), ),
@ -823,6 +838,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
selectButton2Key, selectButton2Key,
), ),
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: Container(key: child2Key, child: const Text('Child 2')), child: Container(key: child2Key, child: const Text('Child 2')),
), ),
), ),
@ -858,6 +874,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget exitWidgetSelectionButtonBuilder( Widget exitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}) { }) {
return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null)); return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null));
@ -869,6 +886,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
child: WidgetInspector( child: WidgetInspector(
key: inspectorKey, key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
child: const Text('Child 1'), child: const Text('Child 1'),
), ),
@ -918,6 +936,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget exitWidgetSelectionButtonBuilder( Widget exitWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key, required GlobalKey key,
}) { }) {
return Material( return Material(
@ -932,6 +951,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
Widget moveWidgetSelectionButtonBuilder( Widget moveWidgetSelectionButtonBuilder(
BuildContext context, { BuildContext context, {
required VoidCallback onPressed, required VoidCallback onPressed,
required String semanticLabel,
bool isLeftAligned = true, bool isLeftAligned = true,
}) { }) {
return Material( return Material(
@ -953,6 +973,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
key: inspectorKey, key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: null,
child: const Text('APP'), child: const Text('APP'),
), ),
), ),
@ -990,6 +1011,118 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(), skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
); );
testWidgets(
'WidgetInspector Tap behavior button',
(WidgetTester tester) async {
Widget exitWidgetSelectionButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticLabel,
required GlobalKey key,
}) {
return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null));
}
Widget tapBehaviorButtonBuilder(
BuildContext context, {
required VoidCallback onPressed,
required String semanticLabel,
required bool selectionOnTapEnabled,
}) {
return Material(
child: ElevatedButton(
onPressed: onPressed,
child: Text(selectionOnTapEnabled ? 'SELECTION ON TAP' : 'APP INTERACTION ON TAP'),
),
);
}
Finder buttonFinder(String buttonText) {
return find.ancestor(of: find.text(buttonText), matching: find.byType(ElevatedButton));
}
int navigateEventsCount() =>
service.dispatchedEvents('navigate', stream: 'ToolEvent').length;
// Enable widget selection mode.
WidgetInspectorService.instance.isSelectMode = true;
// Pump the test widget.
final GlobalKey inspectorKey = GlobalKey();
setupDefaultPubRootDirectory(service);
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: WidgetInspector(
key: inspectorKey,
exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder,
tapBehaviorButtonBuilder: tapBehaviorButtonBuilder,
moveExitWidgetSelectionButtonBuilder: null,
child: const Row(children: <Widget>[Text('Child 1'), Text('Child 2')]),
),
),
);
// Verify there are no navigate events yet.
expect(navigateEventsCount(), equals(0));
// Tap on the first child widget.
final Finder child1 = find.text('Child 1');
await tester.tap(child1, warnIfMissed: false);
await tester.pump();
// Verify the selection matches the first child widget.
final Element child1Element = child1.evaluate().first;
expect(service.selection.current, equals(child1Element.renderObject));
// Verify that a navigate event was sent.
expect(navigateEventsCount(), equals(1));
// Tap on the SELECTION ON TAP button.
final Finder tapBehaviorButtonBefore = buttonFinder('SELECTION ON TAP');
expect(tapBehaviorButtonBefore, findsOneWidget);
await tester.tap(tapBehaviorButtonBefore);
await tester.pump();
// Verify the tap behavior button's UI has been updated.
expect(tapBehaviorButtonBefore, findsNothing);
final Finder tapBehaviorButtonAfter = buttonFinder('APP INTERACTION ON TAP');
expect(tapBehaviorButtonAfter, findsOneWidget);
// Tap on the second child widget.
final Finder child2 = find.text('Child 2');
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// Verify there is no selection.
expect(service.selection.current, isNull);
// Verify no navigate events were sent.
expect(navigateEventsCount(), equals(1));
// Tap on the SELECTION ON TAP button again.
await tester.tap(tapBehaviorButtonAfter);
await tester.pump();
// Verify the tap behavior button's UI has been reset.
expect(tapBehaviorButtonAfter, findsNothing);
expect(tapBehaviorButtonBefore, findsOneWidget);
// Tap on the second child widget again.
await tester.tap(child2, warnIfMissed: false);
await tester.pump();
// Verify the selection now matches the second child widget.
final Element child2Element = child2.evaluate().first;
expect(service.selection.current, equals(child2Element.renderObject));
// Verify another navigate event was sent.
expect(navigateEventsCount(), equals(2));
},
// [intended] Test requires --track-widget-creation flag.
skip: !WidgetInspectorService.instance.isWidgetCreationTracked(),
);
testWidgets('test transformDebugCreator will re-order if after stack trace', ( testWidgets('test transformDebugCreator will re-order if after stack trace', (
WidgetTester tester, WidgetTester tester,
) async { ) async {
@ -3867,6 +4000,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService {
child: WidgetInspector( child: WidgetInspector(
exitWidgetSelectionButtonBuilder: null, exitWidgetSelectionButtonBuilder: null,
moveExitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null,
tapBehaviorButtonBuilder: null,
child: _applyConstructor(_TrivialWidget.new), child: _applyConstructor(_TrivialWidget.new),
), ),
); );