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,150 +5165,154 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return _CompositionCallback( return _CompositionCallback(
compositeCallback: _compositeCallback, compositeCallback: _compositeCallback,
enabled: _hasInputConnection, enabled: _hasInputConnection,
child: TextFieldTapRegion( child: Actions(
groupId: widget.groupId, actions: _actions,
onTapOutside: _hasFocus ? widget.onTapOutside ?? _defaultOnTapOutside : null, child: Builder(
debugLabel: kReleaseMode ? null : 'EditableText', builder: (BuildContext context) {
child: MouseRegion( return TextFieldTapRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text, groupId: widget.groupId,
child: Actions( onTapOutside: _hasFocus ? widget.onTapOutside ?? (PointerDownEvent event) => _defaultOnTapOutside(context, event) : null,
actions: _actions, debugLabel: kReleaseMode ? null : 'EditableText',
child: UndoHistory<TextEditingValue>( child: MouseRegion(
value: widget.controller, cursor: widget.mouseCursor ?? SystemMouseCursors.text,
onTriggered: (TextEditingValue value) { child: UndoHistory<TextEditingValue>(
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); value: widget.controller,
}, onTriggered: (TextEditingValue value) {
shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) { userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
if (!newValue.selection.isValid) { },
return false; shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) {
} if (!newValue.selection.isValid) {
if (oldValue == null) {
return true;
}
switch (defaultTargetPlatform) {
case TargetPlatform.iOS:
case TargetPlatform.macOS:
case TargetPlatform.fuchsia:
case TargetPlatform.linux:
case TargetPlatform.windows:
// Composing text is not counted in history coalescing.
if (!widget.controller.value.composing.isCollapsed) {
return false; return false;
} }
case TargetPlatform.android:
// Gboard on Android puts non-CJK words in composing regions. Coalesce
// composing text in order to allow the saving of partial words in that
// case.
break;
}
return oldValue.text != newValue.text || oldValue.composing != newValue.composing; if (oldValue == null) {
}, return true;
undoStackModifier: (TextEditingValue value) { }
// On Android we should discard the composing region when pushing
// a new entry to the undo stack. This prevents the TextInputPlugin switch (defaultTargetPlatform) {
// from restarting the input on every undo/redo when the composing case TargetPlatform.iOS:
// region is changed by the framework. case TargetPlatform.macOS:
return defaultTargetPlatform == TargetPlatform.android ? value.copyWith(composing: TextRange.empty) : value; case TargetPlatform.fuchsia:
}, case TargetPlatform.linux:
focusNode: widget.focusNode, case TargetPlatform.windows:
controller: widget.undoController, // Composing text is not counted in history coalescing.
child: Focus( if (!widget.controller.value.composing.isCollapsed) {
focusNode: widget.focusNode, return false;
includeSemantics: false, }
debugLabel: kReleaseMode ? null : 'EditableText', case TargetPlatform.android:
child: NotificationListener<ScrollNotification>( // Gboard on Android puts non-CJK words in composing regions. Coalesce
onNotification: (ScrollNotification notification) { // composing text in order to allow the saving of partial words in that
_handleContextMenuOnScroll(notification); // case.
_scribbleCacheKey = null; break;
return false; }
return oldValue.text != newValue.text || oldValue.composing != newValue.composing;
}, },
child: Scrollable( undoStackModifier: (TextEditingValue value) {
key: _scrollableKey, // On Android we should discard the composing region when pushing
excludeFromSemantics: true, // a new entry to the undo stack. This prevents the TextInputPlugin
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, // from restarting the input on every undo/redo when the composing
controller: _scrollController, // region is changed by the framework.
physics: widget.scrollPhysics, return defaultTargetPlatform == TargetPlatform.android ? value.copyWith(composing: TextRange.empty) : value;
dragStartBehavior: widget.dragStartBehavior, },
restorationId: widget.restorationId, focusNode: widget.focusNode,
// If a ScrollBehavior is not provided, only apply scrollbars when controller: widget.undoController,
// multiline. The overscroll indicator should not be applied in child: Focus(
// either case, glowing or stretching. focusNode: widget.focusNode,
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith( includeSemantics: false,
scrollbars: _isMultiline, debugLabel: kReleaseMode ? null : 'EditableText',
overscroll: false, child: NotificationListener<ScrollNotification>(
), onNotification: (ScrollNotification notification) {
viewportBuilder: (BuildContext context, ViewportOffset offset) { _handleContextMenuOnScroll(notification);
return CompositedTransformTarget( _scribbleCacheKey = null;
link: _toolbarLayerLink, return false;
child: Semantics( },
onCopy: _semanticsOnCopy(controls), child: Scrollable(
onCut: _semanticsOnCut(controls), key: _scrollableKey,
onPaste: _semanticsOnPaste(controls), excludeFromSemantics: true,
child: _ScribbleFocusable( axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
focusNode: widget.focusNode, controller: _scrollController,
editableKey: _editableKey, physics: widget.scrollPhysics,
enabled: widget.scribbleEnabled, dragStartBehavior: widget.dragStartBehavior,
updateSelectionRects: () { restorationId: widget.restorationId,
_openInputConnection(); // If a ScrollBehavior is not provided, only apply scrollbars when
_updateSelectionRects(force: true); // multiline. The overscroll indicator should not be applied in
}, // either case, glowing or stretching.
child: SizeChangedLayoutNotifier( scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
child: _Editable( scrollbars: _isMultiline,
key: _editableKey, overscroll: false,
startHandleLayerLink: _startHandleLayerLink, ),
endHandleLayerLink: _endHandleLayerLink, viewportBuilder: (BuildContext context, ViewportOffset offset) {
inlineSpan: buildTextSpan(), return CompositedTransformTarget(
value: _value, link: _toolbarLayerLink,
cursorColor: _cursorColor, child: Semantics(
backgroundCursorColor: widget.backgroundCursorColor, onCopy: _semanticsOnCopy(controls),
showCursor: _cursorVisibilityNotifier, onCut: _semanticsOnCut(controls),
forceLine: widget.forceLine, onPaste: _semanticsOnPaste(controls),
readOnly: widget.readOnly, child: _ScribbleFocusable(
hasFocus: _hasFocus, focusNode: widget.focusNode,
maxLines: widget.maxLines, editableKey: _editableKey,
minLines: widget.minLines, enabled: widget.scribbleEnabled,
expands: widget.expands, updateSelectionRects: () {
strutStyle: widget.strutStyle, _openInputConnection();
selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false _updateSelectionRects(force: true);
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor },
: widget.selectionColor, child: SizeChangedLayoutNotifier(
textScaler: effectiveTextScaler, child: _Editable(
textAlign: widget.textAlign, key: _editableKey,
textDirection: _textDirection, startHandleLayerLink: _startHandleLayerLink,
locale: widget.locale, endHandleLayerLink: _endHandleLayerLink,
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context), inlineSpan: buildTextSpan(),
textWidthBasis: widget.textWidthBasis, value: _value,
obscuringCharacter: widget.obscuringCharacter, cursorColor: _cursorColor,
obscureText: widget.obscureText, backgroundCursorColor: widget.backgroundCursorColor,
offset: offset, showCursor: _cursorVisibilityNotifier,
rendererIgnoresPointer: widget.rendererIgnoresPointer, forceLine: widget.forceLine,
cursorWidth: widget.cursorWidth, readOnly: widget.readOnly,
cursorHeight: widget.cursorHeight, hasFocus: _hasFocus,
cursorRadius: widget.cursorRadius, maxLines: widget.maxLines,
cursorOffset: widget.cursorOffset ?? Offset.zero, minLines: widget.minLines,
selectionHeightStyle: widget.selectionHeightStyle, expands: widget.expands,
selectionWidthStyle: widget.selectionWidthStyle, strutStyle: widget.strutStyle,
paintCursorAboveText: widget.paintCursorAboveText, selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
enableInteractiveSelection: widget._userSelectionEnabled, ? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
textSelectionDelegate: this, : widget.selectionColor,
devicePixelRatio: _devicePixelRatio, textScaler: effectiveTextScaler,
promptRectRange: _currentPromptRectRange, textAlign: widget.textAlign,
promptRectColor: widget.autocorrectionTextRectColor, textDirection: _textDirection,
clipBehavior: widget.clipBehavior, locale: widget.locale,
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.maybeOf(context),
textWidthBasis: widget.textWidthBasis,
obscuringCharacter: widget.obscuringCharacter,
obscureText: widget.obscureText,
offset: offset,
rendererIgnoresPointer: widget.rendererIgnoresPointer,
cursorWidth: widget.cursorWidth,
cursorHeight: widget.cursorHeight,
cursorRadius: widget.cursorRadius,
cursorOffset: widget.cursorOffset ?? Offset.zero,
selectionHeightStyle: widget.selectionHeightStyle,
selectionWidthStyle: widget.selectionWidthStyle,
paintCursorAboveText: widget.paintCursorAboveText,
enableInteractiveSelection: widget._userSelectionEnabled,
textSelectionDelegate: this,
devicePixelRatio: _devicePixelRatio,
promptRectRange: _currentPromptRectRange,
promptRectColor: widget.autocorrectionTextRectColor,
clipBehavior: widget.clipBehavior,
),
),
), ),
), ),
), );
), },
); ),
}, ),
), ),
), ),
), ),
), );
), }
), ),
), ),
); );
@ -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(