mirror of
https://github.com/flutter/flutter.git
synced 2026-02-20 02:29:02 +08:00
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:
parent
5187246256
commit
2f110cc7e5
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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,
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
|
||||
@ -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);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user