Add TapOutsideConfiguration widget (#150125)

This PR adds `TapOutsideConfiguration` where you can define a custom default logic for `onTapOutside`.

```dart
TapOutsideConfiguration(
  behavior: AlwaysUnfocusTapOutsideBehavior(),
  // behavior: const NeverUnfocusTapOutsideBehavior(),
  // behavior: const CustomTapOutsideBehavior(),
  child: Column(
    children: [
      // This TextField will use onTapOutside from TapOutsideConfiguration
      TextField(),
      // Of course you can still define onTapOutside
      TextField(onTapOutside: (event) => print('Tapped outside')),
    ],
  ),
)
```

Custom logic can be define like this:

```dart
class CustomTapOutsideBehavior extends TapOutsideBehavior {
  const CustomTapOutsideBehavior();

  @override
  void defaultOnTapOutside(PointerDownEvent event, FocusNode focusNode) {
    // any custom logic here
  }
}
```

This PR implements also two simple `AlwaysUnfocusTapOutsideBehavior` and `NeverUnfocusTapOutsideBehavior`.

Resolves #150123
This commit is contained in:
Jakub Bogacki 2024-10-16 15:25:34 +02:00 committed by GitHub
parent a9ae0b8bb0
commit 279d30a44f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
3 changed files with 245 additions and 168 deletions

View File

@ -1495,12 +1495,13 @@ class EditableText extends StatefulWidget {
/// Called for each tap that occurs outside of the[TextFieldTapRegion] group /// Called for each tap that occurs outside of the[TextFieldTapRegion] group
/// when the text field is focused. /// when the text field is focused.
/// ///
/// If this is null, [FocusNode.unfocus] will be called on the [focusNode] for /// If this is null, [EditableTextTapOutsideIntent] will be invoked. In the
/// this text field when a [PointerDownEvent] is received on another part of /// default implementation, [FocusNode.unfocus] will be called on the
/// the UI. However, it will not unfocus as a result of mobile application /// [focusNode] for this text field when a [PointerDownEvent] is received on
/// touch events (which does not include mouse clicks), to conform with the /// another part of the UI. However, it will not unfocus as a result of mobile
/// platform conventions. To change this behavior, a callback may be set here /// application touch events (which does not include mouse clicks), to conform
/// that operates differently from the default. /// with the platform conventions. To change this behavior, a callback may be
/// set here or [EditableTextTapOutsideIntent] may be overridden.
/// ///
/// When adding additional controls to a text field (for example, a spinner, a /// When adding additional controls to a text field (for example, a spinner, a
/// button that copies the selected text, or modifies formatting), it is /// button that copies the selected text, or modifies formatting), it is
@ -1529,6 +1530,8 @@ class EditableText extends StatefulWidget {
/// See also: /// See also:
/// ///
/// * [TapRegion] for how the region group is determined. /// * [TapRegion] for how the region group is determined.
/// * [EditableTextTapOutsideIntent] for the intent that is invoked if
/// this is null.
final TapRegionCallback? onTapOutside; final TapRegionCallback? onTapOutside;
/// {@template flutter.widgets.editableText.inputFormatters} /// {@template flutter.widgets.editableText.inputFormatters}
@ -5104,33 +5107,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
/// The default behavior used if [EditableText.onTapOutside] is null. /// The default behavior used if [EditableText.onTapOutside] is null.
/// ///
/// The `event` argument is the [PointerDownEvent] that caused the notification. /// The `event` argument is the [PointerDownEvent] that caused the notification.
void _defaultOnTapOutside(PointerDownEvent event) { void _defaultOnTapOutside(BuildContext context, PointerDownEvent event) {
/// The focus dropping behavior is only present on desktop platforms Actions.invoke(context, EditableTextTapOutsideIntent(focusNode: widget.focusNode, pointerDownEvent: event));
/// and mobile browsers.
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
// On mobile platforms, we don't unfocus on touch events unless they're
// in the web browser, but we do unfocus for all other kinds of events.
switch (event.kind) {
case ui.PointerDeviceKind.touch:
if (kIsWeb) {
widget.focusNode.unfocus();
}
case ui.PointerDeviceKind.mouse:
case ui.PointerDeviceKind.stylus:
case ui.PointerDeviceKind.invertedStylus:
case ui.PointerDeviceKind.unknown:
widget.focusNode.unfocus();
case ui.PointerDeviceKind.trackpad:
throw UnimplementedError('Unexpected pointer down event for trackpad');
}
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
widget.focusNode.unfocus();
}
} }
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{ late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
@ -5169,6 +5147,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))), PasteTextIntent: _makeOverridable(CallbackAction<PasteTextIntent>(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))),
TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction), TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction),
EditableTextTapOutsideIntent: _makeOverridable(_EditableTextTapOutsideAction()),
}; };
@override @override
@ -5186,14 +5165,16 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return _CompositionCallback( return _CompositionCallback(
compositeCallback: _compositeCallback, compositeCallback: _compositeCallback,
enabled: _hasInputConnection, enabled: _hasInputConnection,
child: TextFieldTapRegion( child: Actions(
actions: _actions,
child: Builder(
builder: (BuildContext context) {
return TextFieldTapRegion(
groupId: widget.groupId, groupId: widget.groupId,
onTapOutside: _hasFocus ? widget.onTapOutside ?? _defaultOnTapOutside : null, onTapOutside: _hasFocus ? widget.onTapOutside ?? (PointerDownEvent event) => _defaultOnTapOutside(context, event) : null,
debugLabel: kReleaseMode ? null : 'EditableText', debugLabel: kReleaseMode ? null : 'EditableText',
child: MouseRegion( child: MouseRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text, cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions(
actions: _actions,
child: UndoHistory<TextEditingValue>( child: UndoHistory<TextEditingValue>(
value: widget.controller, value: widget.controller,
onTriggered: (TextEditingValue value) { onTriggered: (TextEditingValue value) {
@ -5330,6 +5311,8 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
), ),
), ),
), ),
);
}
), ),
), ),
); );
@ -6083,3 +6066,37 @@ class _WebClipboardStatusNotifier extends ClipboardStatusNotifier {
return Future<void>.value(); return Future<void>.value();
} }
} }
class _EditableTextTapOutsideAction extends ContextAction<EditableTextTapOutsideIntent> {
_EditableTextTapOutsideAction();
@override
void invoke(EditableTextTapOutsideIntent intent, [BuildContext? context]) {
// The focus dropping behavior is only present on desktop platforms.
switch (defaultTargetPlatform) {
case TargetPlatform.android:
case TargetPlatform.iOS:
case TargetPlatform.fuchsia:
// On mobile platforms, we don't unfocus on touch events unless they're
// in the web browser, but we do unfocus for all other kinds of events.
switch (intent.pointerDownEvent.kind) {
case ui.PointerDeviceKind.touch:
if (kIsWeb) {
intent.focusNode.unfocus();
}
case ui.PointerDeviceKind.mouse:
case ui.PointerDeviceKind.stylus:
case ui.PointerDeviceKind.invertedStylus:
case ui.PointerDeviceKind.unknown:
intent.focusNode.unfocus();
case ui.PointerDeviceKind.trackpad:
throw UnimplementedError(
'Unexpected pointer down event for trackpad');
}
case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
intent.focusNode.unfocus();
}
}
}

View File

@ -8,6 +8,8 @@ library;
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'actions.dart'; import 'actions.dart';
import 'basic.dart';
import 'focus_manager.dart';
/// An [Intent] to send the event straight to the engine. /// An [Intent] to send the event straight to the engine.
/// ///
@ -387,3 +389,26 @@ class TransposeCharactersIntent extends Intent {
/// Creates a [TransposeCharactersIntent]. /// Creates a [TransposeCharactersIntent].
const TransposeCharactersIntent(); const TransposeCharactersIntent();
} }
/// An [Intent] that represents a tap outside the field.
///
/// Invoked when the user taps outside the focused [EditableText] if
/// [EditableText.onTapOutside] is null.
///
/// Override this [Intent] to modify the default behavior, which is to unfocus
/// on a touch event on web and do nothing on other platforms.
///
/// See also:
///
/// * [Action.overridable] for an example on how to make an [Action]
/// overridable.
class EditableTextTapOutsideIntent extends Intent {
/// Creates an [EditableTextTapOutsideIntent].
const EditableTextTapOutsideIntent({required this.focusNode, required this.pointerDownEvent});
/// The [FocusNode] that this [Intent]'s action should be performed on.
final FocusNode focusNode;
/// The [PointerDownEvent] that initiated this [Intent].
final PointerDownEvent pointerDownEvent;
}

View File

@ -12791,6 +12791,41 @@ void main() {
// On web, using keyboard for selection is handled by the browser. // On web, using keyboard for selection is handled by the browser.
}, skip: kIsWeb); // [intended] }, skip: kIsWeb); // [intended]
testWidgets('can change tap outside behavior by overriding actions', (WidgetTester tester) async {
bool myIntentWasCalled = false;
final CallbackAction<EditableTextTapOutsideIntent> overrideAction = CallbackAction<EditableTextTapOutsideIntent>(
onInvoke: (EditableTextTapOutsideIntent intent) { myIntentWasCalled = true; return null; },
);
final GlobalKey key = GlobalKey();
await tester.pumpWidget(MaterialApp(
home: Column(
children: <Widget>[
SizedBox(
key: key,
width: 200,
height: 200,
),
Actions(
actions: <Type, Action<Intent>>{ EditableTextTapOutsideIntent: overrideAction, },
child: EditableText(
autofocus: true,
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: Colors.blue,
backgroundCursorColor: Colors.grey,
),
),
],
),
));
await tester.pump();
await tester.tap(find.byKey(key), warnIfMissed: false);
await tester.pumpAndSettle();
expect(myIntentWasCalled, isTrue);
expect(focusNode.hasFocus, true);
});
testWidgets('ignore key event from web platform', (WidgetTester tester) async { testWidgets('ignore key event from web platform', (WidgetTester tester) async {
controller.text = 'test\ntest'; controller.text = 'test\ntest';
controller.selection = const TextSelection( controller.selection = const TextSelection(