Add action for configuring default action of EditableText.onTapUpOutside (#162575)

This PR adds an `Action` for configuring a default action of
`EditableText.onTapUpOutside`. This is the equivalent to what
https://github.com/flutter/flutter/pull/150125 did for
`EditableText.onTapOutside`.

Fixes https://github.com/flutter/flutter/issues/162212

## Pre-launch Checklist

- [x] I read the [Contributor Guide] and followed the process outlined
there for submitting PRs.
- [x] I read the [Tree Hygiene] wiki page, which explains my
responsibilities.
- [x] I read and followed the [Flutter Style Guide], including [Features
we expect every widget to implement].
- [x] I signed the [CLA].
- [x] I listed at least one issue that this PR fixes in the description
above.
- [x] I updated/added relevant documentation (doc comments with `///`).
- [x] I added new tests to check the change I am making, or this PR is
[test-exempt].
- [x] I followed the [breaking change policy] and added [Data Driven
Fixes] where supported.
- [x] All existing and new tests are passing.

<!-- Links -->
[Contributor Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#overview
[Tree Hygiene]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md
[test-exempt]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#tests
[Flutter Style Guide]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md
[Features we expect every widget to implement]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Style-guide-for-Flutter-repo.md#features-we-expect-every-widget-to-implement
[CLA]: https://cla.developers.google.com/
[flutter/tests]: https://github.com/flutter/tests
[breaking change policy]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Tree-hygiene.md#handling-breaking-changes
[Discord]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Chat.md
[Data Driven Fixes]:
https://github.com/flutter/flutter/blob/main/docs/contributing/Data-driven-Fixes.md

---------

Co-authored-by: Victor Sanni <victorsanniay@gmail.com>
This commit is contained in:
Hannes Hultergård 2025-02-28 20:01:33 +01:00 committed by GitHub
parent 845c7779b8
commit 6958d086bc
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
5 changed files with 269 additions and 1 deletions

View File

@ -0,0 +1,117 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'dart:ui' as ui;
import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/material.dart';
/// Flutter code sample for [EditableTextTapUpOutsideIntent].
void main() {
runApp(const SampleApp());
}
class SampleApp extends StatelessWidget {
const SampleApp({super.key});
@override
Widget build(BuildContext context) {
return const MaterialApp(home: EditableTextTapUpOutsideIntentExample());
}
}
class EditableTextTapUpOutsideIntentExample extends StatefulWidget {
const EditableTextTapUpOutsideIntentExample({super.key});
@override
State<EditableTextTapUpOutsideIntentExample> createState() =>
_EditableTextTapUpOutsideIntentExampleState();
}
class _EditableTextTapUpOutsideIntentExampleState
extends State<EditableTextTapUpOutsideIntentExample> {
PointerDownEvent? latestPointerDownEvent;
void _handlePointerDown(EditableTextTapOutsideIntent intent) {
// Store the latest pointer down event to calculate the distance between
// the pointer down and pointer up events later.
latestPointerDownEvent = intent.pointerDownEvent;
// Match the default behavior of unfocusing on tap down on desktop platforms
// and on mobile web. Additionally, save the latest pointer down event to
// on non-web mobile platforms to calculate the distance between the pointer
// down and pointer up events later.
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();
} else {
// Store the latest pointer down event to calculate the distance
// between the pointer down and pointer up events later.
latestPointerDownEvent = intent.pointerDownEvent;
}
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();
}
}
void _handlePointerUp(EditableTextTapUpOutsideIntent intent) {
if (latestPointerDownEvent == null) {
return;
}
final double distance =
(latestPointerDownEvent!.position - intent.pointerUpEvent.position).distance;
// Unfocus on taps but not scrolls.
// kTouchSlop is a framework constant that is used to determine if a
// pointer event is a tap or a scroll.
if (distance < kTouchSlop) {
intent.focusNode.unfocus();
}
}
@override
Widget build(BuildContext context) {
return Scaffold(
body: Padding(
padding: const EdgeInsets.all(20),
child: Actions(
actions: <Type, Action<Intent>>{
EditableTextTapOutsideIntent: CallbackAction<EditableTextTapOutsideIntent>(
onInvoke: _handlePointerDown,
),
EditableTextTapUpOutsideIntent: CallbackAction<EditableTextTapUpOutsideIntent>(
onInvoke: _handlePointerUp,
),
},
child: ListView(
children: <Widget>[
TextField(focusNode: FocusNode()),
...List<Widget>.generate(50, (int index) => Text('Item $index')),
],
),
),
),
);
}
}

View File

@ -0,0 +1,50 @@
// Copyright 2014 The Flutter Authors. All rights reserved.
// Use of this source code is governed by a BSD-style license that can be
// found in the LICENSE file.
import 'package:flutter/material.dart';
import 'package:flutter_api_samples/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart'
as example;
import 'package:flutter_test/flutter_test.dart';
void main() {
testWidgets('Unfocuses TextField on tap', (WidgetTester tester) async {
await tester.pumpWidget(const example.SampleApp());
final Finder finder = find.byType(TextField);
final TextField textField = tester.firstWidget(finder);
await tester.tap(finder);
await tester.pump();
expect(textField.focusNode!.hasFocus, true);
// Tap the center of the Scaffold, outside the TextField.
await tester.tap(find.byType(Scaffold));
await tester.pump();
expect(textField.focusNode!.hasFocus, false);
});
testWidgets('Does not unfocus TextField on scroll', (WidgetTester tester) async {
await tester.pumpWidget(const example.SampleApp());
final Finder finder = find.byType(TextField);
final TextField textField = tester.firstWidget(finder);
await tester.tap(finder);
await tester.pump();
expect(textField.focusNode!.hasFocus, true);
// Tap the center of the Scaffold, outside the TextField.
await tester.drag(find.byType(Scaffold), const Offset(0.0, -100.0));
await tester.pump();
expect(textField.focusNode!.hasFocus, true);
});
}

View File

@ -1563,6 +1563,10 @@ class EditableText extends StatefulWidget {
/// Called for each tap up that occurs outside of the [TextFieldTapRegion]
/// group when the text field is focused.
///
/// If this is null, [EditableTextTapUpOutsideIntent] will be invoked. In the
/// default implementation, this is a no-op. To change this behavior, set a
/// callback here or override [EditableTextTapUpOutsideIntent].
///
/// The [PointerUpEvent] passed to the function is the event that caused the
/// notification. It is possible that the event may occur outside of the
/// immediate bounding box defined by the text field, although it will be
@ -1573,6 +1577,8 @@ class EditableText extends StatefulWidget {
///
/// * [TapRegion] for how the region group is determined.
/// * [onTapOutside], which is called for each tap down.
/// * [EditableTextTapOutsideIntent], the intent that is invoked if
/// this is null.
final TapRegionUpCallback? onTapUpOutside;
/// {@template flutter.widgets.editableText.inputFormatters}
@ -5372,6 +5378,16 @@ class EditableTextState extends State<EditableText>
);
}
/// The default behavior used if [EditableText.onTapUpOutside] is null.
///
/// The `event` argument is the [PointerUpEvent] that caused the notification.
void _defaultOnTapUpOutside(BuildContext context, PointerUpEvent event) {
Actions.invoke(
context,
EditableTextTapUpOutsideIntent(focusNode: widget.focusNode, pointerUpEvent: event),
);
}
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
ReplaceTextIntent: _replaceTextAction,
@ -5493,6 +5509,7 @@ class EditableTextState extends State<EditableText>
TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction),
EditableTextTapOutsideIntent: _makeOverridable(_EditableTextTapOutsideAction()),
EditableTextTapUpOutsideIntent: _makeOverridable(_EditableTextTapUpOutsideAction()),
};
@protected
@ -5522,7 +5539,9 @@ class EditableTextState extends State<EditableText>
? widget.onTapOutside ??
(PointerDownEvent event) => _defaultOnTapOutside(context, event)
: null,
onTapUpOutside: widget.onTapUpOutside,
onTapUpOutside:
widget.onTapUpOutside ??
(PointerUpEvent event) => _defaultOnTapUpOutside(context, event),
debugLabel: kReleaseMode ? null : 'EditableText',
child: MouseRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
@ -6505,3 +6524,12 @@ class _EditableTextTapOutsideAction extends ContextAction<EditableTextTapOutside
}
}
}
class _EditableTextTapUpOutsideAction extends ContextAction<EditableTextTapUpOutsideIntent> {
_EditableTextTapUpOutsideAction();
@override
void invoke(EditableTextTapUpOutsideIntent intent, [BuildContext? context]) {
// The default action is a no-op.
}
}

View File

@ -407,3 +407,39 @@ class EditableTextTapOutsideIntent extends Intent {
/// The [PointerDownEvent] that initiated this [Intent].
final PointerDownEvent pointerDownEvent;
}
/// An [Intent] that represents a tap outside the field.
///
/// Invoked when the user taps up outside the focused [EditableText] if
/// [EditableText.onTapUpOutside] 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.
///
/// {@tool dartpad}
/// A common requirement is to unfocus text fields when the user taps outside of
/// it. For UX reasons, it's often desirable to only unfocus when the user taps
/// outside of the text field, but not when they scroll.
///
/// To achieve this, you can override the default behavior of
/// [EditableTextTapOutsideIntent] and [EditableTextTapUpOutsideIntent] to check
/// the difference in distance between the pointer down and pointer up events
/// before potentially unfocusing.
///
/// ** See code in examples/api/lib/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart **
/// {@end-tool}
///
/// See also:
///
/// * [Action.overridable] for an example on how to make an [Action]
/// overridable.
class EditableTextTapUpOutsideIntent extends Intent {
/// Creates an [EditableTextTapUpOutsideIntent].
const EditableTextTapUpOutsideIntent({required this.focusNode, required this.pointerUpEvent});
/// The [FocusNode] that this [Intent]'s action should be performed on.
final FocusNode focusNode;
/// The [PointerUpEvent] that initiated this [Intent].
final PointerUpEvent pointerUpEvent;
}

View File

@ -11969,6 +11969,43 @@ void main() {
expect(focusNode.hasFocus, true);
});
testWidgets('can change tap up outside behavior by overriding actions', (
WidgetTester tester,
) async {
bool myIntentWasCalled = false;
final CallbackAction<EditableTextTapUpOutsideIntent> overrideAction =
CallbackAction<EditableTextTapUpOutsideIntent>(
onInvoke: (EditableTextTapUpOutsideIntent 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>>{EditableTextTapUpOutsideIntent: overrideAction},
child: EditableText(
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.pump();
expect(myIntentWasCalled, isTrue);
});
testWidgets('ignore key event from web platform', (WidgetTester tester) async {
controller.text = 'test\ntest';
controller.selection = const TextSelection(