Skip to content

Commit 2da353a

Browse files
Exclude Tooltip's overlay child from SelectableRegion (#130181)
Fixes flutter/flutter#129969 by making tooltip text unselectable (for now). Also fixes some other issues uncovered when I was writing the tests. Currently `getTransformTo` only works on ancestors. I'll try to add a new method that computes the transform from 2 arbitrary render objects in the same render tree in a follow-up PR and make `Selectable` use that method instead.
1 parent dd0b6e3 commit 2da353a

File tree

4 files changed

+115
-24
lines changed

4 files changed

+115
-24
lines changed

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

Lines changed: 7 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -103,13 +103,7 @@ class SelectionArea extends StatefulWidget {
103103
}
104104

105105
class _SelectionAreaState extends State<SelectionArea> {
106-
FocusNode get _effectiveFocusNode {
107-
if (widget.focusNode != null) {
108-
return widget.focusNode!;
109-
}
110-
_internalNode ??= FocusNode();
111-
return _internalNode!;
112-
}
106+
FocusNode get _effectiveFocusNode => widget.focusNode ?? (_internalNode ??= FocusNode());
113107
FocusNode? _internalNode;
114108

115109
@override
@@ -121,20 +115,12 @@ class _SelectionAreaState extends State<SelectionArea> {
121115
@override
122116
Widget build(BuildContext context) {
123117
assert(debugCheckHasMaterialLocalizations(context));
124-
TextSelectionControls? controls = widget.selectionControls;
125-
switch (Theme.of(context).platform) {
126-
case TargetPlatform.android:
127-
case TargetPlatform.fuchsia:
128-
controls ??= materialTextSelectionHandleControls;
129-
case TargetPlatform.iOS:
130-
controls ??= cupertinoTextSelectionHandleControls;
131-
case TargetPlatform.linux:
132-
case TargetPlatform.windows:
133-
controls ??= desktopTextSelectionHandleControls;
134-
case TargetPlatform.macOS:
135-
controls ??= cupertinoDesktopTextSelectionHandleControls;
136-
}
137-
118+
final TextSelectionControls controls = widget.selectionControls ?? switch (Theme.of(context).platform) {
119+
TargetPlatform.android || TargetPlatform.fuchsia => materialTextSelectionHandleControls,
120+
TargetPlatform.linux || TargetPlatform.windows => desktopTextSelectionHandleControls,
121+
TargetPlatform.iOS => cupertinoTextSelectionHandleControls,
122+
TargetPlatform.macOS => cupertinoDesktopTextSelectionHandleControls,
123+
};
138124
return SelectableRegion(
139125
selectionControls: controls,
140126
focusNode: _effectiveFocusNode,

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

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -482,13 +482,16 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
482482
void _scheduleDismissTooltip({ required Duration withDelay }) {
483483
assert(mounted);
484484
assert(
485-
!(_timer?.isActive ?? false) || _controller.status != AnimationStatus.reverse,
485+
!(_timer?.isActive ?? false) || _backingController?.status != AnimationStatus.reverse,
486486
'timer must not be active when the tooltip is fading out',
487487
);
488488

489489
_timer?.cancel();
490490
_timer = null;
491-
switch (_controller.status) {
491+
// Use _backingController instead of _controller to prevent the lazy getter
492+
// from instaniating an AnimationController unnecessarily.
493+
switch (_backingController?.status) {
494+
case null:
492495
case AnimationStatus.reverse:
493496
case AnimationStatus.dismissed:
494497
break;
@@ -740,7 +743,7 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
740743
};
741744

742745
final TooltipThemeData tooltipTheme = _tooltipTheme;
743-
return _TooltipOverlay(
746+
final _TooltipOverlay overlayChild = _TooltipOverlay(
744747
richMessage: widget.richMessage ?? TextSpan(text: widget.message),
745748
height: widget.height ?? tooltipTheme.height ?? _getDefaultTooltipHeight(),
746749
padding: widget.padding ?? tooltipTheme.padding ?? _getDefaultPadding(),
@@ -755,13 +758,23 @@ class TooltipState extends State<Tooltip> with SingleTickerProviderStateMixin {
755758
verticalOffset: widget.verticalOffset ?? tooltipTheme.verticalOffset ?? _defaultVerticalOffset,
756759
preferBelow: widget.preferBelow ?? tooltipTheme.preferBelow ?? _defaultPreferBelow,
757760
);
761+
762+
return SelectionContainer.maybeOf(context) == null
763+
? overlayChild
764+
: SelectionContainer.disabled(child: overlayChild);
758765
}
759766

760767
@override
761768
void dispose() {
762769
GestureBinding.instance.pointerRouter.removeGlobalRoute(_handleGlobalPointerEvent);
763770
Tooltip._openedTooltips.remove(this);
771+
// _longPressRecognizer.dispose() and _tapRecognizer.dispose() may call
772+
// their registered onCancel callbacks if there's a gesture in progress.
773+
// Remove the onCancel callbacks to prevent the registered callbacks from
774+
// triggering unnecessary side effects (such as animations).
775+
_longPressRecognizer?.onLongPressCancel = null;
764776
_longPressRecognizer?.dispose();
777+
_tapRecognizer?.onTapCancel = null;
765778
_tapRecognizer?.dispose();
766779
_timer?.cancel();
767780
_backingController?.dispose();

packages/flutter/lib/src/widgets/overlay.dart

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1569,6 +1569,7 @@ class _OverlayPortalState extends State<OverlayPortal> {
15691569

15701570
@override
15711571
void dispose() {
1572+
assert(widget.controller._attachTarget == this);
15721573
widget.controller._attachTarget = null;
15731574
_locationCache?._debugMarkLocationInvalid();
15741575
_locationCache = null;

packages/flutter/test/material/tooltip_test.dart

Lines changed: 91 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2278,6 +2278,97 @@ void main() {
22782278
await tester.pump(const Duration(seconds: 1));
22792279
expect(element.dirty, isFalse);
22802280
});
2281+
2282+
testWidgets('Tooltip does not initialize animation controller in dispose process', (WidgetTester tester) async {
2283+
await tester.pumpWidget(
2284+
const MaterialApp(
2285+
home: Center(
2286+
child: Tooltip(
2287+
message: tooltipText,
2288+
waitDuration: Duration(seconds: 1),
2289+
triggerMode: TooltipTriggerMode.longPress,
2290+
child: SizedBox.square(dimension: 50),
2291+
),
2292+
),
2293+
),
2294+
);
2295+
2296+
await tester.startGesture(tester.getCenter(find.byType(Tooltip)));
2297+
await tester.pumpWidget(const SizedBox());
2298+
expect(tester.takeException(), isNull);
2299+
});
2300+
2301+
testWidgets('Tooltip does not crash when showing the tooltip but the OverlayPortal is unmounted, during dispose', (WidgetTester tester) async {
2302+
await tester.pumpWidget(
2303+
const MaterialApp(
2304+
home: SelectionArea(
2305+
child: Center(
2306+
child: Tooltip(
2307+
message: tooltipText,
2308+
waitDuration: Duration(seconds: 1),
2309+
triggerMode: TooltipTriggerMode.longPress,
2310+
child: SizedBox.square(dimension: 50),
2311+
),
2312+
),
2313+
),
2314+
),
2315+
);
2316+
2317+
final TooltipState tooltipState = tester.state(find.byType(Tooltip));
2318+
await tester.startGesture(tester.getCenter(find.byType(Tooltip)));
2319+
tooltipState.ensureTooltipVisible();
2320+
await tester.pumpWidget(const SizedBox());
2321+
expect(tester.takeException(), isNull);
2322+
});
2323+
2324+
testWidgets('Tooltip is not selectable', (WidgetTester tester) async {
2325+
const String tooltipText = 'AAAAAAAAAAAAAAAAAAAAAAA';
2326+
String? selectedText;
2327+
await tester.pumpWidget(
2328+
MaterialApp(
2329+
home: SelectionArea(
2330+
onSelectionChanged: (SelectedContent? content) { selectedText = content?.plainText; },
2331+
child: const Center(
2332+
child: Column(
2333+
children: <Widget>[
2334+
Text('Select Me'),
2335+
Tooltip(
2336+
message: tooltipText,
2337+
waitDuration: Duration(seconds: 1),
2338+
triggerMode: TooltipTriggerMode.longPress,
2339+
child: SizedBox.square(dimension: 50),
2340+
),
2341+
],
2342+
),
2343+
),
2344+
),
2345+
),
2346+
);
2347+
2348+
final TooltipState tooltipState = tester.state(find.byType(Tooltip));
2349+
2350+
final Rect textRect = tester.getRect(find.text('Select Me'));
2351+
final TestGesture gesture = await tester.startGesture(Alignment.centerLeft.alongSize(textRect.size) + textRect.topLeft);
2352+
// Drag from centerLeft to centerRight to select the text.
2353+
await tester.pump(const Duration(seconds: 1));
2354+
await gesture.moveTo(Alignment.centerRight.alongSize(textRect.size) + textRect.topLeft);
2355+
await tester.pump();
2356+
2357+
tooltipState.ensureTooltipVisible();
2358+
await tester.pump();
2359+
// Make sure the tooltip becomes visible.
2360+
expect(find.text(tooltipText), findsOneWidget);
2361+
assert(selectedText != null);
2362+
2363+
final Rect tooltipTextRect = tester.getRect(find.text(tooltipText));
2364+
// Now drag from centerLeft to centerRight to select the tooltip text.
2365+
await gesture.moveTo(Alignment.centerLeft.alongSize(tooltipTextRect.size) + tooltipTextRect.topLeft);
2366+
await tester.pump();
2367+
await gesture.moveTo(Alignment.centerRight.alongSize(tooltipTextRect.size) + tooltipTextRect.topLeft);
2368+
await tester.pump();
2369+
2370+
expect(selectedText, isNot(contains('A')));
2371+
});
22812372
}
22822373

22832374
Future<void> setWidgetForTooltipMode(

0 commit comments

Comments
 (0)