From 9acbc1d4a2a7e152a3694354a248b775cd6d7a4f Mon Sep 17 00:00:00 2001 From: wangyognqi <42757204+wyqlxf@users.noreply.github.com> Date: Thu, 23 May 2024 02:33:14 +0800 Subject: [PATCH] Fix the second TextFormField to trigger onTapOutside (#148206) This PR attempts to fix https://github.com/flutter/flutter/issues/127597 --- .../flutter/lib/src/cupertino/text_field.dart | 6 ++ .../flutter/lib/src/material/text_field.dart | 5 ++ .../lib/src/material/text_form_field.dart | 5 ++ .../lib/src/widgets/editable_text.dart | 15 ++++ .../flutter/lib/src/widgets/tap_region.dart | 3 +- .../test/cupertino/text_field_test.dart | 85 ++++++++++++++++++ .../test/material/text_field_test.dart | 86 +++++++++++++++++++ .../test/material/text_form_field_test.dart | 86 +++++++++++++++++++ .../test/widgets/editable_text_test.dart | 75 ++++++++++++++++ 9 files changed, 365 insertions(+), 1 deletion(-) diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 9948bd96d98a..4f657b67ae88 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -221,6 +221,7 @@ class CupertinoTextField extends StatefulWidget { /// characters" and how it may differ from the intuitive meaning. const CupertinoTextField({ super.key, + this.groupId = EditableText, this.controller, this.focusNode, this.undoController, @@ -351,6 +352,7 @@ class CupertinoTextField extends StatefulWidget { /// characters" and how it may differ from the intuitive meaning. const CupertinoTextField.borderless({ super.key, + this.groupId = EditableText, this.controller, this.focusNode, this.undoController, @@ -446,6 +448,9 @@ class CupertinoTextField extends StatefulWidget { keyboardType = keyboardType ?? (maxLines == 1 ? TextInputType.text : TextInputType.multiline), enableInteractiveSelection = enableInteractiveSelection ?? (!readOnly || !obscureText); + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + /// Controls the text being edited. /// /// If null, this widget will create its own [TextEditingController]. @@ -1387,6 +1392,7 @@ class _CupertinoTextFieldState extends State with Restoratio selectionColor: _effectiveFocusNode.hasFocus ? selectionColor : null, selectionControls: widget.selectionEnabled ? textSelectionControls : null, + groupId: widget.groupId, onChanged: widget.onChanged, onSelectionChanged: _handleSelectionChanged, onEditingComplete: widget.onEditingComplete, diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index d2c2ef0b7c02..9a22c381a587 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -259,6 +259,7 @@ class TextField extends StatefulWidget { /// characters" and how it may differ from the intuitive meaning. const TextField({ super.key, + this.groupId = EditableText, this.controller, this.focusNode, this.undoController, @@ -368,6 +369,9 @@ class TextField extends StatefulWidget { /// {@end-tool} final TextMagnifierConfiguration? magnifierConfiguration; + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + /// Controls the text being edited. /// /// If null, this widget will create its own [TextEditingController]. @@ -1499,6 +1503,7 @@ class _TextFieldState extends State with RestorationMixin implements onEditingComplete: widget.onEditingComplete, onSubmitted: widget.onSubmitted, onAppPrivateCommand: widget.onAppPrivateCommand, + groupId: widget.groupId, onSelectionHandleTapped: _handleSelectionHandleTapped, onTapOutside: widget.onTapOutside, inputFormatters: formatters, diff --git a/packages/flutter/lib/src/material/text_form_field.dart b/packages/flutter/lib/src/material/text_form_field.dart index c9d8613adfe2..00566770e634 100644 --- a/packages/flutter/lib/src/material/text_form_field.dart +++ b/packages/flutter/lib/src/material/text_form_field.dart @@ -101,6 +101,7 @@ class TextFormField extends FormField { /// and [TextField.new], the constructor. TextFormField({ super.key, + this.groupId = EditableText, this.controller, String? initialValue, FocusNode? focusNode, @@ -203,6 +204,7 @@ class TextFormField extends FormField { return UnmanagedRestorationScope( bucket: field.bucket, child: TextField( + groupId: groupId, restorationId: restorationId, controller: state._effectiveController, focusNode: focusNode, @@ -279,6 +281,9 @@ class TextFormField extends FormField { /// initialize its [TextEditingController.text] with [initialValue]. final TextEditingController? controller; + /// {@macro flutter.widgets.editableText.groupId} + final Object groupId; + /// {@template flutter.material.TextFormField.onChanged} /// Called when the user initiates a change to the TextField's /// value: when they have inserted or deleted text or reset the form. diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 069230666e9f..0e7fbbeda18f 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -841,6 +841,7 @@ class EditableText extends StatefulWidget { this.onAppPrivateCommand, this.onSelectionChanged, this.onSelectionHandleTapped, + this.groupId = EditableText, this.onTapOutside, List? inputFormatters, this.mouseCursor, @@ -1470,6 +1471,19 @@ class EditableText extends StatefulWidget { /// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped} final VoidCallback? onSelectionHandleTapped; + /// {@template flutter.widgets.editableText.groupId} + /// The group identifier for the [TextFieldTapRegion] of this text field. + /// + /// Text fields with the same group identifier share the same tap region. + /// Defaults to the type of [EditableText]. + /// + /// See also: + /// + /// * [TextFieldTapRegion], to give a [groupId] to a widget that is to be + /// included in a [EditableText]'s tap region that has [groupId] set. + /// {@endtemplate} + final Object groupId; + /// {@template flutter.widgets.editableText.onTapOutside} /// Called for each tap that occurs outside of the[TextFieldTapRegion] group /// when the text field is focused. @@ -5171,6 +5185,7 @@ class EditableTextState extends State with AutomaticKeepAliveClien compositeCallback: _compositeCallback, enabled: _hasInputConnection, child: TextFieldTapRegion( + groupId: _hasFocus ? widget.groupId : null, onTapOutside: _hasFocus ? widget.onTapOutside ?? _defaultOnTapOutside : null, debugLabel: kReleaseMode ? null : 'EditableText', child: MouseRegion( diff --git a/packages/flutter/lib/src/widgets/tap_region.dart b/packages/flutter/lib/src/widgets/tap_region.dart index d00ec9d40841..756feaa9a2cd 100644 --- a/packages/flutter/lib/src/widgets/tap_region.dart +++ b/packages/flutter/lib/src/widgets/tap_region.dart @@ -635,5 +635,6 @@ class TextFieldTapRegion extends TapRegion { super.onTapInside, super.consumeOutsideTaps, super.debugLabel, - }) : super(groupId: EditableText); + super.groupId = EditableText, + }); } diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index a3a14c845d1c..c46651ab159f 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -909,6 +909,91 @@ void main() { }, ); + testWidgets( + 'The second CupertinoTextField is clicked, triggers the onTapOutside callback of the previous CupertinoTextField', + (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey keyC = GlobalKey(); + bool outsideClickA = false; + bool outsideClickB = false; + bool outsideClickC = false; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: [ + const Text('Outside'), + Material( + child: CupertinoTextField( + key: keyA, + groupId: 'Group A', + onTapOutside: (PointerDownEvent event) { + outsideClickA = true; + }, + ), + ), + Material( + child: CupertinoTextField( + key: keyB, + groupId: 'Group B', + onTapOutside: (PointerDownEvent event) { + outsideClickB = true; + }, + ), + ), + Material( + child: CupertinoTextField( + key: keyC, + groupId: 'Group C', + onTapOutside: (PointerDownEvent event) { + outsideClickC = true; + }, + ), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + + Future click(Finder finder) async { + await tester.tap(finder); + await tester.enterText(finder, 'Hello'); + await tester.pump(); + } + + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyA)); + await tester.showKeyboard(find.byKey(keyA)); + await tester.idle(); + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyB)); + expect(outsideClickA, true); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyC)); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, false); + + await tester.tap(find.text('Outside')); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, true); + }, + ); + testWidgets( 'decoration can be overridden', (WidgetTester tester) async { diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index ed661f5d72b7..516f4332e54e 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -797,6 +797,92 @@ void main() { expect(editableTextWidget.onEditingComplete, onEditingComplete); }); + // Regression test for https://github.com/flutter/flutter/issues/127597. + testWidgets( + 'The second TextField is clicked, triggers the onTapOutside callback of the previous TextField', + (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey keyC = GlobalKey(); + bool outsideClickA = false; + bool outsideClickB = false; + bool outsideClickC = false; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: [ + const Text('Outside'), + Material( + child: TextField( + key: keyA, + groupId: 'Group A', + onTapOutside: (PointerDownEvent event) { + outsideClickA = true; + }, + ), + ), + Material( + child: TextField( + key: keyB, + groupId: 'Group B', + onTapOutside: (PointerDownEvent event) { + outsideClickB = true; + }, + ), + ), + Material( + child: TextField( + key: keyC, + groupId: 'Group C', + onTapOutside: (PointerDownEvent event) { + outsideClickC = true; + }, + ), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + + Future click(Finder finder) async { + await tester.tap(finder); + await tester.enterText(finder, 'Hello'); + await tester.pump(); + } + + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyA)); + await tester.showKeyboard(find.byKey(keyA)); + await tester.idle(); + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyB)); + expect(outsideClickA, true); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyC)); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, false); + + await tester.tap(find.text('Outside')); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, true); + }, + ); + testWidgets('TextField has consistent size', (WidgetTester tester) async { final Key textFieldKey = UniqueKey(); String? textFieldValue; diff --git a/packages/flutter/test/material/text_form_field_test.dart b/packages/flutter/test/material/text_form_field_test.dart index 99b739d56dd1..a0401cc3eb69 100644 --- a/packages/flutter/test/material/text_form_field_test.dart +++ b/packages/flutter/test/material/text_form_field_test.dart @@ -824,6 +824,92 @@ void main() { expect(tapOutsideCount, 0); }); + // Regression test for https://github.com/flutter/flutter/issues/127597. + testWidgets( + 'The second TextFormField is clicked, triggers the onTapOutside callback of the previous TextFormField', + (WidgetTester tester) async { + final GlobalKey keyA = GlobalKey(); + final GlobalKey keyB = GlobalKey(); + final GlobalKey keyC = GlobalKey(); + bool outsideClickA = false; + bool outsideClickB = false; + bool outsideClickC = false; + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: [ + const Text('Outside'), + Material( + child: TextFormField( + key: keyA, + groupId: 'Group A', + onTapOutside: (PointerDownEvent event) { + outsideClickA = true; + }, + ), + ), + Material( + child: TextFormField( + key: keyB, + groupId: 'Group B', + onTapOutside: (PointerDownEvent event) { + outsideClickB = true; + }, + ), + ), + Material( + child: TextFormField( + key: keyC, + groupId: 'Group C', + onTapOutside: (PointerDownEvent event) { + outsideClickC = true; + }, + ), + ), + ], + ), + ), + ), + ); + + await tester.pump(); + + Future click(Finder finder) async { + await tester.tap(finder); + await tester.enterText(finder, 'Hello'); + await tester.pump(); + } + + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyA)); + await tester.showKeyboard(find.byKey(keyA)); + await tester.idle(); + expect(outsideClickA, false); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyB)); + expect(outsideClickA, true); + expect(outsideClickB, false); + expect(outsideClickC, false); + + await click(find.byKey(keyC)); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, false); + + await tester.tap(find.text('Outside')); + expect(outsideClickA, true); + expect(outsideClickB, true); + expect(outsideClickC, true); + }, + ); + // Regression test for https://github.com/flutter/flutter/issues/54472. testWidgets('reset resets the text fields value to the initialValue', (WidgetTester tester) async { await tester.pumpWidget( diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 8c031c4a17ff..e5550f975331 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -193,6 +193,81 @@ void main() { skip: kIsWeb, // [intended] ); + group('Check the passed groupId value', () { + testWidgets( + 'The value of the passed-in groupId should match the groupId of the EditableText', + (WidgetTester tester) async { + final List groupIds = ['Group A', 'Group B', 'Group C']; + final List keys = + List.generate(3, (_) => GlobalKey()); + final List inputFields = [ + TextFormField(key: keys[0], groupId: groupIds[0]), + CupertinoTextField(key: keys[1], groupId: groupIds[1]), + TextField(key: keys[2], groupId: groupIds[2]), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: inputFields.map((Widget child) { + return Material(child: child); + }).toList(), + ), + ), + ), + ); + + await tester.pump(); + + for (int i = 0; i < 3; i++) { + final EditableText editableText = tester.widget(find.descendant( + of: find.byKey(keys[i]), + matching: find.byType(EditableText), + )); + expect(editableText.groupId, groupIds[i]); + } + }, + ); + + testWidgets( + 'When the value of groupId is not passed in, the default type should be EditableText', + (WidgetTester tester) async { + final List keys = + List.generate(3, (_) => GlobalKey()); + final List inputFields = [ + TextFormField(key: keys[0]), + CupertinoTextField(key: keys[1]), + TextField(key: keys[2]), + ]; + + await tester.pumpWidget( + MaterialApp( + home: Align( + alignment: Alignment.topLeft, + child: Column( + children: inputFields.map((Widget child) { + return Material(child: child); + }).toList(), + ), + ), + ), + ); + + await tester.pump(); + + for (int i = 0; i < 3; i++) { + final EditableText editableText = tester.widget(find.descendant( + of: find.byKey(keys[i]), + matching: find.byType(EditableText), + )); + expect(editableText.groupId == EditableText, true); + } + }, + ); + }); + // Regression test for https://github.com/flutter/flutter/issues/126312. testWidgets('when open input connection in didUpdateWidget, should not throw', (WidgetTester tester) async { final Key key = GlobalKey();