diff --git a/packages/flutter/lib/src/material/tooltip.dart b/packages/flutter/lib/src/material/tooltip.dart index ca031fdae5a4..ff73b261d053 100644 --- a/packages/flutter/lib/src/material/tooltip.dart +++ b/packages/flutter/lib/src/material/tooltip.dart @@ -12,6 +12,7 @@ import 'package:flutter/widgets.dart'; import 'colors.dart'; import 'feedback.dart'; +import 'text_theme.dart'; import 'theme.dart'; import 'tooltip_theme.dart'; import 'tooltip_visibility.dart'; @@ -388,23 +389,17 @@ class TooltipState extends State with SingleTickerProviderStateMixin { static const bool _defaultEnableFeedback = true; static const TextAlign _defaultTextAlign = TextAlign.start; - late double _height; - late EdgeInsetsGeometry _padding; - late EdgeInsetsGeometry _margin; - late Decoration _decoration; - late TextStyle _textStyle; - late TextAlign _textAlign; - late double _verticalOffset; - late bool _preferBelow; - late bool _excludeFromSemantics; - OverlayEntry? _entry; - - late Duration _showDuration; - late Duration _hoverShowDuration; - late Duration _waitDuration; - late TooltipTriggerMode _triggerMode; - late bool _enableFeedback; + final OverlayPortalController _overlayController = OverlayPortalController(); + + // From InheritedWidgets late bool _visible; + late TooltipThemeData _tooltipTheme; + + Duration get _showDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultShowDuration; + Duration get _hoverShowDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultHoverShowDuration; + Duration get _waitDuration => widget.waitDuration ?? _tooltipTheme.waitDuration ?? _defaultWaitDuration; + TooltipTriggerMode get _triggerMode => widget.triggerMode ?? _tooltipTheme.triggerMode ?? _defaultTriggerMode; + bool get _enableFeedback => widget.enableFeedback ?? _tooltipTheme.enableFeedback ?? _defaultEnableFeedback; /// The plain text message for this tooltip. /// @@ -438,14 +433,16 @@ class TooltipState extends State with SingleTickerProviderStateMixin { case AnimationStatus.dismissed: entryNeedsUpdating = _animationStatus != AnimationStatus.dismissed; if (entryNeedsUpdating) { - _removeEntry(); + Tooltip._openedTooltips.remove(this); + _overlayController.hide(); } case AnimationStatus.completed: case AnimationStatus.forward: case AnimationStatus.reverse: entryNeedsUpdating = _animationStatus == AnimationStatus.dismissed; if (entryNeedsUpdating) { - _createNewEntry(); + _overlayController.show(); + Tooltip._openedTooltips.add(this); SemanticsService.tooltip(_tooltipMessage); } } @@ -620,11 +617,6 @@ class TooltipState extends State with SingleTickerProviderStateMixin { // (even these tooltips are still hovered), // iii. The last hovering device leaves the tooltip. void _handleMouseEnter(PointerEnterEvent event) { - // The callback is also used in an OverlayEntry, so there's a chance that - // this widget is already unmounted. - if (!mounted) { - return; - } // _handleMouseEnter is only called when the mouse starts to hover over this // tooltip (including the actual tooltip it shows on the overlay), and this // tooltip is the first to be hit in the widget tree's hit testing order. @@ -646,7 +638,7 @@ class TooltipState extends State with SingleTickerProviderStateMixin { } void _handleMouseExit(PointerExitEvent event) { - if (!mounted || _activeHoveringPointerDevices.isEmpty) { + if (_activeHoveringPointerDevices.isEmpty) { return; } _activeHoveringPointerDevices.remove(event.device); @@ -694,6 +686,7 @@ class TooltipState extends State with SingleTickerProviderStateMixin { void didChangeDependencies() { super.didChangeDependencies(); _visible = TooltipVisibility.of(context); + _tooltipTheme = TooltipTheme.of(context); } // https://material.io/components/tooltips#specs @@ -719,8 +712,8 @@ class TooltipState extends State with SingleTickerProviderStateMixin { }; } - double _getDefaultFontSize() { - return switch (Theme.of(context).platform) { + static double _getDefaultFontSize(TargetPlatform platform) { + return switch (platform) { TargetPlatform.macOS || TargetPlatform.linux || TargetPlatform.windows => 12.0, @@ -730,58 +723,50 @@ class TooltipState extends State with SingleTickerProviderStateMixin { }; } - void _createNewEntry() { - final OverlayState overlayState = Overlay.of( - context, - debugRequiredFor: widget, - ); - - final RenderBox box = context.findRenderObject()! as RenderBox; + Widget _buildTooltipOverlay(BuildContext context) { + final OverlayState overlayState = Overlay.of(context, debugRequiredFor: widget); + final RenderBox box = this.context.findRenderObject()! as RenderBox; final Offset target = box.localToGlobal( box.size.center(Offset.zero), ancestor: overlayState.context.findRenderObject(), ); - // We create this widget outside of the overlay entry's builder to prevent - // updated values from happening to leak into the overlay when the overlay - // rebuilds. - final Widget overlay = Directionality( - textDirection: Directionality.of(context), - child: _TooltipOverlay( - richMessage: widget.richMessage ?? TextSpan(text: widget.message), - height: _height, - padding: _padding, - margin: _margin, - onEnter: _handleMouseEnter, - onExit: _handleMouseExit, - decoration: _decoration, - textStyle: _textStyle, - textAlign: _textAlign, - animation: CurvedAnimation( - parent: _controller, - curve: Curves.fastOutSlowIn, - ), - target: target, - verticalOffset: _verticalOffset, - preferBelow: _preferBelow, + final (TextStyle defaultTextStyle, BoxDecoration defaultDecoration) = switch (Theme.of(context)) { + ThemeData(brightness: Brightness.dark, :final TextTheme textTheme, :final TargetPlatform platform) => ( + textTheme.bodyMedium!.copyWith(color: Colors.black, fontSize: _getDefaultFontSize(platform)), + BoxDecoration(color: Colors.white.withOpacity(0.9), borderRadius: const BorderRadius.all(Radius.circular(4))), ), - ); - final OverlayEntry entry = _entry = OverlayEntry(builder: (BuildContext context) => overlay); - overlayState.insert(entry); - Tooltip._openedTooltips.add(this); - } + ThemeData(brightness: Brightness.light, :final TextTheme textTheme, :final TargetPlatform platform) => ( + textTheme.bodyMedium!.copyWith(color: Colors.white, fontSize: _getDefaultFontSize(platform)), + BoxDecoration(color: Colors.grey[700]!.withOpacity(0.9), borderRadius: const BorderRadius.all(Radius.circular(4))), + ), + }; - void _removeEntry() { - Tooltip._openedTooltips.remove(this); - _entry?.remove(); - _entry?.dispose(); - _entry = null; + final TooltipThemeData tooltipTheme = _tooltipTheme; + return _TooltipOverlay( + richMessage: widget.richMessage ?? TextSpan(text: widget.message), + height: widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(), + padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(), + margin: widget.margin ?? tooltipTheme.margin ?? _defaultMargin, + onEnter: _handleMouseEnter, + onExit: _handleMouseExit, + decoration: widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration, + textStyle: widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle, + textAlign: widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign, + animation: CurvedAnimation( + parent: _controller, + curve: Curves.fastOutSlowIn, + ), + target: target, + verticalOffset: widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset, + preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow, + ); } @override void dispose() { GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent); - _removeEntry(); + Tooltip._openedTooltips.remove(this); _longPressRecognizer?.dispose(); _tapRecognizer?.dispose(); _timer?.cancel(); @@ -798,47 +783,9 @@ class TooltipState extends State with SingleTickerProviderStateMixin { return widget.child ?? const SizedBox.shrink(); } assert(debugCheckHasOverlay(context)); - final ThemeData theme = Theme.of(context); - final TooltipThemeData tooltipTheme = TooltipTheme.of(context); - final TextStyle defaultTextStyle; - final BoxDecoration defaultDecoration; - if (theme.brightness == Brightness.dark) { - defaultTextStyle = theme.textTheme.bodyMedium!.copyWith( - color: Colors.black, - fontSize: _getDefaultFontSize(), - ); - defaultDecoration = BoxDecoration( - color: Colors.white.withOpacity(0.9), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ); - } else { - defaultTextStyle = theme.textTheme.bodyMedium!.copyWith( - color: Colors.white, - fontSize: _getDefaultFontSize(), - ); - defaultDecoration = BoxDecoration( - color: Colors.grey[700]!.withOpacity(0.9), - borderRadius: const BorderRadius.all(Radius.circular(4)), - ); - } - - _height = widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(); - _padding = widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(); - _margin = widget.margin ?? tooltipTheme.margin ?? _defaultMargin; - _verticalOffset = widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset; - _preferBelow = widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow; - _excludeFromSemantics = widget.excludeFromSemantics ?? tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics; - _decoration = widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration; - _textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle; - _textAlign = widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign; - _waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration; - _showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration; - _hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration; - _triggerMode = widget.triggerMode ?? tooltipTheme.triggerMode ?? _defaultTriggerMode; - _enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback; - + final bool excludeFromSemantics = widget.excludeFromSemantics ?? _tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics; Widget result = Semantics( - tooltip: _excludeFromSemantics ? null : _tooltipMessage, + tooltip: excludeFromSemantics ? null : _tooltipMessage, child: widget.child, ); @@ -854,8 +801,11 @@ class TooltipState extends State with SingleTickerProviderStateMixin { ), ); } - - return result; + return OverlayPortal( + controller: _overlayController, + overlayChildBuilder: _buildTooltipOverlay, + child: result, + ); } } diff --git a/packages/flutter/test/material/tooltip_test.dart b/packages/flutter/test/material/tooltip_test.dart index 0cbd9b50d416..009f9a0cdbc5 100644 --- a/packages/flutter/test/material/tooltip_test.dart +++ b/packages/flutter/test/material/tooltip_test.dart @@ -1041,15 +1041,9 @@ void main() { ), ); - // The tooltip overlay still on the tree and it will removed in the next frame. - - // Dispatch the mouse in and out events before the overlay detached. - await gesture.moveTo(tester.getCenter(find.text(tooltipText))); - await gesture.moveTo(Offset.zero); - await tester.pumpAndSettle(); - - // Go without crashes. - await gesture.removePointer(); + // The tooltip should be removed, including the overlay child. + expect(find.text(tooltipText), findsNothing); + expect(find.byTooltip(tooltipText), findsNothing); }); testWidgetsWithLeakTracking('Calling ensureTooltipVisible on an unmounted TooltipState returns false', (WidgetTester tester) async { @@ -1435,35 +1429,6 @@ void main() { semantics.dispose(); }); - testWidgetsWithLeakTracking('Tooltip overlay does not update', (WidgetTester tester) async { - Widget buildApp(String text) { - return MaterialApp( - home: Center( - child: Tooltip( - message: text, - child: Container( - width: 100.0, - height: 100.0, - color: Colors.green[500], - ), - ), - ), - ); - } - - await tester.pumpWidget(buildApp(tooltipText)); - await tester.longPress(find.byType(Tooltip)); - expect(find.text(tooltipText), findsOneWidget); - await tester.pumpWidget(buildApp('NEW')); - expect(find.text(tooltipText), findsOneWidget); - await tester.tapAt(const Offset(5.0, 5.0)); - await tester.pump(); - await tester.pump(const Duration(seconds: 1)); - expect(find.text(tooltipText), findsNothing); - await tester.longPress(find.byType(Tooltip)); - expect(find.text(tooltipText), findsNothing); - }); - testWidgetsWithLeakTracking('Tooltip text scales with textScaleFactor', (WidgetTester tester) async { Widget buildApp(String text, { required double textScaleFactor }) { return MediaQuery(