From f5e4d2b4275b2be4712d77f4e5e5604ae394409c Mon Sep 17 00:00:00 2001 From: Greg Spencer Date: Fri, 29 Jul 2022 09:00:07 -0700 Subject: [PATCH] Replace FocusTrap with TapRegionSurface (#107262) --- dev/manual_tests/lib/density.dart | 12 +- .../tap_region/text_field_tap_region.0.dart | 250 ++++++++ .../text_field_tap_region.0_test.dart | 100 ++++ .../lib/src/cupertino/bottom_tab_bar.dart | 36 +- .../flutter/lib/src/cupertino/text_field.dart | 32 +- .../flutter/lib/src/material/text_field.dart | 31 +- packages/flutter/lib/src/widgets/app.dart | 7 +- .../lib/src/widgets/editable_text.dart | 259 +++++--- packages/flutter/lib/src/widgets/routes.dart | 251 +------- .../flutter/lib/src/widgets/tap_region.dart | 562 ++++++++++++++++++ .../lib/src/widgets/text_selection.dart | 41 +- packages/flutter/lib/widgets.dart | 1 + .../test/cupertino/text_field_test.dart | 140 +++++ .../flutter/test/material/debug_test.dart | 2 +- .../test/material/input_decorator_test.dart | 6 +- .../test/material/text_field_focus_test.dart | 31 +- .../test/material/text_field_test.dart | 220 +++++++ .../widgets/editable_text_shortcuts_test.dart | 1 + .../test/widgets/editable_text_test.dart | 26 +- .../flutter/test/widgets/routes_test.dart | 203 ------- .../flutter/test/widgets/tap_region_test.dart | 188 ++++++ 21 files changed, 1808 insertions(+), 591 deletions(-) create mode 100644 examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart create mode 100644 examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart create mode 100644 packages/flutter/lib/src/widgets/tap_region.dart create mode 100644 packages/flutter/test/widgets/tap_region_test.dart diff --git a/dev/manual_tests/lib/density.dart b/dev/manual_tests/lib/density.dart index 914c2d723d..6800447279 100644 --- a/dev/manual_tests/lib/density.dart +++ b/dev/manual_tests/lib/density.dart @@ -631,13 +631,11 @@ class _MyHomePageState extends State { data: Theme.of(context).copyWith(visualDensity: _model.density), child: Directionality( textDirection: _model.rtl ? TextDirection.rtl : TextDirection.ltr, - child: Scrollbar( - child: MediaQuery( - data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size), - child: SizedBox.expand( - child: ListView( - children: tiles, - ), + child: MediaQuery( + data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size), + child: SizedBox.expand( + child: ListView( + children: tiles, ), ), ), diff --git a/examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart b/examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart new file mode 100644 index 0000000000..a935e17807 --- /dev/null +++ b/examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart @@ -0,0 +1,250 @@ +// 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. + +/// Flutter code sample for [TextFieldTapRegion]. + +import 'package:flutter/material.dart'; +import 'package:flutter/services.dart'; + +void main() => runApp(const TapRegionApp()); + +class TapRegionApp extends StatelessWidget { + const TapRegionApp({super.key}); + + @override + Widget build(BuildContext context) { + return MaterialApp( + home: Scaffold( + appBar: AppBar(title: const Text('TextFieldTapRegion Example')), + body: const TextFieldTapRegionExample(), + ), + ); + } +} + +class TextFieldTapRegionExample extends StatefulWidget { + const TextFieldTapRegionExample({super.key}); + + @override + State createState() => _TextFieldTapRegionExampleState(); +} + +class _TextFieldTapRegionExampleState extends State { + int value = 0; + + @override + Widget build(BuildContext context) { + return ListView( + children: [ + Center( + child: Padding( + padding: const EdgeInsets.all(20.0), + child: SizedBox( + width: 150, + height: 80, + child: IntegerSpinnerField( + value: value, + autofocus: true, + onChanged: (int newValue) { + if (value == newValue) { + // Avoid unnecessary redraws. + return; + } + setState(() { + // Update the value and redraw. + value = newValue; + }); + }, + ), + ), + ), + ), + ], + ); + } +} + +/// An integer example of the generic [SpinnerField] that validates input and +/// increments by a delta. +class IntegerSpinnerField extends StatelessWidget { + const IntegerSpinnerField({ + super.key, + required this.value, + this.autofocus = false, + this.delta = 1, + this.onChanged, + }); + + final int value; + final bool autofocus; + final int delta; + final ValueChanged? onChanged; + + @override + Widget build(BuildContext context) { + return SpinnerField( + value: value, + onChanged: onChanged, + autofocus: autofocus, + fromString: (String stringValue) => int.tryParse(stringValue) ?? value, + increment: (int i) => i + delta, + decrement: (int i) => i - delta, + // Add a text formatter that only allows integer values and a leading + // minus sign. + inputFormatters: [ + TextInputFormatter.withFunction( + (TextEditingValue oldValue, TextEditingValue newValue) { + String newString; + if (newValue.text.startsWith('-')) { + newString = '-${newValue.text.replaceAll(RegExp(r'\D'), '')}'; + } else { + newString = newValue.text.replaceAll(RegExp(r'\D'), ''); + } + return newValue.copyWith( + text: newString, + selection: newValue.selection.copyWith( + baseOffset: newValue.selection.baseOffset.clamp(0, newString.length), + extentOffset: newValue.selection.extentOffset.clamp(0, newString.length), + ), + ); + }, + ) + ], + ); + } +} + +/// A generic "spinner" field example which adds extra buttons next to a +/// [TextField] to increment and decrement the value. +/// +/// This widget uses [TextFieldTapRegion] to indicate that tapping on the +/// spinner buttons should not cause the text field to lose focus. +class SpinnerField extends StatefulWidget { + SpinnerField({ + super.key, + required this.value, + required this.fromString, + this.autofocus = false, + String Function(T value)? asString, + this.increment, + this.decrement, + this.onChanged, + this.inputFormatters = const [], + }) : asString = asString ?? ((T value) => value.toString()); + + final T value; + final T Function(T value)? increment; + final T Function(T value)? decrement; + final String Function(T value) asString; + final T Function(String value) fromString; + final ValueChanged? onChanged; + final List inputFormatters; + final bool autofocus; + + @override + State> createState() => _SpinnerFieldState(); +} + +class _SpinnerFieldState extends State> { + TextEditingController controller = TextEditingController(); + + @override + void initState() { + super.initState(); + _updateText(widget.asString(widget.value)); + } + + @override + void dispose() { + controller.dispose(); + super.dispose(); + } + + @override + void didUpdateWidget(covariant SpinnerField oldWidget) { + super.didUpdateWidget(oldWidget); + if (oldWidget.asString != widget.asString || oldWidget.value != widget.value) { + final String newText = widget.asString(widget.value); + _updateText(newText); + } + } + + void _updateText(String text, {bool collapsed = true}) { + if (text != controller.text) { + controller.value = TextEditingValue( + text: text, + selection: collapsed + ? TextSelection.collapsed(offset: text.length) + : TextSelection(baseOffset: 0, extentOffset: text.length), + ); + } + } + + void _spin(T Function(T value)? spinFunction) { + if (spinFunction == null) { + return; + } + final T newValue = spinFunction(widget.value); + widget.onChanged?.call(newValue); + _updateText(widget.asString(newValue), collapsed: false); + } + + void _increment() { + _spin(widget.increment); + } + + void _decrement() { + _spin(widget.decrement); + } + + @override + Widget build(BuildContext context) { + return CallbackShortcuts( + bindings: { + const SingleActivator(LogicalKeyboardKey.arrowUp): _increment, + const SingleActivator(LogicalKeyboardKey.arrowDown): _decrement, + }, + child: Row( + children: [ + Expanded( + child: TextField( + autofocus: widget.autofocus, + inputFormatters: widget.inputFormatters, + decoration: const InputDecoration( + border: OutlineInputBorder(), + ), + onChanged: (String value) => widget.onChanged?.call(widget.fromString(value)), + controller: controller, + textAlign: TextAlign.center, + ), + ), + const SizedBox(width: 12), + // Without this TextFieldTapRegion, tapping on the buttons below would + // increment the value, but it would cause the text field to be + // unfocused, since tapping outside of a text field should unfocus it + // on non-mobile platforms. + TextFieldTapRegion( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Expanded( + child: OutlinedButton( + onPressed: _increment, + child: const Icon(Icons.add), + ), + ), + Expanded( + child: OutlinedButton( + onPressed: _decrement, + child: const Icon(Icons.remove), + ), + ), + ], + ), + ) + ], + ), + ); + } +} diff --git a/examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart b/examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart new file mode 100644 index 0000000000..bd1e91f9a5 --- /dev/null +++ b/examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart @@ -0,0 +1,100 @@ +// 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/tap_region/text_field_tap_region.0.dart' as example; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('shows a text field with a zero count, and the spinner buttons', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TapRegionApp(), + ); + + expect(find.byType(TextField), findsOneWidget); + expect(getFieldValue(tester).text, equals('0')); + expect(find.byIcon(Icons.add), findsOneWidget); + expect(find.byIcon(Icons.remove), findsOneWidget); + }); + + testWidgets('tapping increment/decrement works', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TapRegionApp(), + ); + await tester.pump(); + + expect(getFieldValue(tester).text, equals('0')); + expect( + getFieldValue(tester).selection, + equals(const TextSelection.collapsed(offset: 1)), + ); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(getFieldValue(tester).text, equals('1')); + expect( + getFieldValue(tester).selection, + equals(const TextSelection(baseOffset: 0, extentOffset: 1)), + ); + + await tester.tap(find.byIcon(Icons.remove)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.remove)); + await tester.pumpAndSettle(); + + expect(getFieldValue(tester).text, equals('-1')); + expect( + getFieldValue(tester).selection, + equals(const TextSelection(baseOffset: 0, extentOffset: 2)), + ); + }); + + testWidgets('entering text and then incrementing/decrementing works', (WidgetTester tester) async { + await tester.pumpWidget( + const example.TapRegionApp(), + ); + await tester.pump(); + + await tester.tap(find.byIcon(Icons.add)); + await tester.pumpAndSettle(); + + expect(getFieldValue(tester).text, equals('1')); + expect( + getFieldValue(tester).selection, + equals(const TextSelection(baseOffset: 0, extentOffset: 1)), + ); + + await tester.enterText(find.byType(TextField), '123'); + await tester.pumpAndSettle(); + expect(getFieldValue(tester).text, equals('123')); + expect( + getFieldValue(tester).selection, + equals(const TextSelection.collapsed(offset: 3)), + ); + + await tester.tap(find.byIcon(Icons.remove)); + await tester.pumpAndSettle(); + await tester.tap(find.byIcon(Icons.remove)); + await tester.pumpAndSettle(); + + expect(getFieldValue(tester).text, equals('121')); + expect( + getFieldValue(tester).selection, + equals(const TextSelection(baseOffset: 0, extentOffset: 3)), + ); + final FocusNode textFieldFocusNode = Focus.of( + tester.element( + find.byWidgetPredicate((Widget widget) { + return widget.runtimeType.toString() == '_Editable'; + }), + ), + ); + expect(textFieldFocusNode.hasPrimaryFocus, isTrue); + }); +} + +TextEditingValue getFieldValue(WidgetTester tester) { + return (tester.widget(find.byType(TextField)) as TextField).controller!.value; +} diff --git a/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart b/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart index f2628feee0..2cb9c0fd42 100644 --- a/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart +++ b/packages/flutter/lib/src/cupertino/bottom_tab_bar.dart @@ -230,22 +230,26 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget { _wrapActiveItem( context, Expanded( - child: Semantics( - selected: active, - hint: localizations.tabSemanticsLabel( - tabIndex: index + 1, - tabCount: items.length, - ), - child: MouseRegion( - cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, - child: GestureDetector( - behavior: HitTestBehavior.opaque, - onTap: onTap == null ? null : () { onTap!(index); }, - child: Padding( - padding: const EdgeInsets.only(bottom: 4.0), - child: Column( - mainAxisAlignment: MainAxisAlignment.end, - children: _buildSingleTabItem(items[index], active), + // Make tab items part of the EditableText tap region so that + // switching tabs doesn't unfocus text fields. + child: TextFieldTapRegion( + child: Semantics( + selected: active, + hint: localizations.tabSemanticsLabel( + tabIndex: index + 1, + tabCount: items.length, + ), + child: MouseRegion( + cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer, + child: GestureDetector( + behavior: HitTestBehavior.opaque, + onTap: onTap == null ? null : () { onTap!(index); }, + child: Padding( + padding: const EdgeInsets.only(bottom: 4.0), + child: Column( + mainAxisAlignment: MainAxisAlignment.end, + children: _buildSingleTabItem(items[index], active), + ), ), ), ), diff --git a/packages/flutter/lib/src/cupertino/text_field.dart b/packages/flutter/lib/src/cupertino/text_field.dart index 24e1c105e3..b32748b4bf 100644 --- a/packages/flutter/lib/src/cupertino/text_field.dart +++ b/packages/flutter/lib/src/cupertino/text_field.dart @@ -251,6 +251,7 @@ class CupertinoTextField extends StatefulWidget { this.onChanged, this.onEditingComplete, this.onSubmitted, + this.onTapOutside, this.inputFormatters, this.enabled, this.cursorWidth = 2.0, @@ -411,6 +412,7 @@ class CupertinoTextField extends StatefulWidget { this.onChanged, this.onEditingComplete, this.onSubmitted, + this.onTapOutside, this.inputFormatters, this.enabled, this.cursorWidth = 2.0, @@ -692,6 +694,9 @@ class CupertinoTextField extends StatefulWidget { /// the user is done editing. final ValueChanged? onSubmitted; + /// {@macro flutter.widgets.editableText.onTapOutside} + final TapRegionCallback? onTapOutside; + /// {@macro flutter.widgets.editableText.inputFormatters} final List? inputFormatters; @@ -1277,6 +1282,7 @@ class _CupertinoTextFieldState extends State with Restoratio onSelectionChanged: _handleSelectionChanged, onEditingComplete: widget.onEditingComplete, onSubmitted: widget.onSubmitted, + onTapOutside: widget.onTapOutside, inputFormatters: formatters, rendererIgnoresPointer: true, cursorWidth: widget.cursorWidth, @@ -1315,18 +1321,20 @@ class _CupertinoTextFieldState extends State with Restoratio _requestKeyboard(); }, onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, - child: IgnorePointer( - ignoring: !enabled, - child: Container( - decoration: effectiveDecoration, - color: !enabled && effectiveDecoration == null ? disabledColor : null, - child: _selectionGestureDetectorBuilder.buildGestureDetector( - behavior: HitTestBehavior.translucent, - child: Align( - alignment: Alignment(-1.0, _textAlignVertical.y), - widthFactor: 1.0, - heightFactor: 1.0, - child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle), + child: TextFieldTapRegion( + child: IgnorePointer( + ignoring: !enabled, + child: Container( + decoration: effectiveDecoration, + color: !enabled && effectiveDecoration == null ? disabledColor : null, + child: _selectionGestureDetectorBuilder.buildGestureDetector( + behavior: HitTestBehavior.translucent, + child: Align( + alignment: Alignment(-1.0, _textAlignVertical.y), + widthFactor: 1.0, + heightFactor: 1.0, + child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle), + ), ), ), ), diff --git a/packages/flutter/lib/src/material/text_field.dart b/packages/flutter/lib/src/material/text_field.dart index 96bd3b0774..7d5408e7af 100644 --- a/packages/flutter/lib/src/material/text_field.dart +++ b/packages/flutter/lib/src/material/text_field.dart @@ -320,6 +320,7 @@ class TextField extends StatefulWidget { bool? enableInteractiveSelection, this.selectionControls, this.onTap, + this.onTapOutside, this.mouseCursor, this.buildCounter, this.scrollController, @@ -675,6 +676,24 @@ class TextField extends StatefulWidget { /// {@endtemplate} final GestureTapCallback? onTap; + /// {@macro flutter.widgets.editableText.onTapOutside} + /// + /// {@tool dartpad} + /// This example shows how to use a `TextFieldTapRegion` to wrap a set of + /// "spinner" buttons that increment and decrement a value in the [TextField] + /// without causing the text field to lose keyboard focus. + /// + /// This example includes a generic `SpinnerField` class that you can copy + /// into your own project and customize. + /// + /// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TapRegion] for how the region group is determined. + final TapRegionCallback? onTapOutside; + /// The cursor for a mouse pointer when it enters or is hovering over the /// widget. /// @@ -1267,6 +1286,7 @@ class _TextFieldState extends State with RestorationMixin implements onSubmitted: widget.onSubmitted, onAppPrivateCommand: widget.onAppPrivateCommand, onSelectionHandleTapped: _handleSelectionHandleTapped, + onTapOutside: widget.onTapOutside, inputFormatters: formatters, rendererIgnoresPointer: true, mouseCursor: MouseCursor.defer, // TextField will handle the cursor @@ -1334,12 +1354,11 @@ class _TextFieldState extends State with RestorationMixin implements semanticsMaxValueLength = null; } - return FocusTrapArea( - focusNode: focusNode, - child: MouseRegion( - cursor: effectiveMouseCursor, - onEnter: (PointerEnterEvent event) => _handleHover(true), - onExit: (PointerExitEvent event) => _handleHover(false), + return MouseRegion( + cursor: effectiveMouseCursor, + onEnter: (PointerEnterEvent event) => _handleHover(true), + onExit: (PointerExitEvent event) => _handleHover(false), + child: TextFieldTapRegion( child: IgnorePointer( ignoring: !_isEnabled, child: AnimatedBuilder( diff --git a/packages/flutter/lib/src/widgets/app.dart b/packages/flutter/lib/src/widgets/app.dart index b3429da677..ad3a52e71b 100644 --- a/packages/flutter/lib/src/widgets/app.dart +++ b/packages/flutter/lib/src/widgets/app.dart @@ -26,6 +26,7 @@ import 'scrollable.dart'; import 'semantics_debugger.dart'; import 'shared_app_data.dart'; import 'shortcuts.dart'; +import 'tap_region.dart'; import 'text.dart'; import 'title.dart'; import 'widget_inspector.dart'; @@ -1740,8 +1741,10 @@ class _WidgetsAppState extends State with WidgetsBindingObserver { actions: widget.actions ?? WidgetsApp.defaultActions, child: FocusTraversalGroup( policy: ReadingOrderTraversalPolicy(), - child: ShortcutRegistrar( - child: child, + child: TapRegionSurface( + child: ShortcutRegistrar( + child: child, + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/editable_text.dart b/packages/flutter/lib/src/widgets/editable_text.dart index 37801f2765..c18376a189 100644 --- a/packages/flutter/lib/src/widgets/editable_text.dart +++ b/packages/flutter/lib/src/widgets/editable_text.dart @@ -32,6 +32,7 @@ import 'scroll_controller.dart'; import 'scroll_physics.dart'; import 'scrollable.dart'; import 'shortcuts.dart'; +import 'tap_region.dart'; import 'text.dart'; import 'text_editing_intents.dart'; import 'text_selection.dart'; @@ -608,6 +609,7 @@ class EditableText extends StatefulWidget { this.onAppPrivateCommand, this.onSelectionChanged, this.onSelectionHandleTapped, + this.onTapOutside, List? inputFormatters, this.mouseCursor, this.rendererIgnoresPointer = false, @@ -1213,6 +1215,46 @@ class EditableText extends StatefulWidget { /// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped} final VoidCallback? onSelectionHandleTapped; + /// {@template flutter.widgets.editableText.onTapOutside} + /// 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. + /// + /// When adding additional controls to a text field (for example, a spinner, a + /// button that copies the selected text, or modifies formatting), it is + /// helpful if tapping on that control doesn't unfocus the text field. In + /// order for an external widget to be considered as part of the text field + /// for the purposes of tapping "outside" of the field, wrap the control in a + /// [TextFieldTapRegion]. + /// + /// The [PointerDownEvent] 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 + /// within the bounding box of a [TextFieldTapRegion] member. + /// {@endtemplate} + /// + /// {@tool dartpad} + /// This example shows how to use a `TextFieldTapRegion` to wrap a set of + /// "spinner" buttons that increment and decrement a value in the [TextField] + /// without causing the text field to lose keyboard focus. + /// + /// This example includes a generic `SpinnerField` class that you can copy + /// into your own project and customize. + /// + /// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** + /// {@end-tool} + /// + /// See also: + /// + /// * [TapRegion] for how the region group is determined. + final TapRegionCallback? onTapOutside; + /// {@template flutter.widgets.editableText.inputFormatters} /// Optional input validation and formatting overrides. /// @@ -3421,6 +3463,43 @@ class EditableTextState extends State with AutomaticKeepAliveClien return Actions.invoke(context, intent); } + + /// The default behavior used if [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(); + } + break; + case ui.PointerDeviceKind.mouse: + case ui.PointerDeviceKind.stylus: + case ui.PointerDeviceKind.invertedStylus: + case ui.PointerDeviceKind.unknown: + widget.focusNode.unfocus(); + break; + case ui.PointerDeviceKind.trackpad: + throw UnimplementedError('Unexpected pointer down event for trackpad'); + } + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + widget.focusNode.unfocus(); + break; + } + } + late final Map> _actions = >{ DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), ReplaceTextIntent: _replaceTextAction, @@ -3458,96 +3537,100 @@ class EditableTextState extends State with AutomaticKeepAliveClien super.build(context); // See AutomaticKeepAliveClientMixin. final TextSelectionControls? controls = widget.selectionControls; - return MouseRegion( - cursor: widget.mouseCursor ?? SystemMouseCursors.text, - child: Actions( - actions: _actions, - child: _TextEditingHistory( - controller: widget.controller, - onTriggered: (TextEditingValue value) { - userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); - }, - child: Focus( - focusNode: widget.focusNode, - includeSemantics: false, - debugLabel: 'EditableText', - child: Scrollable( - 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: _Editable( - key: _editableKey, - startHandleLayerLink: _startHandleLayerLink, - endHandleLayerLink: _endHandleLayerLink, - inlineSpan: buildTextSpan(), - value: _value, - cursorColor: _cursorColor, - backgroundCursorColor: widget.backgroundCursorColor, - showCursor: EditableText.debugDeterministicCursor - ? ValueNotifier(widget.showCursor) - : _cursorVisibilityNotifier, - forceLine: widget.forceLine, - readOnly: widget.readOnly, - hasFocus: _hasFocus, - maxLines: widget.maxLines, - minLines: widget.minLines, - expands: widget.expands, - strutStyle: widget.strutStyle, - selectionColor: widget.selectionColor, - textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), - textAlign: widget.textAlign, - textDirection: _textDirection, - locale: widget.locale, - textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), - textWidthBasis: widget.textWidthBasis, - obscuringCharacter: widget.obscuringCharacter, - obscureText: widget.obscureText, - offset: offset, - onCaretChanged: _handleCaretChanged, - 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, + return TextFieldTapRegion( + onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside, + debugLabel: kReleaseMode ? null : 'EditableText', + child: MouseRegion( + cursor: widget.mouseCursor ?? SystemMouseCursors.text, + child: Actions( + actions: _actions, + child: _TextEditingHistory( + controller: widget.controller, + onTriggered: (TextEditingValue value) { + userUpdateTextEditingValue(value, SelectionChangedCause.keyboard); + }, + child: Focus( + focusNode: widget.focusNode, + includeSemantics: false, + debugLabel: kReleaseMode ? null : 'EditableText', + child: Scrollable( + 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: _Editable( + key: _editableKey, + startHandleLayerLink: _startHandleLayerLink, + endHandleLayerLink: _endHandleLayerLink, + inlineSpan: buildTextSpan(), + value: _value, + cursorColor: _cursorColor, + backgroundCursorColor: widget.backgroundCursorColor, + showCursor: EditableText.debugDeterministicCursor + ? ValueNotifier(widget.showCursor) + : _cursorVisibilityNotifier, + forceLine: widget.forceLine, + readOnly: widget.readOnly, + hasFocus: _hasFocus, + maxLines: widget.maxLines, + minLines: widget.minLines, + expands: widget.expands, + strutStyle: widget.strutStyle, + selectionColor: widget.selectionColor, + textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context), + textAlign: widget.textAlign, + textDirection: _textDirection, + locale: widget.locale, + textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context), + textWidthBasis: widget.textWidthBasis, + obscuringCharacter: widget.obscuringCharacter, + obscureText: widget.obscureText, + offset: offset, + onCaretChanged: _handleCaretChanged, + 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, + ), ), ), - ), - ); - }, + ); + }, + ), ), ), ), diff --git a/packages/flutter/lib/src/widgets/routes.dart b/packages/flutter/lib/src/widgets/routes.dart index 45a3079d74..62f876799f 100644 --- a/packages/flutter/lib/src/widgets/routes.dart +++ b/packages/flutter/lib/src/widgets/routes.dart @@ -6,7 +6,6 @@ import 'dart:async'; import 'dart:ui' as ui; import 'package:flutter/foundation.dart'; -import 'package:flutter/gestures.dart'; import 'package:flutter/rendering.dart'; import 'package:flutter/scheduler.dart'; import 'package:flutter/services.dart'; @@ -886,45 +885,42 @@ class _ModalScopeState extends State<_ModalScope> { controller: primaryScrollController, child: FocusScope( node: focusScopeNode, // immutable - child: FocusTrap( - focusScopeNode: focusScopeNode, - child: RepaintBoundary( - child: AnimatedBuilder( - animation: _listenable, // immutable - builder: (BuildContext context, Widget? child) { - return widget.route.buildTransitions( - context, - widget.route.animation!, - widget.route.secondaryAnimation!, - // This additional AnimatedBuilder is include because if the - // value of the userGestureInProgressNotifier changes, it's - // only necessary to rebuild the IgnorePointer widget and set - // the focus node's ability to focus. - AnimatedBuilder( - animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier(false), - builder: (BuildContext context, Widget? child) { - final bool ignoreEvents = _shouldIgnoreFocusRequest; - focusScopeNode.canRequestFocus = !ignoreEvents; - return IgnorePointer( - ignoring: ignoreEvents, - child: child, - ); - }, - child: child, - ), - ); - }, - child: _page ??= RepaintBoundary( - key: widget.route._subtreeKey, // immutable - child: Builder( - builder: (BuildContext context) { - return widget.route.buildPage( - context, - widget.route.animation!, - widget.route.secondaryAnimation!, + child: RepaintBoundary( + child: AnimatedBuilder( + animation: _listenable, // immutable + builder: (BuildContext context, Widget? child) { + return widget.route.buildTransitions( + context, + widget.route.animation!, + widget.route.secondaryAnimation!, + // This additional AnimatedBuilder is include because if the + // value of the userGestureInProgressNotifier changes, it's + // only necessary to rebuild the IgnorePointer widget and set + // the focus node's ability to focus. + AnimatedBuilder( + animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier(false), + builder: (BuildContext context, Widget? child) { + final bool ignoreEvents = _shouldIgnoreFocusRequest; + focusScopeNode.canRequestFocus = !ignoreEvents; + return IgnorePointer( + ignoring: ignoreEvents, + child: child, ); }, + child: child, ), + ); + }, + child: _page ??= RepaintBoundary( + key: widget.route._subtreeKey, // immutable + child: Builder( + builder: (BuildContext context) { + return widget.route.buildPage( + context, + widget.route.animation!, + widget.route.secondaryAnimation!, + ); + }, ), ), ), @@ -2147,184 +2143,3 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation animation, Animation secondaryAnimation, Widget child); - -/// The [FocusTrap] widget removes focus when a mouse primary pointer makes contact with another -/// region of the screen. -/// -/// When a primary pointer makes contact with the screen, this widget determines if that pointer -/// contacted an existing focused widget. If not, this asks the [FocusScopeNode] to reset the -/// focus state. This allows [TextField]s and other focusable widgets to give up their focus -/// state, without creating a gesture detector that competes with others on screen. -/// -/// In cases where focus is conceptually larger than the focused render object, a [FocusTrapArea] -/// can be used to expand the focus area to include all render objects below that. This is used by -/// the [TextField] widgets to prevent a loss of focus when interacting with decorations on the -/// text area. -/// -/// See also: -/// -/// * [FocusTrapArea], the widget that allows expanding the conceptual focus area. -class FocusTrap extends SingleChildRenderObjectWidget { - - /// Create a new [FocusTrap] widget scoped to the provided [focusScopeNode]. - const FocusTrap({ - required this.focusScopeNode, - required Widget super.child, - super.key, - }); - - /// The [focusScopeNode] that this focus trap widget operates on. - final FocusScopeNode focusScopeNode; - - @override - RenderObject createRenderObject(BuildContext context) { - return _RenderFocusTrap(focusScopeNode); - } - - @override - void updateRenderObject(BuildContext context, RenderObject renderObject) { - if (renderObject is _RenderFocusTrap) { - renderObject.focusScopeNode = focusScopeNode; - } - } -} - -/// Declares a widget subtree which is part of the provided [focusNode]'s focus area -/// without attaching focus to that region. -/// -/// This is used by text field widgets which decorate a smaller editable text area. -/// This area is conceptually part of the editable text, but not attached to the -/// focus context. The [FocusTrapArea] is used to inform the framework of this -/// relationship, so that primary pointer contact inside of this region but above -/// the editable text focus will not trigger loss of focus. -/// -/// See also: -/// -/// * [FocusTrap], the widget which removes focus based on primary pointer interactions. -class FocusTrapArea extends SingleChildRenderObjectWidget { - - /// Create a new [FocusTrapArea] that expands the area of the provided [focusNode]. - const FocusTrapArea({required this.focusNode, super.key, super.child}); - - /// The [FocusNode] that the focus trap area will expand to. - final FocusNode focusNode; - - @override - RenderObject createRenderObject(BuildContext context) { - return _RenderFocusTrapArea(focusNode); - } - - @override - void updateRenderObject(BuildContext context, RenderObject renderObject) { - if (renderObject is _RenderFocusTrapArea) { - renderObject.focusNode = focusNode; - } - } -} - -class _RenderFocusTrapArea extends RenderProxyBox { - _RenderFocusTrapArea(this.focusNode); - - FocusNode focusNode; -} - -class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior { - _RenderFocusTrap(this._focusScopeNode); - - Rect? currentFocusRect; - Expando cachedResults = Expando(); - - FocusScopeNode _focusScopeNode; - FocusNode? _previousFocus; - FocusScopeNode get focusScopeNode => _focusScopeNode; - set focusScopeNode(FocusScopeNode value) { - if (focusScopeNode == value) { - return; - } - _focusScopeNode = value; - } - - @override - bool hitTest(BoxHitTestResult result, { required Offset position }) { - bool hitTarget = false; - if (size.contains(position)) { - hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position); - if (hitTarget) { - final BoxHitTestEntry entry = BoxHitTestEntry(this, position); - cachedResults[entry] = result; - result.add(entry); - } - } - return hitTarget; - } - - /// The focus dropping behavior is only present on desktop platforms - /// and mobile browsers. - bool get _shouldIgnoreEvents { - switch (defaultTargetPlatform) { - case TargetPlatform.android: - case TargetPlatform.iOS: - return !kIsWeb; - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - case TargetPlatform.fuchsia: - return false; - } - } - - void _checkForUnfocus() { - if (_previousFocus == null) { - return; - } - // Only continue to unfocus if the previous focus matches the current focus. - // If the focus has changed in the meantime, it was probably intentional. - if (FocusManager.instance.primaryFocus == _previousFocus) { - _previousFocus!.unfocus(); - } - _previousFocus = null; - } - - @override - void handleEvent(PointerEvent event, HitTestEntry entry) { - assert(debugHandleEvent(event, entry)); - if (event is! PointerDownEvent - || event.buttons != kPrimaryButton - || event.kind != PointerDeviceKind.mouse - || _shouldIgnoreEvents - || _focusScopeNode.focusedChild == null) { - return; - } - final BoxHitTestResult? result = cachedResults[entry]; - final FocusNode? focusNode = _focusScopeNode.focusedChild; - if (focusNode == null || result == null) { - return; - } - - final RenderObject? renderObject = focusNode.context?.findRenderObject(); - if (renderObject == null) { - return; - } - - bool hitCurrentFocus = false; - for (final HitTestEntry entry in result.path) { - final HitTestTarget target = entry.target; - if (target == renderObject) { - hitCurrentFocus = true; - break; - } - if (target is _RenderFocusTrapArea && target.focusNode == focusNode) { - hitCurrentFocus = true; - break; - } - } - if (!hitCurrentFocus) { - _previousFocus = focusNode; - // Check post-frame to see that the focus hasn't changed before - // unfocusing. This also allows a button tap to capture the previously - // active focus before FocusTrap tries to unfocus it, and avoids a bounce - // through the scope's focus node in between. - SchedulerBinding.instance.scheduleTask(_checkForUnfocus, Priority.touch); - } - } -} diff --git a/packages/flutter/lib/src/widgets/tap_region.dart b/packages/flutter/lib/src/widgets/tap_region.dart new file mode 100644 index 0000000000..4c50a7088a --- /dev/null +++ b/packages/flutter/lib/src/widgets/tap_region.dart @@ -0,0 +1,562 @@ +// 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/foundation.dart'; +import 'package:flutter/gestures.dart'; +import 'package:flutter/rendering.dart'; + +import 'editable_text.dart'; +import 'framework.dart'; + +// Enable if you want verbose logging about tap region changes. +const bool _kDebugTapRegion = false; + +bool _tapRegionDebug(String message, [Iterable? details]) { + if (_kDebugTapRegion) { + debugPrint('TAP REGION: $message'); + if (details != null && details.isNotEmpty) { + for (final String detail in details) { + debugPrint(' $detail'); + } + } + } + // Return true so that it can be easily used inside of an assert. + return true; +} + +/// The type of callback that [TapRegion.onTapOutside] and +/// [TapRegion.onTapInside] take. +/// +/// The event is the pointer event that caused the callback to be called. +typedef TapRegionCallback = void Function(PointerDownEvent event); + +/// An interface for registering and unregistering a [RenderTapRegion] +/// (typically created with a [TapRegion] widget) with a +/// [RenderTapRegionSurface] (typically created with a [TapRegionSurface] +/// widget). +abstract class TapRegionRegistry { + /// Register the given [RenderTapRegion] with the registry. + void registerTapRegion(RenderTapRegion region); + + /// Unregister the given [RenderTapRegion] with the registry. + void unregisterTapRegion(RenderTapRegion region); + + /// Allows finding of the nearest [TapRegionRegistry], such as a + /// [RenderTapRegionSurface]. + /// + /// Will throw if a [TapRegionRegistry] isn't found. + static TapRegionRegistry of(BuildContext context) { + final TapRegionRegistry? registry = maybeOf(context); + assert(() { + if (registry == null) { + throw FlutterError( + 'TapRegionRegistry.of() was called with a context that does not contain a TapRegionSurface widget.\n' + 'No TapRegionSurface widget ancestor could be found starting from the context that was passed to ' + 'TapRegionRegistry.of().\n' + 'The context used was:\n' + ' $context', + ); + } + return true; + }()); + return registry!; + } + + /// Allows finding of the nearest [TapRegionRegistry], such as a + /// [RenderTapRegionSurface]. + static TapRegionRegistry? maybeOf(BuildContext context) { + return context.findAncestorRenderObjectOfType(); + } +} + +/// A widget that provides notification of a tap inside or outside of a set of +/// registered regions, without participating in the [gesture +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) +/// system. +/// +/// The regions are defined by adding [TapRegion] widgets to the widget tree +/// around the regions of interest, and they will register with this +/// `TapRegionSurface`. Each of the tap regions can optionally belong to a group +/// by assigning a [TapRegion.groupId], where all the regions with the same +/// groupId act as if they were all one region. +/// +/// When a tap outside of a registered region or region group is detected, its +/// [TapRegion.onTapOutside] callback is called. If the tap is outside one +/// member of a group, but inside another, no notification is made. +/// +/// When a tap inside of a registered region or region group is detected, its +/// [TapRegion.onTapInside] callback is called. If the tap is inside one member +/// of a group, all members are notified. +/// +/// The `TapRegionSurface` should be defined at the highest level needed to +/// encompass the entire area where taps should be monitored. This is typically +/// around the entire app. If the entire app isn't covered, then taps outside of +/// the `TapRegionSurface` will be ignored and no [TapRegion.onTapOutside] calls +/// wil be made for those events. The [WidgetsApp], [MaterialApp] and +/// [CupertinoApp] automatically include a `TapRegionSurface` around their +/// entire app. +/// +/// [TapRegionSurface] does not participate in the [gesture +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) +/// system, so if multiple [TapRegionSurface]s are active at the same time, they +/// will all fire, and so will any other gestures recognized by a +/// [GestureDetector] or other pointer event handlers. +/// +/// [TapRegion]s register only with the nearest ancestor `TapRegionSurface`. +/// +/// See also: +/// +/// * [RenderTapRegionSurface], the render object that is inserted into the +/// render tree by this widget. +/// * for more +/// information about the gesture system and how it disambiguates inputs. +class TapRegionSurface extends SingleChildRenderObjectWidget { + /// Creates a const [RenderTapRegionSurface]. + /// + /// The [child] attribute is required. + const TapRegionSurface({ + super.key, + required Widget super.child, + }); + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderTapRegionSurface(); + } + + @override + void updateRenderObject( + BuildContext context, + RenderProxyBoxWithHitTestBehavior renderObject, + ) {} +} + +/// A render object that provides notification of a tap inside or outside of a +/// set of registered regions, without participating in the [gesture +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) +/// system. +/// +/// The regions are defined by adding [RenderTapRegion] render objects in the +/// render tree around the regions of interest, and they will register with this +/// `RenderTapRegionSurface`. Each of the tap regions can optionally belong to a +/// group by assigning a [RenderTapRegion.groupId], where all the regions with +/// the same groupId act as if they were all one region. +/// +/// When a tap outside of a registered region or region group is detected, its +/// [TapRegion.onTapOutside] callback is called. If the tap is outside one +/// member of a group, but inside another, no notification is made. +/// +/// When a tap inside of a registered region or region group is detected, its +/// [TapRegion.onTapInside] callback is called. If the tap is inside one member +/// of a group, all members are notified. +/// +/// The `RenderTapRegionSurface` should be defined at the highest level needed +/// to encompass the entire area where taps should be monitored. This is +/// typically around the entire app. If the entire app isn't covered, then taps +/// outside of the `RenderTapRegionSurface` will be ignored and no +/// [RenderTapRegion.onTapOutside] calls wil be made for those events. The +/// [WidgetsApp], [MaterialApp] and [CupertinoApp] automatically include a +/// `RenderTapRegionSurface` around the entire app. +/// +/// `RenderTapRegionSurface` does not participate in the [gesture +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) +/// system, so if multiple `RenderTapRegionSurface`s are active at the same +/// time, they will all fire, and so will any other gestures recognized by a +/// [GestureDetector] or other pointer event handlers. +/// +/// [RenderTapRegion]s register only with the nearest ancestor +/// `RenderTapRegionSurface`. +/// +/// See also: +/// +/// * [TapRegionSurface], a widget that inserts a `RenderTapRegionSurface` into +/// the render tree. +/// * [TapRegionRegistry.of], which can find the nearest ancestor +/// [RenderTapRegionSurface], which is a [TapRegionRegistry]. +class RenderTapRegionSurface extends RenderProxyBoxWithHitTestBehavior with TapRegionRegistry { + final Expando _cachedResults = Expando(); + final Set _registeredRegions = {}; + final Map> _groupIdToRegions = >{}; + + @override + void registerTapRegion(RenderTapRegion region) { + assert(_tapRegionDebug('Region $region registered.')); + assert(!_registeredRegions.contains(region)); + _registeredRegions.add(region); + if (region.groupId != null) { + _groupIdToRegions[region.groupId] ??= {}; + _groupIdToRegions[region.groupId]!.add(region); + } + } + + @override + void unregisterTapRegion(RenderTapRegion region) { + assert(_tapRegionDebug('Region $region unregistered.')); + assert(_registeredRegions.contains(region)); + _registeredRegions.remove(region); + if (region.groupId != null) { + assert(_groupIdToRegions.containsKey(region.groupId)); + _groupIdToRegions[region.groupId]!.remove(region); + if (_groupIdToRegions[region.groupId]!.isEmpty) { + _groupIdToRegions.remove(region.groupId); + } + } + } + + @override + bool hitTest(BoxHitTestResult result, {required Offset position}) { + if (!size.contains(position)) { + return false; + } + + final bool hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position); + + if (hitTarget) { + final BoxHitTestEntry entry = BoxHitTestEntry(this, position); + _cachedResults[entry] = result; + result.add(entry); + } + + return hitTarget; + } + + @override + void handleEvent(PointerEvent event, HitTestEntry entry) { + assert(debugHandleEvent(event, entry)); + assert(() { + for (final RenderTapRegion region in _registeredRegions) { + if (!region.enabled) { + return false; + } + } + return true; + }(), 'A RenderTapRegion was registered when it was disabled.'); + + if (event is! PointerDownEvent || event.buttons != kPrimaryButton) { + return; + } + + if (_registeredRegions.isEmpty) { + assert(_tapRegionDebug('Ignored tap event because no regions are registered.')); + return; + } + + final BoxHitTestResult? result = _cachedResults[entry]; + + if (result == null) { + assert(_tapRegionDebug('Ignored tap event because no surface descendants were hit.')); + return; + } + + // A child was hit, so we need to call onTapOutside for those regions or + // groups of regions that were not hit. + final Set hitRegions = + _getRegionsHit(_registeredRegions, result.path).cast().toSet(); + final Set insideRegions = {}; + assert(_tapRegionDebug('Tap event hit ${hitRegions.length} descendants.')); + + for (final RenderTapRegion region in hitRegions) { + if (region.groupId == null) { + insideRegions.add(region); + continue; + } + // Add all grouped regions to the insideRegions so that groups act as a + // single region. + insideRegions.addAll(_groupIdToRegions[region.groupId]!); + } + // If they're not inside, then they're outside. + final Set outsideRegions = _registeredRegions.difference(insideRegions); + + for (final RenderTapRegion region in outsideRegions) { + assert(_tapRegionDebug('Calling onTapOutside for $region')); + region.onTapOutside?.call(event); + } + for (final RenderTapRegion region in insideRegions) { + assert(_tapRegionDebug('Calling onTapInside for $region')); + region.onTapInside?.call(event); + } + } + + // Returns the registered regions that are in the hit path. + Iterable _getRegionsHit(Set detectors, Iterable hitTestPath) { + final Set hitRegions = {}; + for (final HitTestEntry entry in hitTestPath) { + final HitTestTarget target = entry.target; + if (_registeredRegions.contains(target)) { + hitRegions.add(target); + } + } + return hitRegions; + } +} + +/// A widget that defines a region that can detect taps inside or outside of +/// itself and any group of regions it belongs to, without participating in the +/// [gesture disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) +/// system. +/// +/// This widget indicates to the nearest ancestor [TapRegionSurface] that the +/// region occupied by its child will participate in the tap detection for that +/// surface. +/// +/// If this region belongs to a group (by virtue of its [groupId]), all the +/// regions in the group will act as one. +/// +/// If there is no [TapRegionSurface] ancestor, [TapRegion] will do nothing. +class TapRegion extends SingleChildRenderObjectWidget { + /// Creates a const [TapRegion]. + /// + /// The [child] argument is required. + const TapRegion({ + super.key, + required super.child, + this.enabled = true, + this.onTapOutside, + this.onTapInside, + this.groupId, + String? debugLabel, + }) : debugLabel = kReleaseMode ? null : debugLabel; + + /// Whether or not this [TapRegion] is enabled as part of the composite region. + final bool enabled; + + /// A callback to be invoked when a tap is detected outside of this + /// [TapRegion] and any other region with the same [groupId], if any. + /// + /// The [PointerDownEvent] passed to the function is the event that caused the + /// notification. If this region is part of a group (i.e. [groupId] is set), + /// then it's possible that the event may be outside of this immediate region, + /// although it will be within the region of one of the group members. + final TapRegionCallback? onTapOutside; + + /// A callback to be invoked when a tap is detected inside of this + /// [TapRegion], or any other tap region with the same [groupId], if any. + /// + /// The [PointerDownEvent] passed to the function is the event that caused the + /// notification. If this region is part of a group (i.e. [groupId] is set), + /// then it's possible that the event may be outside of this immediate region, + /// although it will be within the region of one of the group members. + final TapRegionCallback? onTapInside; + + /// An optional group ID that groups [TapRegion]s together so that they + /// operate as one region. If any member of a group is hit by a particular + /// tap, then the [onTapOutside] will not be called for any members of the + /// group. If any member of the group is hit, then all members will have their + /// [onTapInside] called. + /// + /// If the group id is null, then only this region is hit tested. + final Object? groupId; + + /// An optional debug label to help with debugging in debug mode. + /// + /// Will be null in release mode. + final String? debugLabel; + + @override + RenderObject createRenderObject(BuildContext context) { + return RenderTapRegion( + registry: TapRegionRegistry.maybeOf(context), + enabled: enabled, + onTapOutside: onTapOutside, + onTapInside: onTapInside, + groupId: groupId, + debugLabel: debugLabel, + ); + } + + @override + void updateRenderObject(BuildContext context, covariant RenderTapRegion renderObject) { + renderObject.registry = TapRegionRegistry.maybeOf(context); + renderObject.enabled = enabled; + renderObject.groupId = groupId; + renderObject.onTapOutside = onTapOutside; + renderObject.onTapInside = onTapInside; + if (kReleaseMode) { + renderObject.debugLabel = debugLabel; + } + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('debugLabel', debugLabel, defaultValue: null)); + properties.add(DiagnosticsProperty('groupId', groupId, defaultValue: null)); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true)); + } +} + +/// A render object that defines a region that can detect taps inside or outside +/// of itself and any group of regions it belongs to, without participating in +/// the [gesture +/// disambiguation](https://flutter.dev/gestures/#gesture-disambiguation) +/// system. +/// +/// This render object indicates to the nearest ancestor [TapRegionSurface] that +/// the region occupied by its child will participate in the tap detection for +/// that surface. +/// +/// If this region belongs to a group (by virtue of its [groupId]), all the +/// regions in the group will act as one. +/// +/// If there is no [RenderTapRegionSurface] ancestor in the render tree, +/// `RenderTapRegion` will do nothing. +/// +/// See also: +/// +/// * [TapRegion], a widget that inserts a [RenderTapRegion] into the render +/// tree. +class RenderTapRegion extends RenderProxyBox with Diagnosticable { + /// Creates a [RenderTapRegion]. + RenderTapRegion({ + TapRegionRegistry? registry, + bool enabled = true, + this.onTapOutside, + this.onTapInside, + Object? groupId, + String? debugLabel, + }) : _registry = registry, + _enabled = enabled, + _groupId = groupId, + debugLabel = kReleaseMode ? null : debugLabel; + + bool _isRegistered = false; + + /// A callback to be invoked when a tap is detected outside of this + /// [RenderTapRegion] and any other region with the same [groupId], if any. + /// + /// The [PointerDownEvent] passed to the function is the event that caused the + /// notification. If this region is part of a group (i.e. [groupId] is set), + /// then it's possible that the event may be outside of this immediate region, + /// although it will be within the region of one of the group members. + TapRegionCallback? onTapOutside; + + /// A callback to be invoked when a tap is detected inside of this + /// [RenderTapRegion], or any other tap region with the same [groupId], if any. + /// + /// The [PointerDownEvent] passed to the function is the event that caused the + /// notification. If this region is part of a group (i.e. [groupId] is set), + /// then it's possible that the event may be outside of this immediate region, + /// although it will be within the region of one of the group members. + TapRegionCallback? onTapInside; + + /// A label used in debug builds. Will be null in release builds. + String? debugLabel; + + /// Whether or not this region should participate in the composite region. + bool get enabled => _enabled; + bool _enabled; + set enabled(bool value) { + if (_enabled != value) { + _enabled = value; + markNeedsLayout(); + } + } + + /// An optional group ID that groups [RenderTapRegion]s together so that they + /// operate as one region. If any member of a group is hit by a particular + /// tap, then the [onTapOutside] will not be called for any members of the + /// group. If any member of the group is hit, then all members will have their + /// [onTapInside] called. + /// + /// If the group id is null, then only this region is hit tested. + Object? get groupId => _groupId; + Object? _groupId; + set groupId(Object? value) { + if (_groupId != value) { + _groupId = value; + markNeedsLayout(); + } + } + + /// The registry that this [RenderTapRegion] should register with. + /// + /// If the `registry` is null, then this region will not be registered + /// anywhere, and will not do any tap detection. + /// + /// A [RenderTapRegionSurface] is a [TapRegionRegistry]. + TapRegionRegistry? get registry => _registry; + TapRegionRegistry? _registry; + set registry(TapRegionRegistry? value) { + if (_registry != value) { + if (_isRegistered) { + _registry!.unregisterTapRegion(this); + _isRegistered = false; + } + _registry = value; + markNeedsLayout(); + } + } + + @override + void layout(Constraints constraints, {bool parentUsesSize = false}) { + super.layout(constraints, parentUsesSize: parentUsesSize); + if (_registry == null) { + return; + } + if (_isRegistered) { + _registry!.unregisterTapRegion(this); + } + final bool shouldBeRegistered = _enabled && _registry != null; + if (shouldBeRegistered) { + _registry!.registerTapRegion(this); + } + _isRegistered = shouldBeRegistered; + } + + @override + void dispose() { + if (_isRegistered) { + _registry!.unregisterTapRegion(this); + } + super.dispose(); + } + + @override + void debugFillProperties(DiagnosticPropertiesBuilder properties) { + super.debugFillProperties(properties); + properties.add(DiagnosticsProperty('debugLabel', debugLabel, defaultValue: null)); + properties.add(DiagnosticsProperty('groupId', groupId, defaultValue: null)); + properties.add(FlagProperty('enabled', value: enabled, ifFalse: 'DISABLED', defaultValue: true)); + } +} + +/// A [TapRegion] that adds its children to the tap region group for widgets +/// based on the [EditableText] text editing widget, such as [TextField] and +/// [CupertinoTextField]. +/// +/// Widgets that are wrapped with a `TextFieldTapRegion` are considered to be +/// part of a text field for purposes of unfocus behavior. So, when the user +/// taps on them, the currently focused text field won't be unfocused by +/// default. This allows controls like spinners, copy buttons, and formatting +/// buttons to be associated with a text field without causing the text field to +/// lose focus when they are interacted with. +/// +/// {@tool dartpad} +/// This example shows how to use a `TextFieldTapRegion` to wrap a set of +/// "spinner" buttons that increment and decrement a value in the text field +/// without causing the text field to lose keyboard focus. +/// +/// This example includes a generic `SpinnerField` class that you can copy/paste +/// into your own project and customize. +/// +/// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart ** +/// {@end-tool} +/// +/// See also: +/// +/// * [TapRegion], the widget that this widget uses to add widgets to the group +/// of text fields. +class TextFieldTapRegion extends TapRegion { + /// Creates a const [TextFieldTapRegion]. + /// + /// The [child] field is required. + const TextFieldTapRegion({ + super.key, + required super.child, + super.enabled, + super.onTapOutside, + super.onTapInside, + super.debugLabel, + }) : super(groupId: EditableText); +} diff --git a/packages/flutter/lib/src/widgets/text_selection.dart b/packages/flutter/lib/src/widgets/text_selection.dart index 38691d5a9e..3473fcf4b2 100644 --- a/packages/flutter/lib/src/widgets/text_selection.dart +++ b/packages/flutter/lib/src/widgets/text_selection.dart @@ -20,6 +20,7 @@ import 'editable_text.dart'; import 'framework.dart'; import 'gesture_detector.dart'; import 'overlay.dart'; +import 'tap_region.dart'; import 'ticker_provider.dart'; import 'transitions.dart'; @@ -958,8 +959,10 @@ class SelectionOverlay { dragStartBehavior: dragStartBehavior, ); } - return ExcludeSemantics( - child: handle, + return TextFieldTapRegion( + child: ExcludeSemantics( + child: handle, + ), ); } @@ -983,8 +986,10 @@ class SelectionOverlay { dragStartBehavior: dragStartBehavior, ); } - return ExcludeSemantics( - child: handle, + return TextFieldTapRegion( + child: ExcludeSemantics( + child: handle, + ), ); } @@ -1015,19 +1020,21 @@ class SelectionOverlay { selectionEndpoints.first.point.dy - lineHeightAtStart, ); - return Directionality( - textDirection: Directionality.of(this.context), - child: _SelectionToolbarOverlay( - preferredLineHeight: lineHeightAtStart, - toolbarLocation: toolbarLocation, - layerLink: toolbarLayerLink, - editingRegion: editingRegion, - selectionControls: selectionControls, - midpoint: midpoint, - selectionEndpoints: selectionEndpoints, - visibility: toolbarVisible, - selectionDelegate: selectionDelegate, - clipboardStatus: clipboardStatus, + return TextFieldTapRegion( + child: Directionality( + textDirection: Directionality.of(this.context), + child: _SelectionToolbarOverlay( + preferredLineHeight: lineHeightAtStart, + toolbarLocation: toolbarLocation, + layerLink: toolbarLayerLink, + editingRegion: editingRegion, + selectionControls: selectionControls, + midpoint: midpoint, + selectionEndpoints: selectionEndpoints, + visibility: toolbarVisible, + selectionDelegate: selectionDelegate, + clipboardStatus: clipboardStatus, + ), ), ); } diff --git a/packages/flutter/lib/widgets.dart b/packages/flutter/lib/widgets.dart index 5779684801..7b41b63c2d 100644 --- a/packages/flutter/lib/widgets.dart +++ b/packages/flutter/lib/widgets.dart @@ -128,6 +128,7 @@ export 'src/widgets/slotted_render_object_widget.dart'; export 'src/widgets/spacer.dart'; export 'src/widgets/status_transitions.dart'; export 'src/widgets/table.dart'; +export 'src/widgets/tap_region.dart'; export 'src/widgets/text.dart'; export 'src/widgets/text_editing_intents.dart'; export 'src/widgets/text_selection.dart'; diff --git a/packages/flutter/test/cupertino/text_field_test.dart b/packages/flutter/test/cupertino/text_field_test.dart index 464a880e4e..1062f47c53 100644 --- a/packages/flutter/test/cupertino/text_field_test.dart +++ b/packages/flutter/test/cupertino/text_field_test.dart @@ -5960,4 +5960,144 @@ void main() { expect(controller.selection.extentOffset, 5); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); }); + + group('TapRegion integration', () { + testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: 100, + height: 100, + child: CupertinoTextField( + autofocus: true, + focusNode: focusNode, + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + // Tap outside the border. + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isFalse); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets("Tapping outside doesn't lose focus on mobile", (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: 100, + height: 100, + child: CupertinoTextField( + autofocus: true, + focusNode: focusNode, + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + // Tap just outside the border, but not inside the EditableText. + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + // Focus is lost on mobile browsers, but not mobile apps. + expect(focusNode.hasPrimaryFocus, kIsWeb ? isFalse : isTrue); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets("tapping on toolbar doesn't lose focus", (WidgetTester tester) async { + final TextEditingController controller; + final EditableTextState state; + + controller = TextEditingController(text: 'A B C'); + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + CupertinoApp( + debugShowCheckedModeBanner: false, + home: CupertinoPageScaffold( + child: Align( + child: SizedBox( + width: 200, + height: 200, + child: CupertinoTextField( + autofocus: true, + focusNode: focusNode, + controller: controller, + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + state = tester.state(find.byType(EditableText)); + + // Select the first 2 words. + state.renderEditable.selectPositionAt( + from: textOffsetToPosition(tester, 0), + to: textOffsetToPosition(tester, 2), + cause: SelectionChangedCause.tap, + ); + + final Offset midSelection = textOffsetToPosition(tester, 2); + + // Right click the selection. + final TestGesture gesture = await tester.startGesture( + midSelection, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + expect(find.text('Copy'), findsOneWidget); + + // Copy the first word. + await tester.tap(find.text('Copy')); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + }, + variant: TargetPlatformVariant.all(), + skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser. + ); + testWidgets("Tapping on border doesn't lose focus", (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + CupertinoApp( + home: Center( + child: SizedBox( + width: 100, + height: 100, + child: CupertinoTextField( + autofocus: true, + focusNode: focusNode, + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + final Rect borderBox = tester.getRect(find.byType(CupertinoTextField)); + // Tap just inside the border, but not inside the EditableText. + await tester.tapAt(borderBox.topLeft + const Offset(1, 1)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }, variant: TargetPlatformVariant.all()); + }); } diff --git a/packages/flutter/test/material/debug_test.dart b/packages/flutter/test/material/debug_test.dart index 4375e2b700..fb3a221c5f 100644 --- a/packages/flutter/test/material/debug_test.dart +++ b/packages/flutter/test/material/debug_test.dart @@ -134,7 +134,6 @@ void main() { ' _FadeUpwardsPageTransition\n' ' AnimatedBuilder\n' ' RepaintBoundary\n' - ' FocusTrap\n' ' _FocusMarker\n' ' Semantics\n' ' FocusScope\n' @@ -192,6 +191,7 @@ void main() { ' Focus\n' ' Shortcuts\n' ' ShortcutRegistrar\n' + ' TapRegionSurface\n' ' _FocusMarker\n' ' Focus\n' ' _FocusTraversalGroupMarker\n' diff --git a/packages/flutter/test/material/input_decorator_test.dart b/packages/flutter/test/material/input_decorator_test.dart index 69e607d876..db2de05c6e 100644 --- a/packages/flutter/test/material/input_decorator_test.dart +++ b/packages/flutter/test/material/input_decorator_test.dart @@ -5417,7 +5417,7 @@ void main() { final double floatedLabelWidth = getLabelRect(tester).width; - expect(floatedLabelWidth > labelWidth, isTrue); + expect(floatedLabelWidth, greaterThan(labelWidth)); final Widget target = getLabeledInputDecorator(FloatingLabelBehavior.auto); await tester.pumpWidget(target); @@ -5430,8 +5430,8 @@ void main() { // Default animation duration is 200 millisecond. await tester.pumpFrames(target, const Duration(milliseconds: 100)); - expect(getLabelRect(tester).width > labelWidth, isTrue); - expect(getLabelRect(tester).width < floatedLabelWidth, isTrue); + expect(getLabelRect(tester).width, greaterThan(labelWidth)); + expect(getLabelRect(tester).width, lessThanOrEqualTo(floatedLabelWidth)); await tester.pumpAndSettle(); diff --git a/packages/flutter/test/material/text_field_focus_test.dart b/packages/flutter/test/material/text_field_focus_test.dart index 75cdd8ba8f..6612b36fdc 100644 --- a/packages/flutter/test/material/text_field_focus_test.dart +++ b/packages/flutter/test/material/text_field_focus_test.dart @@ -186,7 +186,7 @@ void main() { expect(find.byType(TextField), findsOneWidget); expect(tester.testTextInput.isVisible, isTrue); - await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); + await tester.drag(find.byType(TextField), const Offset(0.0, -1000.0)); await tester.pump(); expect(find.byType(TextField, skipOffstage: false), findsOneWidget); expect(tester.testTextInput.isVisible, isTrue); @@ -225,7 +225,7 @@ void main() { FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode); await tester.pump(); expect(find.byType(TextField), findsOneWidget); - await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0)); + await tester.drag(find.byType(TextField), const Offset(0.0, -1000.0)); await tester.pump(); expect(find.byType(TextField, skipOffstage: false), findsOneWidget); await tester.pumpWidget(makeTest('test')); @@ -490,8 +490,8 @@ void main() { }, variant: TargetPlatformVariant.desktop()); testWidgets('A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop after tab navigation', (WidgetTester tester) async { - final FocusNode focusNodeA = FocusNode(); - final FocusNode focusNodeB = FocusNode(); + final FocusNode focusNodeA = FocusNode(debugLabel: 'A'); + final FocusNode focusNodeB = FocusNode(debugLabel: 'B'); final Key key = UniqueKey(); await tester.pumpWidget( @@ -518,30 +518,33 @@ void main() { ); // Tab over to the 3rd text field. for (int i = 0; i < 3; i += 1) { - await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); - await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); + await tester.sendKeyEvent(LogicalKeyboardKey.tab); + await tester.pump(); + } + + Future click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + await gesture.removePointer(); } expect(focusNodeA.hasFocus, true); expect(focusNodeB.hasFocus, false); // Click on the container to not hit either text field. - final TestGesture down2 = await tester.startGesture(tester.getCenter(find.byKey(key)), kind: PointerDeviceKind.mouse); + await click(find.byKey(key)); await tester.pump(); - await tester.pumpAndSettle(); - await down2.up(); - await down2.removePointer(); expect(focusNodeA.hasFocus, false); expect(focusNodeB.hasFocus, false); // Second text field can still gain focus. - final TestGesture down3 = await tester.startGesture(tester.getCenter(find.byType(TextField).last), kind: PointerDeviceKind.mouse); + await click(find.byType(TextField).last); await tester.pump(); - await tester.pumpAndSettle(); - await down3.up(); - await down3.removePointer(); expect(focusNodeA.hasFocus, false); expect(focusNodeB.hasFocus, true); diff --git a/packages/flutter/test/material/text_field_test.dart b/packages/flutter/test/material/text_field_test.dart index a4c0684c3e..e098749ee5 100644 --- a/packages/flutter/test/material/text_field_test.dart +++ b/packages/flutter/test/material/text_field_test.dart @@ -11785,4 +11785,224 @@ void main() { expect(controller.selection.extentOffset, 5); }, variant: const TargetPlatformVariant({ TargetPlatform.iOS, TargetPlatform.macOS })); }); + group('TapRegion integration', () { + testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: Opacity( + opacity: 0.5, + child: TextField( + autofocus: true, + focusNode: focusNode, + decoration: const InputDecoration( + hintText: 'Placeholder', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isFalse); + }, variant: TargetPlatformVariant.desktop()); + + testWidgets("Tapping outside doesn't lose focus on mobile", (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: Opacity( + opacity: 0.5, + child: TextField( + autofocus: true, + focusNode: focusNode, + decoration: const InputDecoration( + hintText: 'Placeholder', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + await tester.tapAt(const Offset(10, 10)); + await tester.pump(); + + // Focus is lost on mobile browsers, but not mobile apps. + expect(focusNode.hasPrimaryFocus, kIsWeb ? isFalse : isTrue); + }, variant: TargetPlatformVariant.mobile()); + + testWidgets("Tapping on toolbar doesn't lose focus", (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + final TextEditingController controller = TextEditingController(text: 'A B C'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: Opacity( + opacity: 0.5, + child: TextField( + controller: controller, + focusNode: focusNode, + decoration: const InputDecoration(hintText: 'Placeholder'), + ), + ), + ), + ), + ), + ), + ); + + // The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar. + final EditableTextState state = tester.state(find.byType(EditableText)); + state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap); + + final Offset aPosition = textOffsetToPosition(tester, 1); + + // Right clicking shows the menu. + final TestGesture gesture = await tester.startGesture( + aPosition, + kind: PointerDeviceKind.mouse, + buttons: kSecondaryMouseButton, + ); + await tester.pump(); + await gesture.up(); + await tester.pumpAndSettle(); + + // Sanity check that the toolbar widget exists. + expect(find.text('Copy'), findsOneWidget); + expect(focusNode.hasPrimaryFocus, isTrue); + + // Now tap on it to see if we lose focus. + await tester.tap(find.text('Copy')); + await tester.pumpAndSettle(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }, + variant: TargetPlatformVariant.all(), + skip: isBrowser, // [intended] On the web, the toolbar isn't rendered by Flutter. + ); + + testWidgets("Tapping on input decorator doesn't lose focus", (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test Node'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Center( + child: SizedBox( + width: 100, + height: 100, + child: Opacity( + opacity: 0.5, + child: TextField( + autofocus: true, + focusNode: focusNode, + decoration: const InputDecoration( + hintText: 'Placeholder', + border: OutlineInputBorder(), + ), + ), + ), + ), + ), + ), + ), + ); + await tester.pump(); + expect(focusNode.hasPrimaryFocus, isTrue); + + final Rect decorationBox = tester.getRect(find.byType(TextField)); + // Tap just inside the decoration, but not inside the EditableText. + await tester.tapAt(decorationBox.topLeft + const Offset(1, 1)); + await tester.pump(); + + expect(focusNode.hasPrimaryFocus, isTrue); + }, variant: TargetPlatformVariant.all()); + + // PointerDownEvents can't be trackpad events, apparently, so we skip that one. + for (final PointerDeviceKind pointerDeviceKind in PointerDeviceKind.values.toSet()..remove(PointerDeviceKind.trackpad)) { + testWidgets('Default TextField handling of onTapOutside follows platform conventions for ${pointerDeviceKind.name}', (WidgetTester tester) async { + final FocusNode focusNode = FocusNode(debugLabel: 'Test'); + await tester.pumpWidget( + MaterialApp( + home: Scaffold( + body: Column( + children: [ + const Text('Outside'), + TextField( + autofocus: true, + focusNode: focusNode, + ), + ], + ), + ), + ), + ); + await tester.pump(); + + Future click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: pointerDeviceKind, + ); + await gesture.up(); + await gesture.removePointer(); + } + + expect(focusNode.hasPrimaryFocus, isTrue); + + await click(find.text('Outside')); + + switch(pointerDeviceKind) { + case PointerDeviceKind.touch: + switch(defaultTargetPlatform) { + case TargetPlatform.iOS: + case TargetPlatform.android: + case TargetPlatform.fuchsia: + expect(focusNode.hasPrimaryFocus, equals(!kIsWeb)); + break; + case TargetPlatform.linux: + case TargetPlatform.macOS: + case TargetPlatform.windows: + expect(focusNode.hasPrimaryFocus, isFalse); + break; + } + break; + case PointerDeviceKind.mouse: + case PointerDeviceKind.stylus: + case PointerDeviceKind.invertedStylus: + case PointerDeviceKind.trackpad: + case PointerDeviceKind.unknown: + expect(focusNode.hasPrimaryFocus, isFalse); + break; + } + }, variant: TargetPlatformVariant.all()); + } + }); } diff --git a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart index 0661410a5b..2c2b486985 100644 --- a/packages/flutter/test/widgets/editable_text_shortcuts_test.dart +++ b/packages/flutter/test/widgets/editable_text_shortcuts_test.dart @@ -1475,6 +1475,7 @@ void main() { offset: 2, ); await tester.pumpWidget(buildEditableText()); + await tester.pump(); // Wait for autofocus to take effect. await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); await tester.pump(); diff --git a/packages/flutter/test/widgets/editable_text_test.dart b/packages/flutter/test/widgets/editable_text_test.dart index 569c0f6e9a..b33bdfab28 100644 --- a/packages/flutter/test/widgets/editable_text_test.dart +++ b/packages/flutter/test/widgets/editable_text_test.dart @@ -12595,13 +12595,22 @@ class MockTextFormatter extends TextInputFormatter { class MockTextSelectionControls extends Fake implements TextSelectionControls { @override - Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) { - return Container(); + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset position, + List endpoints, + TextSelectionDelegate delegate, + ClipboardStatusNotifier? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { + return const SizedBox(); } @override Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { - return Container(); + return const SizedBox(); } @override @@ -12671,7 +12680,16 @@ class _CustomTextSelectionControls extends TextSelectionControls { final VoidCallback? onCut; @override - Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) { + Widget buildToolbar( + BuildContext context, + Rect globalEditableRegion, + double textLineHeight, + Offset position, + List endpoints, + TextSelectionDelegate delegate, + ClipboardStatusNotifier? clipboardStatus, + Offset? lastSecondaryTapDownPosition, + ) { final Offset selectionMidpoint = position; final TextSelectionPoint startTextSelectionPoint = endpoints[0]; final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1 diff --git a/packages/flutter/test/widgets/routes_test.dart b/packages/flutter/test/widgets/routes_test.dart index 015d7c4270..11a52394fe 100644 --- a/packages/flutter/test/widgets/routes_test.dart +++ b/packages/flutter/test/widgets/routes_test.dart @@ -5,7 +5,6 @@ import 'dart:collection'; import 'dart:ui'; -import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_test/flutter_test.dart'; @@ -1976,143 +1975,6 @@ void main() { await tester.restoreFrom(restorationData); expect(find.byType(AlertDialog), findsOneWidget); }, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 - - testWidgets('FocusTrap moves focus to given focus scope when triggered', (WidgetTester tester) async { - final FocusScopeNode focusScope = FocusScopeNode(); - final FocusNode focusNode = FocusNode(debugLabel: 'Test'); - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScope, - child: FocusTrap( - focusScopeNode: focusScope, - child: Column( - children: [ - const Text('Other Widget'), - FocusTrapTestWidget('Focusable', focusNode: focusNode, onTap: () { - focusNode.requestFocus(); - }), - ], - ), - ), - ), - ), - ); - - await tester.pump(); - - Future click(Finder finder) async { - final TestGesture gesture = await tester.startGesture( - tester.getCenter(finder), - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - await gesture.removePointer(); - } - - expect(focusScope.hasFocus, isFalse); - expect(focusNode.hasFocus, isFalse); - - await click(find.text('Focusable')); - await tester.pump(const Duration(seconds: 1)); - - expect(focusScope.hasFocus, isTrue); - expect(focusNode.hasPrimaryFocus, isTrue); - - await click(find.text('Other Widget')); - // Have to wait out the double click timer. - await tester.pump(const Duration(seconds: 1)); - - switch (defaultTargetPlatform) { - case TargetPlatform.iOS: - case TargetPlatform.android: - if (kIsWeb) { - // Web is a desktop platform. - expect(focusScope.hasPrimaryFocus, isTrue); - expect(focusNode.hasFocus, isFalse); - } else { - expect(focusScope.hasFocus, isTrue); - expect(focusNode.hasPrimaryFocus, isTrue); - } - break; - case TargetPlatform.fuchsia: - case TargetPlatform.linux: - case TargetPlatform.macOS: - case TargetPlatform.windows: - expect(focusScope.hasPrimaryFocus, isTrue); - expect(focusNode.hasFocus, isFalse); - break; - } - }, variant: TargetPlatformVariant.all()); - - testWidgets("FocusTrap doesn't unfocus if focus was set to something else before the frame ends", (WidgetTester tester) async { - final FocusScopeNode focusScope = FocusScopeNode(); - final FocusNode focusNode = FocusNode(debugLabel: 'Test'); - final FocusNode otherFocusNode = FocusNode(debugLabel: 'Other'); - FocusNode? previousFocus; - await tester.pumpWidget( - Directionality( - textDirection: TextDirection.ltr, - child: FocusScope( - node: focusScope, - child: FocusTrap( - focusScopeNode: focusScope, - child: Column( - children: [ - FocusTrapTestWidget( - 'Other Widget', - focusNode: otherFocusNode, - onTap: () { - previousFocus = FocusManager.instance.primaryFocus; - otherFocusNode.requestFocus(); - }, - ), - FocusTrapTestWidget( - 'Focusable', - focusNode: focusNode, - onTap: () { - focusNode.requestFocus(); - }, - ), - ], - ), - ), - ), - ), - ); - - Future click(Finder finder) async { - final TestGesture gesture = await tester.startGesture( - tester.getCenter(finder), - kind: PointerDeviceKind.mouse, - ); - await gesture.up(); - await gesture.removePointer(); - } - - await tester.pump(); - expect(focusScope.hasFocus, isFalse); - expect(focusNode.hasPrimaryFocus, isFalse); - - await click(find.text('Focusable')); - - expect(focusScope.hasFocus, isTrue); - expect(focusNode.hasPrimaryFocus, isTrue); - - await click(find.text('Other Widget')); - await tester.pump(const Duration(seconds: 1)); - - // The previous focus as collected by the "Other Widget" should be the - // previous focus, not be unfocused to the scope, since the primary focus - // was set by something other than the FocusTrap (the "Other Widget") during - // the frame. - expect(previousFocus, equals(focusNode)); - - expect(focusScope.hasFocus, isTrue); - expect(focusNode.hasPrimaryFocus, isFalse); - expect(otherFocusNode.hasPrimaryFocus, isTrue); - }, variant: TargetPlatformVariant.all()); } double _getOpacity(GlobalKey key, WidgetTester tester) { @@ -2327,68 +2189,3 @@ class _RestorableDialogTestWidget extends StatelessWidget { ); } } - -class FocusTrapTestWidget extends StatefulWidget { - const FocusTrapTestWidget( - this.label, { - super.key, - required this.focusNode, - this.onTap, - this.autofocus = false, - }); - - final String label; - final FocusNode focusNode; - final VoidCallback? onTap; - final bool autofocus; - - @override - State createState() => _FocusTrapTestWidgetState(); -} - -class _FocusTrapTestWidgetState extends State { - Color color = Colors.white; - - @override - void initState() { - super.initState(); - widget.focusNode.addListener(_handleFocusChange); - } - - void _handleFocusChange() { - if (widget.focusNode.hasPrimaryFocus) { - setState(() { - color = Colors.grey.shade500; - }); - } else { - setState(() { - color = Colors.white; - }); - } - } - - @override - void dispose() { - widget.focusNode.removeListener(_handleFocusChange); - widget.focusNode.dispose(); - super.dispose(); - } - - @override - Widget build(BuildContext context) { - return Focus( - autofocus: widget.autofocus, - focusNode: widget.focusNode, - child: GestureDetector( - onTap: () { - widget.onTap?.call(); - }, - child: Container( - color: color, - alignment: Alignment.center, - child: Text(widget.label, style: const TextStyle(color: Colors.black)), - ), - ), - ); - } -} diff --git a/packages/flutter/test/widgets/tap_region_test.dart b/packages/flutter/test/widgets/tap_region_test.dart new file mode 100644 index 0000000000..f8b687bc97 --- /dev/null +++ b/packages/flutter/test/widgets/tap_region_test.dart @@ -0,0 +1,188 @@ +// 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'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; + +void main() { + testWidgets('TapRegionSurface detects outside taps', (WidgetTester tester) async { + final Set clickedOutside = {}; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + const Text('Outside Surface'), + TapRegionSurface( + child: Row( + children: [ + const Text('Outside'), + TapRegion( + onTapOutside: (PointerEvent event) { + clickedOutside.add('No Group'); + }, + child: const Text('No Group'), + ), + TapRegion( + groupId: 1, + onTapOutside: (PointerEvent event) { + clickedOutside.add('Group 1 A'); + }, + child: const Text('Group 1 A'), + ), + TapRegion( + groupId: 1, + onTapOutside: (PointerEvent event) { + clickedOutside.add('Group 1 B'); + }, + child: const Text('Group 1 B'), + ), + ], + ), + ), + ], + ), + ), + ); + + await tester.pump(); + + Future click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + await gesture.removePointer(); + } + + expect(clickedOutside, isEmpty); + + await click(find.text('No Group')); + expect( + clickedOutside, + unorderedEquals({ + 'Group 1 A', + 'Group 1 B', + })); + clickedOutside.clear(); + + await click(find.text('Group 1 A')); + expect( + clickedOutside, + equals({ + 'No Group', + })); + clickedOutside.clear(); + + await click(find.text('Group 1 B')); + expect( + clickedOutside, + equals({ + 'No Group', + })); + clickedOutside.clear(); + + await click(find.text('Outside')); + expect( + clickedOutside, + unorderedEquals({ + 'No Group', + 'Group 1 A', + 'Group 1 B', + })); + clickedOutside.clear(); + + await click(find.text('Outside Surface')); + expect(clickedOutside, isEmpty); + }); + testWidgets('TapRegionSurface detects inside taps', (WidgetTester tester) async { + final Set clickedInside = {}; + await tester.pumpWidget( + Directionality( + textDirection: TextDirection.ltr, + child: Column( + children: [ + const Text('Outside Surface'), + TapRegionSurface( + child: Row( + children: [ + const Text('Outside'), + TapRegion( + onTapInside: (PointerEvent event) { + clickedInside.add('No Group'); + }, + child: const Text('No Group'), + ), + TapRegion( + groupId: 1, + onTapInside: (PointerEvent event) { + clickedInside.add('Group 1 A'); + }, + child: const Text('Group 1 A'), + ), + TapRegion( + groupId: 1, + onTapInside: (PointerEvent event) { + clickedInside.add('Group 1 B'); + }, + child: const Text('Group 1 B'), + ), + ], + ), + ), + ], + ), + ), + ); + + await tester.pump(); + + Future click(Finder finder) async { + final TestGesture gesture = await tester.startGesture( + tester.getCenter(finder), + kind: PointerDeviceKind.mouse, + ); + await gesture.up(); + await gesture.removePointer(); + } + + expect(clickedInside, isEmpty); + + await click(find.text('No Group')); + expect( + clickedInside, + unorderedEquals({ + 'No Group', + })); + clickedInside.clear(); + + await click(find.text('Group 1 A')); + expect( + clickedInside, + equals({ + 'Group 1 A', + 'Group 1 B', + })); + clickedInside.clear(); + + await click(find.text('Group 1 B')); + expect( + clickedInside, + equals({ + 'Group 1 A', + 'Group 1 B', + })); + clickedInside.clear(); + + await click(find.text('Outside')); + expect(clickedInside, isEmpty); + clickedInside.clear(); + + await click(find.text('Outside Surface')); + expect(clickedInside, isEmpty); + }); +}