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:
parent
a9ae0b8bb0
commit
279d30a44f
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
@ -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;
|
||||||
|
}
|
||||||
|
@ -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(
|
||||||
|
Loading…
x
Reference in New Issue
Block a user