From 2f110cc7e587cebb22bf6898e7b33d13c843ce21 Mon Sep 17 00:00:00 2001 From: Renzo Olivares Date: Thu, 16 Oct 2025 19:07:39 -0700 Subject: [PATCH] Tapping outside of `SelectableRegion` should dismiss the selection (#176843) This PR updates selectable region so it dismisses the selection when a tap happens outside of it. This is done through the use of `TapRegion` and it's `onTapOutside` callback. https://github.com/user-attachments/assets/d5678c85-9d04-4c9c-af16-9a09a001c228 ## Pre-launch Checklist - [x] I read the [Contributor Guide] and followed the process outlined there for submitting PRs. - [x] I read the [Tree Hygiene] wiki page, which explains my responsibilities. - [x] I read and followed the [Flutter Style Guide], including [Features we expect every widget to implement]. - [x] I signed the [CLA]. - [x] I listed at least one issue that this PR fixes in the description above. - [x] I updated/added relevant documentation (doc comments with `///`). - [x] I added new tests to check the change I am making, or this PR is [test-exempt]. - [x] I followed the [breaking change policy] and added [Data Driven Fixes] where supported. - [x] All existing and new tests are passing. --------- Co-authored-by: Renzo Olivares --- .../lib/src/widgets/selectable_region.dart | 39 +++++++---- .../lib/src/widgets/text_selection.dart | 34 ++++++---- .../test/widgets/selectable_region_test.dart | 65 +++++++++++++++++-- 3 files changed, 108 insertions(+), 30 deletions(-) diff --git a/packages/flutter/lib/src/widgets/selectable_region.dart b/packages/flutter/lib/src/widgets/selectable_region.dart index d57adf8c610..0a33ee8e2dc 100644 --- a/packages/flutter/lib/src/widgets/selectable_region.dart +++ b/packages/flutter/lib/src/widgets/selectable_region.dart @@ -32,6 +32,7 @@ import 'media_query.dart'; import 'overlay.dart'; import 'platform_selectable_region_context_menu.dart'; import 'selection_container.dart'; +import 'tap_region.dart'; import 'text_editing_intents.dart'; import 'text_selection.dart'; import 'text_selection_toolbar_anchors.dart'; @@ -1937,18 +1938,32 @@ class SelectableRegionState extends State if (_webContextMenuEnabled) { result = PlatformSelectableRegionContextMenu(child: result); } - return CompositedTransformTarget( - link: _toolbarLayerLink, - child: RawGestureDetector( - gestures: _gestureRecognizers, - behavior: HitTestBehavior.translucent, - excludeFromSemantics: true, - child: Actions( - actions: _actions, - child: Focus.withExternalFocusNode( - includeSemantics: false, - focusNode: _focusNode, - child: result, + return TapRegion( + groupId: SelectableRegion, + onTapOutside: (PointerDownEvent event) { + // To match the native web behavior, this selectable region is + // unfocused when tapping outside of it causing the selection to + // be dismissed. + // + // Tapping outside the selectable region does not unfocus + // the region on non-web platforms. + if (kIsWeb) { + _focusNode.unfocus(); + } + }, + child: CompositedTransformTarget( + link: _toolbarLayerLink, + child: RawGestureDetector( + gestures: _gestureRecognizers, + behavior: HitTestBehavior.translucent, + excludeFromSemantics: true, + child: Actions( + actions: _actions, + child: Focus.withExternalFocusNode( + includeSemantics: false, + focusNode: _focusNode, + child: result, + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index f26ce5d663c..d2043056ad5 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -29,6 +29,7 @@ import 'inherited_theme.dart'; import 'magnifier.dart'; import 'overlay.dart'; import 'scrollable.dart'; +import 'selectable_region.dart'; import 'tap_region.dart'; import 'ticker_provider.dart'; import 'transitions.dart'; @@ -1777,7 +1778,10 @@ class SelectionOverlay { dragStartBehavior: dragStartBehavior, ); } - return TextFieldTapRegion(child: ExcludeSemantics(child: handle)); + return TapRegion( + groupId: SelectableRegion, + child: TextFieldTapRegion(child: ExcludeSemantics(child: handle)), + ); } Widget _buildEndHandle(BuildContext context) { @@ -1805,7 +1809,10 @@ class SelectionOverlay { dragStartBehavior: dragStartBehavior, ); } - return TextFieldTapRegion(child: ExcludeSemantics(child: handle)); + return TapRegion( + groupId: SelectableRegion, + child: TextFieldTapRegion(child: ExcludeSemantics(child: handle)), + ); } // Build the toolbar via TextSelectionControls. @@ -1946,16 +1953,19 @@ class _SelectionToolbarWrapperState extends State<_SelectionToolbarWrapper> @override Widget build(BuildContext context) { - return TextFieldTapRegion( - child: Directionality( - textDirection: Directionality.of(this.context), - child: FadeTransition( - opacity: _opacity, - child: CompositedTransformFollower( - link: widget.layerLink, - showWhenUnlinked: false, - offset: widget.offset, - child: widget.child, + return TapRegion( + groupId: SelectableRegion, + child: TextFieldTapRegion( + child: Directionality( + textDirection: Directionality.of(this.context), + child: FadeTransition( + opacity: _opacity, + child: CompositedTransformFollower( + link: widget.layerLink, + showWhenUnlinked: false, + offset: widget.offset, + child: widget.child, + ), ), ), ), diff --git a/packages/flutter/test/widgets/selectable_region_test.dart b/packages/flutter/test/widgets/selectable_region_test.dart index d122fbdaceb..bf5e4621a0c 100644 --- a/packages/flutter/test/widgets/selectable_region_test.dart +++ b/packages/flutter/test/widgets/selectable_region_test.dart @@ -45,6 +45,15 @@ void main() { ); }); + Future setAppLifecycleState(AppLifecycleState state) async { + final ByteData? message = const StringCodec().encodeMessage(state.toString()); + await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger.handlePlatformMessage( + 'flutter/lifecycle', + message, + (ByteData? data) {}, + ); + } + group('SelectableRegion', () { testWidgets('mouse selection single click sends correct events', (WidgetTester tester) async { final UniqueKey spy = UniqueKey(); @@ -237,6 +246,56 @@ void main() { ); }); + testWidgets( + 'tapping outside the selectable region dismisses selection', + (WidgetTester tester) async { + const String text = 'Hello world'; + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SelectableRegion( + selectionControls: materialTextSelectionControls, + child: const Text(text), + ), + ), + ), + ), + ); + // The selection only dismisses when unfocused if the app + // was currently active. + await setAppLifecycleState(AppLifecycleState.resumed); + await tester.pumpAndSettle(); + + final RenderParagraph paragraph = tester.renderObject( + find.descendant(of: find.text(text), matching: find.byType(RichText)), + ); + + // Drag to select. + final Offset textTopLeft = tester.getTopLeft(find.text(text)); + final Offset textBottomRight = tester.getBottomRight(find.text(text)); + final TestGesture gesture = await tester.startGesture( + textTopLeft, + kind: PointerDeviceKind.mouse, + ); + addTearDown(gesture.removePointer); + await gesture.moveTo(textBottomRight); + await gesture.up(); + await tester.pump(); + + expect(paragraph.selections, isNotEmpty); + + // Tap just outside the top-left corner of the selectable region + // to dismiss the selection. + final Rect selectableRegionRect = tester.getRect(find.byType(SelectableRegion)); + await tester.tapAt(selectableRegionRect.topLeft - const Offset(10.0, 10.0)); + await tester.pump(); + expect(paragraph.selections, isEmpty); + }, + // [intended] Tap outside to dismiss the selection is only supported on web. + skip: !kIsWeb, + ); + testWidgets('does not merge semantics node of the children', (WidgetTester tester) async { final SemanticsTester semantics = SemanticsTester(tester); @@ -942,12 +1001,6 @@ void main() { testWidgets( 'selection is not cleared when app loses focus on desktop', (WidgetTester tester) async { - Future setAppLifecycleState(AppLifecycleState state) async { - final ByteData? message = const StringCodec().encodeMessage(state.toString()); - await TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger - .handlePlatformMessage('flutter/lifecycle', message, (_) {}); - } - final FocusNode focusNode = FocusNode(); final GlobalKey selectableKey = GlobalKey(); addTearDown(focusNode.dispose);