diff --git a/packages/flutter/lib/src/cupertino/app.dart b/packages/flutter/lib/src/cupertino/app.dart index 0b7ce65d21cbd..fc13d4e99ae7b 100644 --- a/packages/flutter/lib/src/cupertino/app.dart +++ b/packages/flutter/lib/src/cupertino/app.dart @@ -15,6 +15,7 @@ import 'package:flutter/widgets.dart'; import 'button.dart'; import 'colors.dart'; +import 'constants.dart'; import 'icons.dart'; import 'interface_level.dart'; import 'localizations.dart'; @@ -534,46 +535,44 @@ class _CupertinoAppState extends State { Widget _exitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }) { - return CupertinoButton( - key: key, - color: _widgetSelectionButtonsBackgroundColor(context), - padding: EdgeInsets.zero, + return _CupertinoInspectorButton.filled( onPressed: onPressed, - child: Icon( - CupertinoIcons.xmark, - size: 28.0, - color: _widgetSelectionButtonsForegroundColor(context), - semanticLabel: 'Exit Select Widget mode.', - ), + semanticLabel: semanticLabel, + icon: CupertinoIcons.xmark, + buttonKey: key, ); } Widget _moveExitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, bool isLeftAligned = true, }) { - return CupertinoButton( + return _CupertinoInspectorButton.iconOnly( 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'}.', - ), + semanticLabel: semanticLabel, + icon: isLeftAligned ? CupertinoIcons.arrow_right : CupertinoIcons.arrow_left, ); } - Color _widgetSelectionButtonsForegroundColor(BuildContext context) { - return CupertinoTheme.of(context).primaryContrastingColor; - } - - Color _widgetSelectionButtonsBackgroundColor(BuildContext context) { - return CupertinoTheme.of(context).primaryColor; + Widget _tapBehaviorButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticLabel, + required bool selectionOnTapEnabled, + }) { + 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) { @@ -607,6 +606,7 @@ class _CupertinoAppState extends State { debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, @@ -642,6 +642,7 @@ class _CupertinoAppState extends State { debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, @@ -680,3 +681,83 @@ class _CupertinoAppState extends State { ); } } + +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); + } + } +} diff --git a/packages/flutter/lib/src/material/app.dart b/packages/flutter/lib/src/material/app.dart index bab92ee235bda..d2083c0353946 100644 --- a/packages/flutter/lib/src/material/app.dart +++ b/packages/flutter/lib/src/material/app.dart @@ -20,8 +20,8 @@ import 'package:flutter/foundation.dart'; import 'package:flutter/services.dart'; import 'arc.dart'; +import 'button_style.dart'; import 'colors.dart'; -import 'floating_action_button.dart'; import 'icon_button.dart'; import 'icons.dart'; import 'material_localizations.dart'; @@ -29,6 +29,7 @@ import 'page.dart'; import 'scaffold.dart' show ScaffoldMessenger, ScaffoldMessengerState; import 'scrollbar.dart'; import 'theme.dart'; +import 'theme_data.dart'; import 'tooltip.dart'; // Examples can assume: @@ -903,9 +904,6 @@ 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; @@ -938,52 +936,47 @@ class _MaterialAppState extends State { Widget _exitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }) { - return FloatingActionButton( - key: key, + return _MaterialInspectorButton.filled( onPressed: onPressed, - mini: true, - backgroundColor: _widgetSelectionButtonsBackgroundColor(context), - foregroundColor: _widgetSelectionButtonsForegroundColor(context), - child: const Icon(Icons.close, semanticLabel: 'Exit Select Widget mode.'), + semanticLabel: semanticLabel, + icon: Icons.close, + isDarkTheme: _isDarkTheme(context), + buttonKey: key, ); } Widget _moveExitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, bool isLeftAligned = true, }) { - return IconButton( - color: _widgetSelectionButtonsBackgroundColor(context), - padding: EdgeInsets.zero, - iconSize: _moveExitWidgetSelectionIconSize, + return _MaterialInspectorButton.iconOnly( 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'}.', - ), + semanticLabel: semanticLabel, + icon: isLeftAligned ? Icons.arrow_right : Icons.arrow_left, + isDarkTheme: _isDarkTheme(context), ); } - 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; + Widget _tapBehaviorButtonBuilder( + BuildContext context, { + required VoidCallback onPressed, + required String semanticLabel, + required bool selectionOnTapEnabled, + }) { + return _MaterialInspectorButton.toggle( + onPressed: onPressed, + semanticLabel: semanticLabel, + // This icon is also used for the Cupertino-styled button and for DevTools. + // It should be updated in all 3 places if changed. + icon: CupertinoIcons.cursor_rays, + isDarkTheme: _isDarkTheme(context), + toggledOn: selectionOnTapEnabled, + ); } bool _isDarkTheme(BuildContext context) { @@ -1100,6 +1093,7 @@ class _MaterialAppState extends State { debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, @@ -1135,6 +1129,7 @@ class _MaterialAppState extends State { debugShowCheckedModeBanner: widget.debugShowCheckedModeBanner, exitWidgetSelectionButtonBuilder: _exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: _moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: _tapBehaviorButtonBuilder, shortcuts: widget.shortcuts, actions: widget.actions, restorationScopeId: widget.restorationScopeId, @@ -1173,3 +1168,101 @@ class _MaterialAppState extends State { ); } } + +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; + } +} diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index dce1f17bc9ca6..b892ba9c0e7ed 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -355,6 +355,7 @@ class WidgetsApp extends StatefulWidget { this.debugShowCheckedModeBanner = true, this.exitWidgetSelectionButtonBuilder, this.moveExitWidgetSelectionButtonBuilder, + this.tapBehaviorButtonBuilder, this.shortcuts, this.actions, this.restorationScopeId, @@ -446,6 +447,7 @@ class WidgetsApp extends StatefulWidget { this.debugShowCheckedModeBanner = true, this.exitWidgetSelectionButtonBuilder, this.moveExitWidgetSelectionButtonBuilder, + this.tapBehaviorButtonBuilder, this.shortcuts, this.actions, this.restorationScopeId, @@ -1041,19 +1043,27 @@ class WidgetsApp extends StatefulWidget { /// Builds the widget the [WidgetInspector] uses to exit selection mode. /// - /// This lets [MaterialApp] to use a Material Design button to exit the - /// inspector select mode without requiring [WidgetInspector] to depend on the - /// Material package. + /// 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 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. + /// 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 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} /// Turns on a little "DEBUG" banner in debug mode to indicate /// that the app is in debug mode. This is on by default (in @@ -1824,6 +1834,7 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { return WidgetInspector( exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: widget.tapBehaviorButtonBuilder, child: child!, ); } diff --git a/packages/flutter/lib/src/widgets/binding.dart b/packages/flutter/lib/src/widgets/binding.dart index 22bf618056a53..bce192d1926e0 100644 --- a/packages/flutter/lib/src/widgets/binding.dart +++ b/packages/flutter/lib/src/widgets/binding.dart @@ -492,6 +492,15 @@ mixin WidgetsBinding _debugShowWidgetInspectorOverrideNotifierObject ??= ValueNotifier(false); ValueNotifier? _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 get debugWidgetInspectorSelectionOnTapEnabled => + _debugWidgetInspectorSelectionOnTapEnabledNotifierObject ??= ValueNotifier(true); + ValueNotifier? _debugWidgetInspectorSelectionOnTapEnabledNotifierObject; + @visibleForTesting @override void resetInternalState() { @@ -499,6 +508,8 @@ mixin WidgetsBinding super.resetInternalState(); _debugShowWidgetInspectorOverrideNotifierObject?.dispose(); _debugShowWidgetInspectorOverrideNotifierObject = null; + _debugWidgetInspectorSelectionOnTapEnabledNotifierObject?.dispose(); + _debugWidgetInspectorSelectionOnTapEnabledNotifierObject = null; } void _debugAddStackFilters() { diff --git a/packages/flutter/lib/src/widgets/widget_inspector.dart b/packages/flutter/lib/src/widgets/widget_inspector.dart index e862bac44d142..d1cebbd11aeb7 100644 --- a/packages/flutter/lib/src/widgets/widget_inspector.dart +++ b/packages/flutter/lib/src/widgets/widget_inspector.dart @@ -34,6 +34,7 @@ import 'binding.dart'; import 'debug.dart'; import 'framework.dart'; import 'gesture_detector.dart'; +import 'icon_data.dart'; import 'service_extensions.dart'; import 'view.dart'; @@ -43,13 +44,29 @@ typedef ExitWidgetSelectionButtonBuilder = Widget Function( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }); /// Signature for the builder callback used by /// [WidgetInspector.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 /// the given `name`. @@ -2768,6 +2785,7 @@ class WidgetInspector extends StatefulWidget { const WidgetInspector({ super.key, required this.child, + required this.tapBehaviorButtonBuilder, required this.exitWidgetSelectionButtonBuilder, required this.moveExitWidgetSelectionButtonBuilder, }); @@ -2790,6 +2808,15 @@ class WidgetInspector extends StatefulWidget { /// The button UI should respond to the `leftAligned` argument. 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 State createState() => _WidgetInspectorState(); } @@ -2809,6 +2836,11 @@ class _WidgetInspectorState extends State with WidgetsBindingOb /// as selecting the edge of the bounding box. static const double _edgeHitMargin = 2.0; + ValueNotifier get _selectionOnTapEnabled => + WidgetsBinding.instance.debugWidgetInspectorSelectionOnTapEnabled; + + bool get _isSelectModeWithSelectionOnTapEnabled => isSelectMode && _selectionOnTapEnabled.value; + @override void initState() { super.initState(); @@ -2817,6 +2849,7 @@ class _WidgetInspectorState extends State with WidgetsBindingOb WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier.addListener( _selectionInformationChanged, ); + _selectionOnTapEnabled.addListener(_selectionInformationChanged); selection = WidgetInspectorService.instance.selection; isSelectMode = WidgetsBinding.instance.debugShowWidgetInspectorOverride; } @@ -2827,6 +2860,7 @@ class _WidgetInspectorState extends State with WidgetsBindingOb WidgetsBinding.instance.debugShowWidgetInspectorOverrideNotifier.removeListener( _selectionInformationChanged, ); + _selectionOnTapEnabled.removeListener(_selectionInformationChanged); super.dispose(); } @@ -2911,7 +2945,7 @@ class _WidgetInspectorState extends State with WidgetsBindingOb } void _inspectAt(Offset position) { - if (!isSelectMode) { + if (!_isSelectModeWithSelectionOnTapEnabled) { return; } @@ -2952,7 +2986,7 @@ class _WidgetInspectorState extends State with WidgetsBindingOb } void _handleTap() { - if (!isSelectMode) { + if (!_isSelectModeWithSelectionOnTapEnabled) { return; } if (_lastPointerLocation != null) { @@ -2975,11 +3009,16 @@ class _WidgetInspectorState extends State with WidgetsBindingOb onPanUpdate: _handlePanUpdate, behavior: HitTestBehavior.opaque, excludeFromSemantics: true, - child: IgnorePointer(ignoring: isSelectMode, key: _ignorePointerKey, child: widget.child), + child: IgnorePointer( + ignoring: _isSelectModeWithSelectionOnTapEnabled, + key: _ignorePointerKey, + child: widget.child, + ), ), _InspectorOverlay(selection: selection), if (isSelectMode && widget.exitWidgetSelectionButtonBuilder != null) - _ExitWidgetSelectionButtonGroup( + _WidgetInspectorButtonGroup( + tapBehaviorButtonBuilder: widget.tapBehaviorButtonBuilder, exitWidgetSelectionButtonBuilder: widget.exitWidgetSelectionButtonBuilder!, moveExitWidgetSelectionButtonBuilder: widget.moveExitWidgetSelectionButtonBuilder, ), @@ -2988,6 +3027,123 @@ class _WidgetInspectorState extends State 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. class InspectorSelection with ChangeNotifier { /// 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); -class _ExitWidgetSelectionButtonGroup extends StatefulWidget { - const _ExitWidgetSelectionButtonGroup({ +class _WidgetInspectorButtonGroup extends StatefulWidget { + const _WidgetInspectorButtonGroup({ required this.exitWidgetSelectionButtonBuilder, required this.moveExitWidgetSelectionButtonBuilder, + required this.tapBehaviorButtonBuilder, }); final ExitWidgetSelectionButtonBuilder exitWidgetSelectionButtonBuilder; final MoveExitWidgetSelectionButtonBuilder? moveExitWidgetSelectionButtonBuilder; + final TapBehaviorButtonBuilder? tapBehaviorButtonBuilder; @override - State<_ExitWidgetSelectionButtonGroup> createState() => _ExitWidgetSelectionButtonGroupState(); + State<_WidgetInspectorButtonGroup> createState() => _WidgetInspectorButtonGroupState(); } -class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionButtonGroup> { - static const double _kExitWidgetSelectionButtonPadding = 4.0; +class _WidgetInspectorButtonGroupState extends State<_WidgetInspectorButtonGroup> { static const double _kExitWidgetSelectionButtonMargin = 10.0; + static const bool _defaultSelectionOnTapEnabled = true; final GlobalKey _exitWidgetSelectionButtonKey = GlobalKey( debugLabel: 'Exit Widget Selection button', @@ -3480,31 +3638,78 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut bool _leftAligned = true; + ValueNotifier 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 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 selectionModeButtons = Column( + children: [ + if (_tapBehaviorButton != null) _tapBehaviorButton!, + _exitWidgetSelectionButton, + ], + ); final Widget buttonGroup = Stack( alignment: AlignmentDirectional.topCenter, @@ -3517,22 +3722,12 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut ), ), Row( + crossAxisAlignment: CrossAxisAlignment.end, + mainAxisAlignment: MainAxisAlignment.center, 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, + if (_leftAligned) selectionModeButtons, + if (_moveExitWidgetSelectionButton != null) _moveExitWidgetSelectionButton!, + if (!_leftAligned) selectionModeButtons, ], ), ], @@ -3548,6 +3743,25 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut void _exitWidgetSelectionMode() { 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() { @@ -3571,8 +3785,8 @@ class _ExitWidgetSelectionButtonGroupState extends State<_ExitWidgetSelectionBut } } -class _TooltipGestureDetector extends StatefulWidget { - const _TooltipGestureDetector({ +class _WidgetInspectorButton extends StatefulWidget { + const _WidgetInspectorButton({ required this.button, required this.onTooltipVisible, required this.onTooltipHidden, @@ -3586,10 +3800,10 @@ class _TooltipGestureDetector extends StatefulWidget { static const Duration _tooltipDelayDuration = Duration(milliseconds: 100); @override - State<_TooltipGestureDetector> createState() => _TooltipGestureDetectorState(); + State<_WidgetInspectorButton> createState() => _WidgetInspectorButtonState(); } -class _TooltipGestureDetectorState extends State<_TooltipGestureDetector> { +class _WidgetInspectorButtonState extends State<_WidgetInspectorButton> { Timer? _tooltipVisibleTimer; Timer? _tooltipHiddenTimer; @@ -3609,18 +3823,18 @@ class _TooltipGestureDetectorState extends State<_TooltipGestureDetector> { children: [ GestureDetector( onLongPress: () { - _tooltipVisibleAfter(_TooltipGestureDetector._tooltipDelayDuration); + _tooltipVisibleAfter(_WidgetInspectorButton._tooltipDelayDuration); _tooltipHiddenAfter( - _TooltipGestureDetector._tooltipShownOnLongPressDuration + - _TooltipGestureDetector._tooltipDelayDuration, + _WidgetInspectorButton._tooltipShownOnLongPressDuration + + _WidgetInspectorButton._tooltipDelayDuration, ); }, child: MouseRegion( onEnter: (_) { - _tooltipVisibleAfter(_TooltipGestureDetector._tooltipDelayDuration); + _tooltipVisibleAfter(_WidgetInspectorButton._tooltipDelayDuration); }, onExit: (_) { - _tooltipHiddenAfter(_TooltipGestureDetector._tooltipDelayDuration); + _tooltipHiddenAfter(_WidgetInspectorButton._tooltipDelayDuration); }, child: widget.button, ), diff --git a/packages/flutter/test/widgets/widget_inspector_test.dart b/packages/flutter/test/widgets/widget_inspector_test.dart index e6f03740b6933..fc37b4802612a 100644 --- a/packages/flutter/test/widgets/widget_inspector_test.dart +++ b/packages/flutter/test/widgets/widget_inspector_test.dart @@ -343,6 +343,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: WidgetInspector( exitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: Stack( children: [ Text('a', textDirection: TextDirection.ltr), @@ -370,6 +371,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Widget exitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }) { exitWidgetSelectionButtonKey = key; @@ -422,6 +424,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { key: inspectorKey, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: Material( child: ListView( children: [ @@ -515,6 +518,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: WidgetInspector( exitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: Transform( transform: Matrix4.identity()..scale(0.0), child: const Stack( @@ -545,6 +549,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Widget exitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }) { exitWidgetSelectionButtonKey = key; @@ -558,6 +563,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { key: inspectorKey, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: ListView( dragStartBehavior: DragStartBehavior.down, children: [Container(key: childKey, height: 5000.0)], @@ -621,6 +627,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: WidgetInspector( exitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: GestureDetector( onLongPress: () { expect(didLongPress, isFalse); @@ -688,6 +695,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { key: inspectorKey, exitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: Overlay( initialEntries: [ entry1 = OverlayEntry( @@ -747,6 +755,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: WidgetInspector( exitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: ColoredBox( color: Colors.white, child: Center( @@ -791,7 +800,12 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { final GlobalKey child2Key = GlobalKey(); 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)); }; } @@ -813,6 +827,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { selectButton1Key, ), moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: Container(key: child1Key, child: const Text('Child 1')), ), ), @@ -823,6 +838,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { selectButton2Key, ), moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: Container(key: child2Key, child: const Text('Child 2')), ), ), @@ -858,6 +874,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Widget exitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }) { return Material(child: ElevatedButton(onPressed: onPressed, key: key, child: null)); @@ -869,6 +886,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: WidgetInspector( key: inspectorKey, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, child: const Text('Child 1'), ), @@ -918,6 +936,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Widget exitWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, required GlobalKey key, }) { return Material( @@ -932,6 +951,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { Widget moveWidgetSelectionButtonBuilder( BuildContext context, { required VoidCallback onPressed, + required String semanticLabel, bool isLeftAligned = true, }) { return Material( @@ -953,6 +973,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { key: inspectorKey, exitWidgetSelectionButtonBuilder: exitWidgetSelectionButtonBuilder, moveExitWidgetSelectionButtonBuilder: moveWidgetSelectionButtonBuilder, + tapBehaviorButtonBuilder: null, child: const Text('APP'), ), ), @@ -990,6 +1011,118 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { 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: [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', ( WidgetTester tester, ) async { @@ -3867,6 +4000,7 @@ class _TestWidgetInspectorService extends TestWidgetInspectorService { child: WidgetInspector( exitWidgetSelectionButtonBuilder: null, moveExitWidgetSelectionButtonBuilder: null, + tapBehaviorButtonBuilder: null, child: _applyConstructor(_TrivialWidget.new), ), );