Skip to content

Commit e1fdb1a

Browse files
migrate Tooltip to use OverlayPortal (#127728)
flutter/flutter#7151 isn't a problem with OverlayPortal so the test is removed. Also removed some `mounted` checks since they're no longer needed.
1 parent 9e8143a commit e1fdb1a

2 files changed

Lines changed: 61 additions & 146 deletions

File tree

packages/flutter/lib/src/material/tooltip.dart

Lines changed: 58 additions & 108 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import 'package:flutter/widgets.dart';
1212

1313
import 'colors.dart';
1414
import 'feedback.dart';
15+
import 'text_theme.dart';
1516
import 'theme.dart';
1617
import 'tooltip_theme.dart';
1718
import 'tooltip_visibility.dart';
@@ -388,23 +389,17 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
388389
static const bool _defaultEnableFeedback = true;
389390
static const TextAlign _defaultTextAlign = TextAlign.start;
390391

391-
late double _height;
392-
late EdgeInsetsGeometry _padding;
393-
late EdgeInsetsGeometry _margin;
394-
late Decoration _decoration;
395-
late TextStyle _textStyle;
396-
late TextAlign _textAlign;
397-
late double _verticalOffset;
398-
late bool _preferBelow;
399-
late bool _excludeFromSemantics;
400-
OverlayEntry? _entry;
401-
402-
late Duration _showDuration;
403-
late Duration _hoverShowDuration;
404-
late Duration _waitDuration;
405-
late TooltipTriggerMode _triggerMode;
406-
late bool _enableFeedback;
392+
final OverlayPortalController _overlayController = OverlayPortalController();
393+
394+
// From InheritedWidgets
407395
late bool _visible;
396+
late TooltipThemeData _tooltipTheme;
397+
398+
Duration get _showDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultShowDuration;
399+
Duration get _hoverShowDuration => widget.showDuration ?? _tooltipTheme.showDuration ?? _defaultHoverShowDuration;
400+
Duration get _waitDuration => widget.waitDuration ?? _tooltipTheme.waitDuration ?? _defaultWaitDuration;
401+
TooltipTriggerMode get _triggerMode => widget.triggerMode ?? _tooltipTheme.triggerMode ?? _defaultTriggerMode;
402+
bool get _enableFeedback => widget.enableFeedback ?? _tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
408403

409404
/// The plain text message for this tooltip.
410405
///
@@ -438,14 +433,16 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
438433
case AnimationStatus.dismissed:
439434
entryNeedsUpdating = _animationStatus != AnimationStatus.dismissed;
440435
if (entryNeedsUpdating) {
441-
_removeEntry();
436+
Tooltip._openedTooltips.remove(this);
437+
_overlayController.hide();
442438
}
443439
case AnimationStatus.completed:
444440
case AnimationStatus.forward:
445441
case AnimationStatus.reverse:
446442
entryNeedsUpdating = _animationStatus == AnimationStatus.dismissed;
447443
if (entryNeedsUpdating) {
448-
_createNewEntry();
444+
_overlayController.show();
445+
Tooltip._openedTooltips.add(this);
449446
SemanticsService.tooltip(_tooltipMessage);
450447
}
451448
}
@@ -620,11 +617,6 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
620617
// (even these tooltips are still hovered),
621618
// iii. The last hovering device leaves the tooltip.
622619
void _handleMouseEnter(PointerEnterEvent event) {
623-
// The callback is also used in an OverlayEntry, so there's a chance that
624-
// this widget is already unmounted.
625-
if (!mounted) {
626-
return;
627-
}
628620
// _handleMouseEnter is only called when the mouse starts to hover over this
629621
// tooltip (including the actual tooltip it shows on the overlay), and this
630622
// tooltip is the first to be hit in the widget tree's hit testing order.
@@ -646,7 +638,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
646638
}
647639

648640
void _handleMouseExit(PointerExitEvent event) {
649-
if (!mounted || _activeHoveringPointerDevices.isEmpty) {
641+
if (_activeHoveringPointerDevices.isEmpty) {
650642
return;
651643
}
652644
_activeHoveringPointerDevices.remove(event.device);
@@ -694,6 +686,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
694686
void didChangeDependencies() {
695687
super.didChangeDependencies();
696688
_visible = TooltipVisibility.of(context);
689+
_tooltipTheme = TooltipTheme.of(context);
697690
}
698691

699692
// https://material.io/components/tooltips#specs
@@ -719,8 +712,8 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
719712
};
720713
}
721714

722-
double _getDefaultFontSize() {
723-
return switch (Theme.of(context).platform) {
715+
static double _getDefaultFontSize(TargetPlatform platform) {
716+
return switch (platform) {
724717
TargetPlatform.macOS ||
725718
TargetPlatform.linux ||
726719
TargetPlatform.windows => 12.0,
@@ -730,58 +723,50 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
730723
};
731724
}
732725

733-
void _createNewEntry() {
734-
final OverlayState overlayState = Overlay.of(
735-
context,
736-
debugRequiredFor: widget,
737-
);
738-
739-
final RenderBox box = context.findRenderObject()! as RenderBox;
726+
Widget _buildTooltipOverlay(BuildContext context) {
727+
final OverlayState overlayState = Overlay.of(context, debugRequiredFor: widget);
728+
final RenderBox box = this.context.findRenderObject()! as RenderBox;
740729
final Offset target = box.localToGlobal(
741730
box.size.center(Offset.zero),
742731
ancestor: overlayState.context.findRenderObject(),
743732
);
744733

745-
// We create this widget outside of the overlay entry's builder to prevent
746-
// updated values from happening to leak into the overlay when the overlay
747-
// rebuilds.
748-
final Widget overlay = Directionality(
749-
textDirection: Directionality.of(context),
750-
child: _TooltipOverlay(
751-
richMessage: widget.richMessage ?? TextSpan(text: widget.message),
752-
height: _height,
753-
padding: _padding,
754-
margin: _margin,
755-
onEnter: _handleMouseEnter,
756-
onExit: _handleMouseExit,
757-
decoration: _decoration,
758-
textStyle: _textStyle,
759-
textAlign: _textAlign,
760-
animation: CurvedAnimation(
761-
parent: _controller,
762-
curve: Curves.fastOutSlowIn,
763-
),
764-
target: target,
765-
verticalOffset: _verticalOffset,
766-
preferBelow: _preferBelow,
734+
final (TextStyle defaultTextStyle, BoxDecoration defaultDecoration) = switch (Theme.of(context)) {
735+
ThemeData(brightness: Brightness.dark, :final TextTheme textTheme, :final TargetPlatform platform) => (
736+
textTheme.bodyMedium!.copyWith(color: Colors.black, fontSize: _getDefaultFontSize(platform)),
737+
BoxDecoration(color: Colors.white.withOpacity(0.9), borderRadius: const BorderRadius.all(Radius.circular(4))),
767738
),
768-
);
769-
final OverlayEntry entry = _entry = OverlayEntry(builder: (BuildContext context) => overlay);
770-
overlayState.insert(entry);
771-
Tooltip._openedTooltips.add(this);
772-
}
739+
ThemeData(brightness: Brightness.light, :final TextTheme textTheme, :final TargetPlatform platform) => (
740+
textTheme.bodyMedium!.copyWith(color: Colors.white, fontSize: _getDefaultFontSize(platform)),
741+
BoxDecoration(color: Colors.grey[700]!.withOpacity(0.9), borderRadius: const BorderRadius.all(Radius.circular(4))),
742+
),
743+
};
773744

774-
void _removeEntry() {
775-
Tooltip._openedTooltips.remove(this);
776-
_entry?.remove();
777-
_entry?.dispose();
778-
_entry = null;
745+
final TooltipThemeData tooltipTheme = _tooltipTheme;
746+
return _TooltipOverlay(
747+
richMessage: widget.richMessage ?? TextSpan(text: widget.message),
748+
height: widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(),
749+
padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(),
750+
margin: widget.margin ?? tooltipTheme.margin ?? _defaultMargin,
751+
onEnter: _handleMouseEnter,
752+
onExit: _handleMouseExit,
753+
decoration: widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration,
754+
textStyle: widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle,
755+
textAlign: widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign,
756+
animation: CurvedAnimation(
757+
parent: _controller,
758+
curve: Curves.fastOutSlowIn,
759+
),
760+
target: target,
761+
verticalOffset: widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset,
762+
preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow,
763+
);
779764
}
780765

781766
@override
782767
void dispose() {
783768
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent);
784-
_removeEntry();
769+
Tooltip._openedTooltips.remove(this);
785770
_longPressRecognizer?.dispose();
786771
_tapRecognizer?.dispose();
787772
_timer?.cancel();
@@ -798,47 +783,9 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
798783
return widget.child ?? const SizedBox.shrink();
799784
}
800785
assert(debugCheckHasOverlay(context));
801-
final ThemeData theme = Theme.of(context);
802-
final TooltipThemeData tooltipTheme = TooltipTheme.of(context);
803-
final TextStyle defaultTextStyle;
804-
final BoxDecoration defaultDecoration;
805-
if (theme.brightness == Brightness.dark) {
806-
defaultTextStyle = theme.textTheme.bodyMedium!.copyWith(
807-
color: Colors.black,
808-
fontSize: _getDefaultFontSize(),
809-
);
810-
defaultDecoration = BoxDecoration(
811-
color: Colors.white.withOpacity(0.9),
812-
borderRadius: const BorderRadius.all(Radius.circular(4)),
813-
);
814-
} else {
815-
defaultTextStyle = theme.textTheme.bodyMedium!.copyWith(
816-
color: Colors.white,
817-
fontSize: _getDefaultFontSize(),
818-
);
819-
defaultDecoration = BoxDecoration(
820-
color: Colors.grey[700]!.withOpacity(0.9),
821-
borderRadius: const BorderRadius.all(Radius.circular(4)),
822-
);
823-
}
824-
825-
_height = widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight();
826-
_padding = widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding();
827-
_margin = widget.margin ?? tooltipTheme.margin ?? _defaultMargin;
828-
_verticalOffset = widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset;
829-
_preferBelow = widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow;
830-
_excludeFromSemantics = widget.excludeFromSemantics ?? tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics;
831-
_decoration = widget.decoration ?? tooltipTheme.decoration ?? defaultDecoration;
832-
_textStyle = widget.textStyle ?? tooltipTheme.textStyle ?? defaultTextStyle;
833-
_textAlign = widget.textAlign ?? tooltipTheme.textAlign ?? _defaultTextAlign;
834-
_waitDuration = widget.waitDuration ?? tooltipTheme.waitDuration ?? _defaultWaitDuration;
835-
_showDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultShowDuration;
836-
_hoverShowDuration = widget.showDuration ?? tooltipTheme.showDuration ?? _defaultHoverShowDuration;
837-
_triggerMode = widget.triggerMode ?? tooltipTheme.triggerMode ?? _defaultTriggerMode;
838-
_enableFeedback = widget.enableFeedback ?? tooltipTheme.enableFeedback ?? _defaultEnableFeedback;
839-
786+
final bool excludeFromSemantics = widget.excludeFromSemantics ?? _tooltipTheme.excludeFromSemantics ?? _defaultExcludeFromSemantics;
840787
Widget result = Semantics(
841-
tooltip: _excludeFromSemantics ? null : _tooltipMessage,
788+
tooltip: excludeFromSemantics ? null : _tooltipMessage,
842789
child: widget.child,
843790
);
844791

@@ -854,8 +801,11 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
854801
),
855802
);
856803
}
857-
858-
return result;
804+
return OverlayPortal(
805+
controller: _overlayController,
806+
overlayChildBuilder: _buildTooltipOverlay,
807+
child: result,
808+
);
859809
}
860810
}
861811

packages/flutter/test/material/tooltip_test.dart

Lines changed: 3 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -1041,15 +1041,9 @@ void main() {
10411041
),
10421042
);
10431043

1044-
// The tooltip overlay still on the tree and it will removed in the next frame.
1045-
1046-
// Dispatch the mouse in and out events before the overlay detached.
1047-
await gesture.moveTo(tester.getCenter(find.text(tooltipText)));
1048-
await gesture.moveTo(Offset.zero);
1049-
await tester.pumpAndSettle();
1050-
1051-
// Go without crashes.
1052-
await gesture.removePointer();
1044+
// The tooltip should be removed, including the overlay child.
1045+
expect(find.text(tooltipText), findsNothing);
1046+
expect(find.byTooltip(tooltipText), findsNothing);
10531047
});
10541048

10551049
testWidgetsWithLeakTracking('Calling ensureTooltipVisible on an unmounted TooltipState returns false', (WidgetTester tester) async {
@@ -1435,35 +1429,6 @@ void main() {
14351429
semantics.dispose();
14361430
});
14371431

1438-
testWidgetsWithLeakTracking('Tooltip overlay does not update', (WidgetTester tester) async {
1439-
Widget buildApp(String text) {
1440-
return MaterialApp(
1441-
home: Center(
1442-
child: Tooltip(
1443-
message: text,
1444-
child: Container(
1445-
width: 100.0,
1446-
height: 100.0,
1447-
color: Colors.green[500],
1448-
),
1449-
),
1450-
),
1451-
);
1452-
}
1453-
1454-
await tester.pumpWidget(buildApp(tooltipText));
1455-
await tester.longPress(find.byType(Tooltip));
1456-
expect(find.text(tooltipText), findsOneWidget);
1457-
await tester.pumpWidget(buildApp('NEW'));
1458-
expect(find.text(tooltipText), findsOneWidget);
1459-
await tester.tapAt(const Offset(5.0, 5.0));
1460-
await tester.pump();
1461-
await tester.pump(const Duration(seconds: 1));
1462-
expect(find.text(tooltipText), findsNothing);
1463-
await tester.longPress(find.byType(Tooltip));
1464-
expect(find.text(tooltipText), findsNothing);
1465-
});
1466-
14671432
testWidgetsWithLeakTracking('Tooltip text scales with textScaleFactor', (WidgetTester tester) async {
14681433
Widget buildApp(String text, { required double textScaleFactor }) {
14691434
return MediaQuery(

0 commit comments

Comments
 (0)