From 6958d086bc755e6183a5004d5802de7cc5ed9784 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Hannes=20Hulterg=C3=A5rd?= <47989573+Hannnes1@users.noreply.github.com> Date: Fri, 28 Feb 2025 20:01:33 +0100 Subject: [PATCH] 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. [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 --- ...editable_text_tap_up_outside_intent.0.dart | 117 ++++++++++++++++++ ...ble_text_tap_up_outside_intent.0_test.dart | 50 ++++++++ .../lib/src/widgets/editable_text.dart | 30 ++++- .../lib/src/widgets/text_editing_intents.dart | 36 ++++++ .../test/widgets/editable_text_test.dart | 37 ++++++ 5 files changed, 269 insertions(+), 1 deletion(-) create mode 100644 examples/api/lib/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart create mode 100644 examples/api/test/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0_test.dart diff --git a/examples/api/lib/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart b/examples/api/lib/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart new file mode 100644 index 0000000000..0d7a6cd50f --- /dev/null +++ b/examples/api/lib/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0.dart @@ -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 createState() => + _EditableTextTapUpOutsideIntentExampleState(); +} + +class _EditableTextTapUpOutsideIntentExampleState + extends State { + 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: >{ + EditableTextTapOutsideIntent: CallbackAction( + onInvoke: _handlePointerDown, + ), + EditableTextTapUpOutsideIntent: CallbackAction( + onInvoke: _handlePointerUp, + ), + }, + child: ListView( + children: [ + TextField(focusNode: FocusNode()), + ...List.generate(50, (int index) => Text('Item $index')), + ], + ), + ), + ), + ); + } +} diff --git a/examples/api/test/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0_test.dart b/examples/api/test/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0_test.dart new file mode 100644 index 0000000000..ee9cd9522c --- /dev/null +++ b/examples/api/test/widgets/text_editing_intents/editable_text_tap_up_outside_intent.0_test.dart @@ -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); + }); +} diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 762abea969..f4b90c39a6 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -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 ); } + /// 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> _actions = >{ DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), ReplaceTextIntent: _replaceTextAction, @@ -5493,6 +5509,7 @@ class EditableTextState extends State TransposeCharactersIntent: _makeOverridable(_transposeCharactersAction), EditableTextTapOutsideIntent: _makeOverridable(_EditableTextTapOutsideAction()), + EditableTextTapUpOutsideIntent: _makeOverridable(_EditableTextTapUpOutsideAction()), }; @protected @@ -5522,7 +5539,9 @@ class EditableTextState extends State ? 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 { + _EditableTextTapUpOutsideAction(); + + @override + void invoke(EditableTextTapUpOutsideIntent intent, [BuildContext? context]) { + // The default action is a no-op. + } +} diff --git a/packages/flutter/lib/src/widgets/text_editing_intents.dart b/packages/flutter/lib/src/widgets/text_editing_intents.dart index 36586a56d0..8e39142ac7 100644 --- a/packages/flutter/lib/src/widgets/text_editing_intents.dart +++ b/packages/flutter/lib/src/widgets/text_editing_intents.dart @@ -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; +} diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 0703b24d5d..34718066be 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -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 overrideAction = + CallbackAction( + onInvoke: (EditableTextTapUpOutsideIntent 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: >{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(