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 <roliv@google.com>
This commit is contained in:
Renzo Olivares 2025-10-16 19:07:39 -07:00 committed by GitHub
parent 5187246256
commit 2f110cc7e5
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 108 additions and 30 deletions

View File

@ -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<SelectableRegion>
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,
),
),
),
),

View File

@ -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,
),
),
),
),

View File

@ -45,6 +45,15 @@ void main() {
);
});
Future<void> 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<RenderParagraph>(
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<void> 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);