From 279d30a44fc5b82b14b34d5bcb774a68f11a60a6 Mon Sep 17 00:00:00 2001 From: Jakub Bogacki Date: Wed, 16 Oct 2024 15:25:34 +0200 Subject: [PATCH] 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 --- .../lib/src/widgets/editable_text.dart | 353 +++++++++--------- .../lib/src/widgets/text_editing_intents.dart | 25 ++ .../test/widgets/editable_text_test.dart | 35 ++ 3 files changed, 245 insertions(+), 168 deletions(-) diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index e6e451ef6a..b5569aa391 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -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 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> _actions = >{ @@ -5169,6 +5147,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien PasteTextIntent: _makeOverridable(CallbackAction(onInvoke: (PasteTextIntent intent) => pasteText(intent.cause))), TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction), + EditableTextTapOutsideIntent: _makeOverridable(_EditableTextTapOutsideAction()), }; @override @@ -5186,150 +5165,154 @@ class EditableTextState extends State 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( - 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( + 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( - 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( + 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.value(); } } + +class _EditableTextTapOutsideAction extends ContextAction { + _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(); + } + } +} diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index 6636e84289..ca8a88f0ac 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -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; +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 71c09c7a09..90cf086815 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -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 overrideAction = CallbackAction( + onInvoke: (EditableTextTapOutsideIntent intent) { myIntentWasCalled = true; return null; }, + ); + final GlobalKey key = GlobalKey(); + await tester.pumpWidget(MaterialApp( + home: Column( + children: [ + SizedBox( + key: key, + width: 200, + height: 200, + ), + Actions( + actions: >{ 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(