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
/// when the text field is focused.
///
/// If this is null, [FocusNode.unfocus] will be called on the [focusNode] for
/// this text field when a [PointerDownEvent] is received on another part of
/// the UI. However, it will not unfocus as a result of mobile application
/// touch events (which does not include mouse clicks), to conform with the
/// platform conventions. To change this behavior, a callback may be set here
/// that operates differently from the default.
/// If this is null, [EditableTextTapOutsideIntent] will be invoked. In the
/// default implementation, [FocusNode.unfocus] will be called on the
/// [focusNode] for this text field when a [PointerDownEvent] is received on
/// another part of the UI. However, it will not unfocus as a result of mobile
/// application touch events (which does not include mouse clicks), to conform
/// 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
/// button that copies the selected text, or modifies formatting), it is
@ -1529,6 +1530,8 @@ class EditableText extends StatefulWidget {
/// See also:
///
/// * [TapRegion] for how the region group is determined.
/// * [EditableTextTapOutsideIntent] for the intent that is invoked if
/// this is null.
final TapRegionCallback? onTapOutside;
/// {@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 `event` argument is the [PointerDownEvent] that caused the notification.
void _defaultOnTapOutside(PointerDownEvent event) {
/// The focus dropping behavior is only present on desktop platforms
/// 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();
}
void _defaultOnTapOutside(BuildContext context, PointerDownEvent event) {
Actions.invoke(context, EditableTextTapOutsideIntent(focusNode: widget.focusNode, pointerDownEvent: event));
}
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))),
TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction),
EditableTextTapOutsideIntent: _makeOverridable(_EditableTextTapOutsideAction()),
};
@override
@ -5186,150 +5165,154 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return _CompositionCallback(
compositeCallback: _compositeCallback,
enabled: _hasInputConnection,
child: TextFieldTapRegion(
groupId: widget.groupId,
onTapOutside: _hasFocus ? widget.onTapOutside ?? _defaultOnTapOutside : null,
debugLabel: kReleaseMode ? null : 'EditableText',
child: MouseRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions(
actions: _actions,
child: UndoHistory<TextEditingValue>(
value: widget.controller,
onTriggered: (TextEditingValue value) {
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
},
shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) {
if (!newValue.selection.isValid) {
return false;
}
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) {
child: Actions(
actions: _actions,
child: Builder(
builder: (BuildContext context) {
return TextFieldTapRegion(
groupId: widget.groupId,
onTapOutside: _hasFocus ? widget.onTapOutside ?? (PointerDownEvent event) => _defaultOnTapOutside(context, event) : null,
debugLabel: kReleaseMode ? null : 'EditableText',
child: MouseRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: UndoHistory<TextEditingValue>(
value: widget.controller,
onTriggered: (TextEditingValue value) {
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
},
shouldChangeUndoStack: (TextEditingValue? oldValue, TextEditingValue newValue) {
if (!newValue.selection.isValid) {
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;
},
undoStackModifier: (TextEditingValue value) {
// On Android we should discard the composing region when pushing
// a new entry to the undo stack. This prevents the TextInputPlugin
// from restarting the input on every undo/redo when the composing
// region is changed by the framework.
return defaultTargetPlatform == TargetPlatform.android ? value.copyWith(composing: TextRange.empty) : value;
},
focusNode: widget.focusNode,
controller: widget.undoController,
child: Focus(
focusNode: widget.focusNode,
includeSemantics: false,
debugLabel: kReleaseMode ? null : 'EditableText',
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
_handleContextMenuOnScroll(notification);
_scribbleCacheKey = null;
return false;
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;
}
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;
},
child: Scrollable(
key: _scrollableKey,
excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: widget.scrollPhysics,
dragStartBehavior: widget.dragStartBehavior,
restorationId: widget.restorationId,
// If a ScrollBehavior is not provided, only apply scrollbars when
// multiline. The overscroll indicator should not be applied in
// either case, glowing or stretching.
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
scrollbars: _isMultiline,
overscroll: false,
),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
onCopy: _semanticsOnCopy(controls),
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
child: _ScribbleFocusable(
focusNode: widget.focusNode,
editableKey: _editableKey,
enabled: widget.scribbleEnabled,
updateSelectionRects: () {
_openInputConnection();
_updateSelectionRects(force: true);
},
child: SizeChangedLayoutNotifier(
child: _Editable(
key: _editableKey,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor: widget.backgroundCursorColor,
showCursor: _cursorVisibilityNotifier,
forceLine: widget.forceLine,
readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
: widget.selectionColor,
textScaler: effectiveTextScaler,
textAlign: widget.textAlign,
textDirection: _textDirection,
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,
undoStackModifier: (TextEditingValue value) {
// On Android we should discard the composing region when pushing
// a new entry to the undo stack. This prevents the TextInputPlugin
// from restarting the input on every undo/redo when the composing
// region is changed by the framework.
return defaultTargetPlatform == TargetPlatform.android ? value.copyWith(composing: TextRange.empty) : value;
},
focusNode: widget.focusNode,
controller: widget.undoController,
child: Focus(
focusNode: widget.focusNode,
includeSemantics: false,
debugLabel: kReleaseMode ? null : 'EditableText',
child: NotificationListener<ScrollNotification>(
onNotification: (ScrollNotification notification) {
_handleContextMenuOnScroll(notification);
_scribbleCacheKey = null;
return false;
},
child: Scrollable(
key: _scrollableKey,
excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
controller: _scrollController,
physics: widget.scrollPhysics,
dragStartBehavior: widget.dragStartBehavior,
restorationId: widget.restorationId,
// If a ScrollBehavior is not provided, only apply scrollbars when
// multiline. The overscroll indicator should not be applied in
// either case, glowing or stretching.
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
scrollbars: _isMultiline,
overscroll: false,
),
viewportBuilder: (BuildContext context, ViewportOffset offset) {
return CompositedTransformTarget(
link: _toolbarLayerLink,
child: Semantics(
onCopy: _semanticsOnCopy(controls),
onCut: _semanticsOnCut(controls),
onPaste: _semanticsOnPaste(controls),
child: _ScribbleFocusable(
focusNode: widget.focusNode,
editableKey: _editableKey,
enabled: widget.scribbleEnabled,
updateSelectionRects: () {
_openInputConnection();
_updateSelectionRects(force: true);
},
child: SizeChangedLayoutNotifier(
child: _Editable(
key: _editableKey,
startHandleLayerLink: _startHandleLayerLink,
endHandleLayerLink: _endHandleLayerLink,
inlineSpan: buildTextSpan(),
value: _value,
cursorColor: _cursorColor,
backgroundCursorColor: widget.backgroundCursorColor,
showCursor: _cursorVisibilityNotifier,
forceLine: widget.forceLine,
readOnly: widget.readOnly,
hasFocus: _hasFocus,
maxLines: widget.maxLines,
minLines: widget.minLines,
expands: widget.expands,
strutStyle: widget.strutStyle,
selectionColor: _selectionOverlay?.spellCheckToolbarIsVisible ?? false
? _spellCheckConfiguration.misspelledSelectionColor ?? widget.selectionColor
: widget.selectionColor,
textScaler: effectiveTextScaler,
textAlign: widget.textAlign,
textDirection: _textDirection,
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();
}
}
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 'actions.dart';
import 'basic.dart';
import 'focus_manager.dart';
/// An [Intent] to send the event straight to the engine.
///
@ -387,3 +389,26 @@ class TransposeCharactersIntent extends Intent {
/// Creates a [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.
}, 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 {
controller.text = 'test\ntest';
controller.selection = const TextSelection(