diff --git a/packages/flutter/lib/material.dart b/packages/flutter/lib/material.dart index d530490159..525047ff44 100644 --- a/packages/flutter/lib/material.dart +++ b/packages/flutter/lib/material.dart @@ -158,6 +158,7 @@ export 'src/material/toggle_buttons_theme.dart'; export 'src/material/toggleable.dart'; export 'src/material/tooltip.dart'; export 'src/material/tooltip_theme.dart'; +export 'src/material/tooltip_visibility.dart'; export 'src/material/typography.dart'; export 'src/material/user_accounts_drawer_header.dart'; export 'widgets.dart'; diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index 40ee3e1104..cdfbceb607 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -14,6 +14,7 @@ import 'colors.dart'; import 'feedback.dart'; import 'theme.dart'; import 'tooltip_theme.dart'; +import 'tooltip_visibility.dart'; /// A material design tooltip. /// @@ -69,6 +70,7 @@ import 'tooltip_theme.dart'; /// /// * /// * [TooltipTheme] or [ThemeData.tooltipTheme] +/// * [TooltipVisibility] class Tooltip extends StatefulWidget { /// Creates a tooltip. /// @@ -327,6 +329,7 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { late bool enableFeedback; late bool _isConcealed; late bool _forceRemoval; + late bool _visible; /// The plain text message for this tooltip. /// @@ -352,6 +355,12 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { GestureBinding.instance!.pointerRouter.addGlobalRoute(_handlePointerEvent); } + @override + void didChangeDependencies() { + super.didChangeDependencies(); + _visible = TooltipVisibility.of(context); + } + // https://material.io/components/tooltips#specs double _getDefaultTooltipHeight() { final ThemeData theme = Theme.of(context); @@ -483,8 +492,11 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { /// Shows the tooltip if it is not already visible. /// - /// Returns `false` when the tooltip was already visible. + /// Returns `false` when the tooltip shouldn't be shown or when the tooltip + /// was already visible. bool ensureTooltipVisible() { + if (!_visible) + return false; _showTimer?.cancel(); _showTimer = null; _forceRemoval = false; @@ -671,27 +683,31 @@ class _TooltipState extends State with SingleTickerProviderStateMixin { triggerMode = widget.triggerMode ?? tooltipTheme.triggerMode ?? _defaultTriggerMode; enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback; - Widget result = GestureDetector( - behavior: HitTestBehavior.opaque, - onLongPress: (triggerMode == TooltipTriggerMode.longPress) ? - _handlePress : null, - onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null, - excludeFromSemantics: true, - child: Semantics( - label: excludeFromSemantics - ? null - : _tooltipMessage, - child: widget.child, - ), + Widget result = Semantics( + label: excludeFromSemantics + ? null + : _tooltipMessage, + child: widget.child, ); - // Only check for hovering if there is a mouse connected. - if (_mouseIsConnected) { - result = MouseRegion( - onEnter: (_) => _handleMouseEnter(), - onExit: (_) => _handleMouseExit(), + // Only check for gestures if tooltip should be visible. + if (_visible) { + result = GestureDetector( + behavior: HitTestBehavior.opaque, + onLongPress: (triggerMode == TooltipTriggerMode.longPress) ? + _handlePress : null, + onTap: (triggerMode == TooltipTriggerMode.tap) ? _handlePress : null, + excludeFromSemantics: true, child: result, ); + // Only check for hovering if there is a mouse connected. + if (_mouseIsConnected) { + result = MouseRegion( + onEnter: (_) => _handleMouseEnter(), + onExit: (_) => _handleMouseExit(), + child: result, + ); + } } return result; diff --git a/packages/flutter/lib/src/material/tooltip_theme.dart b/packages/flutter/lib/src/material/tooltip_theme.dart index dfb5a61a8b..1f43af227d 100644 --- a/packages/flutter/lib/src/material/tooltip_theme.dart +++ b/packages/flutter/lib/src/material/tooltip_theme.dart @@ -242,6 +242,10 @@ class TooltipThemeData with Diagnosticable { /// ) /// ``` /// {@end-tool} +/// +/// See also: +/// +/// * [TooltipVisibility], which can be used to visually disable descendant [Tooltip]s. class TooltipTheme extends InheritedTheme { /// Creates a tooltip theme that controls the configurations for /// [Tooltip]. diff --git a/packages/flutter/lib/src/material/tooltip_visibility.dart b/packages/flutter/lib/src/material/tooltip_visibility.dart new file mode 100644 index 0000000000..d242ad1f43 --- /dev/null +++ b/packages/flutter/lib/src/material/tooltip_visibility.dart @@ -0,0 +1,63 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'package:flutter/widgets.dart'; + +class _TooltipVisibilityScope extends InheritedWidget { + const _TooltipVisibilityScope({ + Key? key, + required Widget child, + required this.visible, + }) : super(key: key, child: child); + + final bool visible; + + @override + bool updateShouldNotify(_TooltipVisibilityScope old) { + return old.visible != visible; + } +} + +/// Overrides the visibility of descendant [Tooltip] widgets. +/// +/// If disabled, the descendant [Tooltip] widgets will not display a tooltip +/// when tapped, long-pressed, hovered by the mouse, or when +/// `ensureTooltipVisible` is called. This only visually disables tooltips but +/// continues to provide any semantic information that is provided. +class TooltipVisibility extends StatelessWidget { + /// Creates a widget that configures the visibility of [Tooltip]. + /// + /// Both arguments must not be null. + const TooltipVisibility({ + Key? key, + required this.child, + required this.visible, + }) : super(key: key); + + /// The widget below this widget in the tree. + /// + /// The entire app can be wrapped in this widget to globally control [Tooltip] + /// visibility. + /// + /// {@macro flutter.widgets.ProxyWidget.child} + final Widget child; + + /// Determines the visibility of [Tooltip] widgets that inherit from this widget. + final bool visible; + + /// The [visible] of the closest instance of this class that encloses the + /// given context. Defaults to `true` if none are found. + static bool of(BuildContext context) { + final _TooltipVisibilityScope? visibility = context.dependOnInheritedWidgetOfExactType<_TooltipVisibilityScope>(); + return visibility?.visible ?? true; + } + + @override + Widget build(BuildContext context) { + return _TooltipVisibilityScope( + child: child, + visible: visible, + ); + } +} diff --git a/packages/flutter/test/material/tooltip_visibility_test.dart b/packages/flutter/test/material/tooltip_visibility_test.dart new file mode 100644 index 0000000000..febf9a0479 --- /dev/null +++ b/packages/flutter/test/material/tooltip_visibility_test.dart @@ -0,0 +1,223 @@ +// Copyright 2014 The Flutter Authors. All rights reserved. +// Use of this source code is governed by a BSD-style license that can be +// found in the LICENSE file. + +import 'dart:ui'; + +import 'package:flutter/gestures.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter/rendering.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void _ensureTooltipVisible(GlobalKey key) { + // This function uses "as dynamic" to defeat the static analysis. In general + // you want to avoid using this style in your code, as it will cause the + // analyzer to be unable to help you catch errors. + // + // In this case, we do it because we are trying to call internal methods of + // the tooltip code in order to test it. Normally, the state of a tooltip is a + // private class, but by using a GlobalKey we can get a handle to that object + // and by using "as dynamic" we can bypass the analyzer's type checks and call + // methods that we aren't supposed to be able to know about. + // + // It's ok to do this in tests, but you really don't want to do it in + // production code. + // ignore: avoid_dynamic_calls + (key.currentState as dynamic).ensureTooltipVisible(); +} + +const String tooltipText = 'TIP'; + +void main() { + testWidgets('Tooltip does not build MouseRegion when mouse is detected and in TooltipVisibility with visibility = false', (WidgetTester tester) async { + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: TooltipVisibility( + visible: false, + child: Tooltip( + message: tooltipText, + child: SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ); + + expect(find.descendant(of: find.byType(Tooltip), matching: find.byType(MouseRegion)), findsNothing); + }); + + testWidgets('Tooltip does not show when hovered when in TooltipVisibility with visible = false', (WidgetTester tester) async { + const Duration waitDuration = Duration.zero; + final TestGesture gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: TooltipVisibility( + visible: false, + child: Tooltip( + message: tooltipText, + waitDuration: waitDuration, + child: SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + // Wait for it to appear. + await tester.pump(waitDuration); + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip shows when hovered when in TooltipVisibility with visible = true', (WidgetTester tester) async { + const Duration waitDuration = Duration.zero; + TestGesture? gesture = await tester.createGesture(kind: PointerDeviceKind.mouse); + addTearDown(() async { + if (gesture != null) + return gesture.removePointer(); + }); + await gesture.addPointer(); + await gesture.moveTo(const Offset(1.0, 1.0)); + await tester.pump(); + await gesture.moveTo(Offset.zero); + + await tester.pumpWidget( + const MaterialApp( + home: Center( + child: TooltipVisibility( + visible: true, + child: Tooltip( + message: tooltipText, + waitDuration: waitDuration, + child: SizedBox( + width: 100.0, + height: 100.0, + ), + ), + ), + ), + ), + ); + + final Finder tooltip = find.byType(Tooltip); + await gesture.moveTo(Offset.zero); + await tester.pump(); + await gesture.moveTo(tester.getCenter(tooltip)); + await tester.pump(); + // Wait for it to appear. + await tester.pump(waitDuration); + expect(find.text(tooltipText), findsOneWidget); + + // Wait for it to disappear. + await gesture.moveTo(Offset.zero); + await tester.pumpAndSettle(); + await gesture.removePointer(); + gesture = null; + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip does not build GestureDetector when in TooltipVisibility with visibility = false', (WidgetTester tester) async { + await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, false); + + expect(find.byType(GestureDetector), findsNothing); + }); + + testWidgets('Tooltip triggers on tap when trigger mode is tap and in TooltipVisibility with visible = true', (WidgetTester tester) async { + await setWidgetForTooltipMode(tester, TooltipTriggerMode.tap, true); + + final Finder tooltip = find.byType(Tooltip); + expect(find.text(tooltipText), findsNothing); + + await testGestureTap(tester, tooltip); + expect(find.text(tooltipText), findsOneWidget); + }); + + testWidgets('Tooltip does not trigger manually when in TooltipVisibility with visible = false', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: TooltipVisibility( + visible: false, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ); + + _ensureTooltipVisible(key); + await tester.pump(); + expect(find.text(tooltipText), findsNothing); + }); + + testWidgets('Tooltip triggers manually when in TooltipVisibility with visible = true', (WidgetTester tester) async { + final GlobalKey key = GlobalKey(); + await tester.pumpWidget( + MaterialApp( + home: TooltipVisibility( + visible: true, + child: Tooltip( + key: key, + message: tooltipText, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ); + + _ensureTooltipVisible(key); + await tester.pump(); + expect(find.text(tooltipText), findsOneWidget); + }); +} + +Future setWidgetForTooltipMode(WidgetTester tester, TooltipTriggerMode triggerMode, bool visibility) async { + await tester.pumpWidget( + MaterialApp( + home: TooltipVisibility( + visible: visibility, + child: Tooltip( + message: tooltipText, + triggerMode: triggerMode, + child: const SizedBox(width: 100.0, height: 100.0), + ), + ), + ), + ); +} + +Future testGestureTap(WidgetTester tester, Finder tooltip) async { + await tester.tap(tooltip); + await tester.pump(const Duration(milliseconds: 10)); +}