Unverified Commit f5e4d2b4 authored by Greg Spencer's avatar Greg Spencer Committed by GitHub

Replace FocusTrap with TapRegionSurface (#107262)

parent 347de992
...@@ -631,7 +631,6 @@ class _MyHomePageState extends State<MyHomePage> { ...@@ -631,7 +631,6 @@ class _MyHomePageState extends State<MyHomePage> {
data: Theme.of(context).copyWith(visualDensity: _model.density), data: Theme.of(context).copyWith(visualDensity: _model.density),
child: Directionality( child: Directionality(
textDirection: _model.rtl ? TextDirection.rtl : TextDirection.ltr, textDirection: _model.rtl ? TextDirection.rtl : TextDirection.ltr,
child: Scrollbar(
child: MediaQuery( child: MediaQuery(
data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size), data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size),
child: SizedBox.expand( child: SizedBox.expand(
...@@ -645,7 +644,6 @@ class _MyHomePageState extends State<MyHomePage> { ...@@ -645,7 +644,6 @@ class _MyHomePageState extends State<MyHomePage> {
), ),
), ),
), ),
),
); );
} }
} }
// 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<TextFieldTapRegionExample> createState() => _TextFieldTapRegionExampleState();
}
class _TextFieldTapRegionExampleState extends State<TextFieldTapRegionExample> {
int value = 0;
@override
Widget build(BuildContext context) {
return ListView(
children: <Widget>[
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<int>? onChanged;
@override
Widget build(BuildContext context) {
return SpinnerField<int>(
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>[
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<T> 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 <TextInputFormatter>[],
}) : 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<T>? onChanged;
final List<TextInputFormatter> inputFormatters;
final bool autofocus;
@override
State<SpinnerField<T>> createState() => _SpinnerFieldState<T>();
}
class _SpinnerFieldState<T> extends State<SpinnerField<T>> {
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<T> 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: <ShortcutActivator, VoidCallback>{
const SingleActivator(LogicalKeyboardKey.arrowUp): _increment,
const SingleActivator(LogicalKeyboardKey.arrowDown): _decrement,
},
child: Row(
children: <Widget>[
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: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: _increment,
child: const Icon(Icons.add),
),
),
Expanded(
child: OutlinedButton(
onPressed: _decrement,
child: const Icon(Icons.remove),
),
),
],
),
)
],
),
);
}
}
// 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;
}
...@@ -230,6 +230,9 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget { ...@@ -230,6 +230,9 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget {
_wrapActiveItem( _wrapActiveItem(
context, context,
Expanded( Expanded(
// Make tab items part of the EditableText tap region so that
// switching tabs doesn't unfocus text fields.
child: TextFieldTapRegion(
child: Semantics( child: Semantics(
selected: active, selected: active,
hint: localizations.tabSemanticsLabel( hint: localizations.tabSemanticsLabel(
...@@ -252,6 +255,7 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget { ...@@ -252,6 +255,7 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget {
), ),
), ),
), ),
),
active: active, active: active,
), ),
); );
......
...@@ -251,6 +251,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -251,6 +251,7 @@ class CupertinoTextField extends StatefulWidget {
this.onChanged, this.onChanged,
this.onEditingComplete, this.onEditingComplete,
this.onSubmitted, this.onSubmitted,
this.onTapOutside,
this.inputFormatters, this.inputFormatters,
this.enabled, this.enabled,
this.cursorWidth = 2.0, this.cursorWidth = 2.0,
...@@ -411,6 +412,7 @@ class CupertinoTextField extends StatefulWidget { ...@@ -411,6 +412,7 @@ class CupertinoTextField extends StatefulWidget {
this.onChanged, this.onChanged,
this.onEditingComplete, this.onEditingComplete,
this.onSubmitted, this.onSubmitted,
this.onTapOutside,
this.inputFormatters, this.inputFormatters,
this.enabled, this.enabled,
this.cursorWidth = 2.0, this.cursorWidth = 2.0,
...@@ -692,6 +694,9 @@ class CupertinoTextField extends StatefulWidget { ...@@ -692,6 +694,9 @@ class CupertinoTextField extends StatefulWidget {
/// the user is done editing. /// the user is done editing.
final ValueChanged<String>? onSubmitted; final ValueChanged<String>? onSubmitted;
/// {@macro flutter.widgets.editableText.onTapOutside}
final TapRegionCallback? onTapOutside;
/// {@macro flutter.widgets.editableText.inputFormatters} /// {@macro flutter.widgets.editableText.inputFormatters}
final List<TextInputFormatter>? inputFormatters; final List<TextInputFormatter>? inputFormatters;
...@@ -1277,6 +1282,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1277,6 +1282,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
onSelectionChanged: _handleSelectionChanged, onSelectionChanged: _handleSelectionChanged,
onEditingComplete: widget.onEditingComplete, onEditingComplete: widget.onEditingComplete,
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
onTapOutside: widget.onTapOutside,
inputFormatters: formatters, inputFormatters: formatters,
rendererIgnoresPointer: true, rendererIgnoresPointer: true,
cursorWidth: widget.cursorWidth, cursorWidth: widget.cursorWidth,
...@@ -1315,6 +1321,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1315,6 +1321,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
_requestKeyboard(); _requestKeyboard();
}, },
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus, onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
child: TextFieldTapRegion(
child: IgnorePointer( child: IgnorePointer(
ignoring: !enabled, ignoring: !enabled,
child: Container( child: Container(
...@@ -1331,6 +1338,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio ...@@ -1331,6 +1338,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
), ),
), ),
), ),
),
); );
} }
} }
...@@ -320,6 +320,7 @@ class TextField extends StatefulWidget { ...@@ -320,6 +320,7 @@ class TextField extends StatefulWidget {
bool? enableInteractiveSelection, bool? enableInteractiveSelection,
this.selectionControls, this.selectionControls,
this.onTap, this.onTap,
this.onTapOutside,
this.mouseCursor, this.mouseCursor,
this.buildCounter, this.buildCounter,
this.scrollController, this.scrollController,
...@@ -675,6 +676,24 @@ class TextField extends StatefulWidget { ...@@ -675,6 +676,24 @@ class TextField extends StatefulWidget {
/// {@endtemplate} /// {@endtemplate}
final GestureTapCallback? onTap; 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<T>` 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 /// The cursor for a mouse pointer when it enters or is hovering over the
/// widget. /// widget.
/// ///
...@@ -1267,6 +1286,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1267,6 +1286,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
onSubmitted: widget.onSubmitted, onSubmitted: widget.onSubmitted,
onAppPrivateCommand: widget.onAppPrivateCommand, onAppPrivateCommand: widget.onAppPrivateCommand,
onSelectionHandleTapped: _handleSelectionHandleTapped, onSelectionHandleTapped: _handleSelectionHandleTapped,
onTapOutside: widget.onTapOutside,
inputFormatters: formatters, inputFormatters: formatters,
rendererIgnoresPointer: true, rendererIgnoresPointer: true,
mouseCursor: MouseCursor.defer, // TextField will handle the cursor mouseCursor: MouseCursor.defer, // TextField will handle the cursor
...@@ -1334,12 +1354,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements ...@@ -1334,12 +1354,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
semanticsMaxValueLength = null; semanticsMaxValueLength = null;
} }
return FocusTrapArea( return MouseRegion(
focusNode: focusNode,
child: MouseRegion(
cursor: effectiveMouseCursor, cursor: effectiveMouseCursor,
onEnter: (PointerEnterEvent event) => _handleHover(true), onEnter: (PointerEnterEvent event) => _handleHover(true),
onExit: (PointerExitEvent event) => _handleHover(false), onExit: (PointerExitEvent event) => _handleHover(false),
child: TextFieldTapRegion(
child: IgnorePointer( child: IgnorePointer(
ignoring: !_isEnabled, ignoring: !_isEnabled,
child: AnimatedBuilder( child: AnimatedBuilder(
......
...@@ -26,6 +26,7 @@ import 'scrollable.dart'; ...@@ -26,6 +26,7 @@ import 'scrollable.dart';
import 'semantics_debugger.dart'; import 'semantics_debugger.dart';
import 'shared_app_data.dart'; import 'shared_app_data.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'tap_region.dart';
import 'text.dart'; import 'text.dart';
import 'title.dart'; import 'title.dart';
import 'widget_inspector.dart'; import 'widget_inspector.dart';
...@@ -1740,6 +1741,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1740,6 +1741,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
actions: widget.actions ?? WidgetsApp.defaultActions, actions: widget.actions ?? WidgetsApp.defaultActions,
child: FocusTraversalGroup( child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(), policy: ReadingOrderTraversalPolicy(),
child: TapRegionSurface(
child: ShortcutRegistrar( child: ShortcutRegistrar(
child: child, child: child,
), ),
...@@ -1748,6 +1750,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver { ...@@ -1748,6 +1750,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
), ),
), ),
), ),
),
); );
} }
} }
...@@ -32,6 +32,7 @@ import 'scroll_controller.dart'; ...@@ -32,6 +32,7 @@ import 'scroll_controller.dart';
import 'scroll_physics.dart'; import 'scroll_physics.dart';
import 'scrollable.dart'; import 'scrollable.dart';
import 'shortcuts.dart'; import 'shortcuts.dart';
import 'tap_region.dart';
import 'text.dart'; import 'text.dart';
import 'text_editing_intents.dart'; import 'text_editing_intents.dart';
import 'text_selection.dart'; import 'text_selection.dart';
...@@ -608,6 +609,7 @@ class EditableText extends StatefulWidget { ...@@ -608,6 +609,7 @@ class EditableText extends StatefulWidget {
this.onAppPrivateCommand, this.onAppPrivateCommand,
this.onSelectionChanged, this.onSelectionChanged,
this.onSelectionHandleTapped, this.onSelectionHandleTapped,
this.onTapOutside,
List<TextInputFormatter>? inputFormatters, List<TextInputFormatter>? inputFormatters,
this.mouseCursor, this.mouseCursor,
this.rendererIgnoresPointer = false, this.rendererIgnoresPointer = false,
...@@ -1213,6 +1215,46 @@ class EditableText extends StatefulWidget { ...@@ -1213,6 +1215,46 @@ class EditableText extends StatefulWidget {
/// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped} /// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
final VoidCallback? 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<T>` 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} /// {@template flutter.widgets.editableText.inputFormatters}
/// Optional input validation and formatting overrides. /// Optional input validation and formatting overrides.
/// ///
...@@ -3421,6 +3463,43 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3421,6 +3463,43 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return Actions.invoke(context, intent); 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<Type, Action<Intent>> _actions = <Type, Action<Intent>>{ late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false), DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
ReplaceTextIntent: _replaceTextAction, ReplaceTextIntent: _replaceTextAction,
...@@ -3458,7 +3537,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3458,7 +3537,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
super.build(context); // See AutomaticKeepAliveClientMixin. super.build(context); // See AutomaticKeepAliveClientMixin.
final TextSelectionControls? controls = widget.selectionControls; final TextSelectionControls? controls = widget.selectionControls;
return MouseRegion( return TextFieldTapRegion(
onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside,
debugLabel: kReleaseMode ? null : 'EditableText',
child: MouseRegion(
cursor: widget.mouseCursor ?? SystemMouseCursors.text, cursor: widget.mouseCursor ?? SystemMouseCursors.text,
child: Actions( child: Actions(
actions: _actions, actions: _actions,
...@@ -3470,7 +3552,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3470,7 +3552,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
child: Focus( child: Focus(
focusNode: widget.focusNode, focusNode: widget.focusNode,
includeSemantics: false, includeSemantics: false,
debugLabel: 'EditableText', debugLabel: kReleaseMode ? null : 'EditableText',
child: Scrollable( child: Scrollable(
excludeFromSemantics: true, excludeFromSemantics: true,
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right, axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
...@@ -3552,6 +3634,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien ...@@ -3552,6 +3634,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
), ),
), ),
), ),
),
); );
} }
......
...@@ -6,7 +6,6 @@ import 'dart:async'; ...@@ -6,7 +6,6 @@ import 'dart:async';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/gestures.dart';
import 'package:flutter/rendering.dart'; import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart'; import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
...@@ -886,8 +885,6 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -886,8 +885,6 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
controller: primaryScrollController, controller: primaryScrollController,
child: FocusScope( child: FocusScope(
node: focusScopeNode, // immutable node: focusScopeNode, // immutable
child: FocusTrap(
focusScopeNode: focusScopeNode,
child: RepaintBoundary( child: RepaintBoundary(
child: AnimatedBuilder( child: AnimatedBuilder(
animation: _listenable, // immutable animation: _listenable, // immutable
...@@ -930,7 +927,6 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> { ...@@ -930,7 +927,6 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
), ),
), ),
), ),
),
); );
}, },
), ),
...@@ -2147,184 +2143,3 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl ...@@ -2147,184 +2143,3 @@ typedef RoutePageBuilder = Widget Function(BuildContext context, Animation<doubl
/// ///
/// See [ModalRoute.buildTransitions] for complete definition of the parameters. /// See [ModalRoute.buildTransitions] for complete definition of the parameters.
typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child); typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> 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<BoxHitTestResult> cachedResults = Expando<BoxHitTestResult>();
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<void>(_checkForUnfocus, Priority.touch);
}
}
}
This diff is collapsed.
...@@ -20,6 +20,7 @@ import 'editable_text.dart'; ...@@ -20,6 +20,7 @@ import 'editable_text.dart';
import 'framework.dart'; import 'framework.dart';
import 'gesture_detector.dart'; import 'gesture_detector.dart';
import 'overlay.dart'; import 'overlay.dart';
import 'tap_region.dart';
import 'ticker_provider.dart'; import 'ticker_provider.dart';
import 'transitions.dart'; import 'transitions.dart';
...@@ -958,8 +959,10 @@ class SelectionOverlay { ...@@ -958,8 +959,10 @@ class SelectionOverlay {
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
); );
} }
return ExcludeSemantics( return TextFieldTapRegion(
child: ExcludeSemantics(
child: handle, child: handle,
),
); );
} }
...@@ -983,8 +986,10 @@ class SelectionOverlay { ...@@ -983,8 +986,10 @@ class SelectionOverlay {
dragStartBehavior: dragStartBehavior, dragStartBehavior: dragStartBehavior,
); );
} }
return ExcludeSemantics( return TextFieldTapRegion(
child: ExcludeSemantics(
child: handle, child: handle,
),
); );
} }
...@@ -1015,7 +1020,8 @@ class SelectionOverlay { ...@@ -1015,7 +1020,8 @@ class SelectionOverlay {
selectionEndpoints.first.point.dy - lineHeightAtStart, selectionEndpoints.first.point.dy - lineHeightAtStart,
); );
return Directionality( return TextFieldTapRegion(
child: Directionality(
textDirection: Directionality.of(this.context), textDirection: Directionality.of(this.context),
child: _SelectionToolbarOverlay( child: _SelectionToolbarOverlay(
preferredLineHeight: lineHeightAtStart, preferredLineHeight: lineHeightAtStart,
...@@ -1029,6 +1035,7 @@ class SelectionOverlay { ...@@ -1029,6 +1035,7 @@ class SelectionOverlay {
selectionDelegate: selectionDelegate, selectionDelegate: selectionDelegate,
clipboardStatus: clipboardStatus, clipboardStatus: clipboardStatus,
), ),
),
); );
} }
} }
......
...@@ -128,6 +128,7 @@ export 'src/widgets/slotted_render_object_widget.dart'; ...@@ -128,6 +128,7 @@ export 'src/widgets/slotted_render_object_widget.dart';
export 'src/widgets/spacer.dart'; export 'src/widgets/spacer.dart';
export 'src/widgets/status_transitions.dart'; export 'src/widgets/status_transitions.dart';
export 'src/widgets/table.dart'; export 'src/widgets/table.dart';
export 'src/widgets/tap_region.dart';
export 'src/widgets/text.dart'; export 'src/widgets/text.dart';
export 'src/widgets/text_editing_intents.dart'; export 'src/widgets/text_editing_intents.dart';
export 'src/widgets/text_selection.dart'; export 'src/widgets/text_selection.dart';
......
...@@ -5960,4 +5960,144 @@ void main() { ...@@ -5960,4 +5960,144 @@ void main() {
expect(controller.selection.extentOffset, 5); expect(controller.selection.extentOffset, 5);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ 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<EditableTextState>(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());
});
} }
...@@ -134,7 +134,6 @@ void main() { ...@@ -134,7 +134,6 @@ void main() {
' _FadeUpwardsPageTransition\n' ' _FadeUpwardsPageTransition\n'
' AnimatedBuilder\n' ' AnimatedBuilder\n'
' RepaintBoundary\n' ' RepaintBoundary\n'
' FocusTrap\n'
' _FocusMarker\n' ' _FocusMarker\n'
' Semantics\n' ' Semantics\n'
' FocusScope\n' ' FocusScope\n'
...@@ -192,6 +191,7 @@ void main() { ...@@ -192,6 +191,7 @@ void main() {
' Focus\n' ' Focus\n'
' Shortcuts\n' ' Shortcuts\n'
' ShortcutRegistrar\n' ' ShortcutRegistrar\n'
' TapRegionSurface\n'
' _FocusMarker\n' ' _FocusMarker\n'
' Focus\n' ' Focus\n'
' _FocusTraversalGroupMarker\n' ' _FocusTraversalGroupMarker\n'
......
...@@ -5417,7 +5417,7 @@ void main() { ...@@ -5417,7 +5417,7 @@ void main() {
final double floatedLabelWidth = getLabelRect(tester).width; final double floatedLabelWidth = getLabelRect(tester).width;
expect(floatedLabelWidth > labelWidth, isTrue); expect(floatedLabelWidth, greaterThan(labelWidth));
final Widget target = getLabeledInputDecorator(FloatingLabelBehavior.auto); final Widget target = getLabeledInputDecorator(FloatingLabelBehavior.auto);
await tester.pumpWidget(target); await tester.pumpWidget(target);
...@@ -5430,8 +5430,8 @@ void main() { ...@@ -5430,8 +5430,8 @@ void main() {
// Default animation duration is 200 millisecond. // Default animation duration is 200 millisecond.
await tester.pumpFrames(target, const Duration(milliseconds: 100)); await tester.pumpFrames(target, const Duration(milliseconds: 100));
expect(getLabelRect(tester).width > labelWidth, isTrue); expect(getLabelRect(tester).width, greaterThan(labelWidth));
expect(getLabelRect(tester).width < floatedLabelWidth, isTrue); expect(getLabelRect(tester).width, lessThanOrEqualTo(floatedLabelWidth));
await tester.pumpAndSettle(); await tester.pumpAndSettle();
......
...@@ -186,7 +186,7 @@ void main() { ...@@ -186,7 +186,7 @@ void main() {
expect(find.byType(TextField), findsOneWidget); expect(find.byType(TextField), findsOneWidget);
expect(tester.testTextInput.isVisible, isTrue); 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(); await tester.pump();
expect(find.byType(TextField, skipOffstage: false), findsOneWidget); expect(find.byType(TextField, skipOffstage: false), findsOneWidget);
expect(tester.testTextInput.isVisible, isTrue); expect(tester.testTextInput.isVisible, isTrue);
...@@ -225,7 +225,7 @@ void main() { ...@@ -225,7 +225,7 @@ void main() {
FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode); FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode);
await tester.pump(); await tester.pump();
expect(find.byType(TextField), findsOneWidget); 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(); await tester.pump();
expect(find.byType(TextField, skipOffstage: false), findsOneWidget); expect(find.byType(TextField, skipOffstage: false), findsOneWidget);
await tester.pumpWidget(makeTest('test')); await tester.pumpWidget(makeTest('test'));
...@@ -490,8 +490,8 @@ void main() { ...@@ -490,8 +490,8 @@ void main() {
}, variant: TargetPlatformVariant.desktop()); }, 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 { 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 focusNodeA = FocusNode(debugLabel: 'A');
final FocusNode focusNodeB = FocusNode(); final FocusNode focusNodeB = FocusNode(debugLabel: 'B');
final Key key = UniqueKey(); final Key key = UniqueKey();
await tester.pumpWidget( await tester.pumpWidget(
...@@ -518,30 +518,33 @@ void main() { ...@@ -518,30 +518,33 @@ void main() {
); );
// Tab over to the 3rd text field. // Tab over to the 3rd text field.
for (int i = 0; i < 3; i += 1) { for (int i = 0; i < 3; i += 1) {
await tester.sendKeyDownEvent(LogicalKeyboardKey.tab); await tester.sendKeyEvent(LogicalKeyboardKey.tab);
await tester.sendKeyUpEvent(LogicalKeyboardKey.tab); await tester.pump();
}
Future<void> 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(focusNodeA.hasFocus, true);
expect(focusNodeB.hasFocus, false); expect(focusNodeB.hasFocus, false);
// Click on the container to not hit either text field. // 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.pump();
await tester.pumpAndSettle();
await down2.up();
await down2.removePointer();
expect(focusNodeA.hasFocus, false); expect(focusNodeA.hasFocus, false);
expect(focusNodeB.hasFocus, false); expect(focusNodeB.hasFocus, false);
// Second text field can still gain focus. // 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.pump();
await tester.pumpAndSettle();
await down3.up();
await down3.removePointer();
expect(focusNodeA.hasFocus, false); expect(focusNodeA.hasFocus, false);
expect(focusNodeB.hasFocus, true); expect(focusNodeB.hasFocus, true);
......
...@@ -11785,4 +11785,224 @@ void main() { ...@@ -11785,4 +11785,224 @@ void main() {
expect(controller.selection.extentOffset, 5); expect(controller.selection.extentOffset, 5);
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS })); }, variant: const TargetPlatformVariant(<TargetPlatform>{ 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<EditableTextState>(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: <Widget>[
const Text('Outside'),
TextField(
autofocus: true,
focusNode: focusNode,
),
],
),
),
),
);
await tester.pump();
Future<void> 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());
}
});
} }
...@@ -1475,6 +1475,7 @@ void main() { ...@@ -1475,6 +1475,7 @@ void main() {
offset: 2, offset: 2,
); );
await tester.pumpWidget(buildEditableText()); await tester.pumpWidget(buildEditableText());
await tester.pump(); // Wait for autofocus to take effect.
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown)); await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown));
await tester.pump(); await tester.pump();
......
...@@ -12595,13 +12595,22 @@ class MockTextFormatter extends TextInputFormatter { ...@@ -12595,13 +12595,22 @@ class MockTextFormatter extends TextInputFormatter {
class MockTextSelectionControls extends Fake implements TextSelectionControls { class MockTextSelectionControls extends Fake implements TextSelectionControls {
@override @override
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) { Widget buildToolbar(
return Container(); BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
return const SizedBox();
} }
@override @override
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) { Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
return Container(); return const SizedBox();
} }
@override @override
...@@ -12671,7 +12680,16 @@ class _CustomTextSelectionControls extends TextSelectionControls { ...@@ -12671,7 +12680,16 @@ class _CustomTextSelectionControls extends TextSelectionControls {
final VoidCallback? onCut; final VoidCallback? onCut;
@override @override
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) { Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> endpoints,
TextSelectionDelegate delegate,
ClipboardStatusNotifier? clipboardStatus,
Offset? lastSecondaryTapDownPosition,
) {
final Offset selectionMidpoint = position; final Offset selectionMidpoint = position;
final TextSelectionPoint startTextSelectionPoint = endpoints[0]; final TextSelectionPoint startTextSelectionPoint = endpoints[0];
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1 final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
......
...@@ -5,7 +5,6 @@ ...@@ -5,7 +5,6 @@
import 'dart:collection'; import 'dart:collection';
import 'dart:ui'; import 'dart:ui';
import 'package:flutter/foundation.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart'; import 'package:flutter_test/flutter_test.dart';
...@@ -1976,143 +1975,6 @@ void main() { ...@@ -1976,143 +1975,6 @@ void main() {
await tester.restoreFrom(restorationData); await tester.restoreFrom(restorationData);
expect(find.byType(AlertDialog), findsOneWidget); expect(find.byType(AlertDialog), findsOneWidget);
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615 }, 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: <Widget>[
const Text('Other Widget'),
FocusTrapTestWidget('Focusable', focusNode: focusNode, onTap: () {
focusNode.requestFocus();
}),
],
),
),
),
),
);
await tester.pump();
Future<void> 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: <Widget>[
FocusTrapTestWidget(
'Other Widget',
focusNode: otherFocusNode,
onTap: () {
previousFocus = FocusManager.instance.primaryFocus;
otherFocusNode.requestFocus();
},
),
FocusTrapTestWidget(
'Focusable',
focusNode: focusNode,
onTap: () {
focusNode.requestFocus();
},
),
],
),
),
),
),
);
Future<void> 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) { double _getOpacity(GlobalKey key, WidgetTester tester) {
...@@ -2327,68 +2189,3 @@ class _RestorableDialogTestWidget extends StatelessWidget { ...@@ -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<FocusTrapTestWidget> createState() => _FocusTrapTestWidgetState();
}
class _FocusTrapTestWidgetState extends State<FocusTrapTestWidget> {
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)),
),
),
);
}
}
// 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<String> clickedOutside = <String>{};
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Column(
children: <Widget>[
const Text('Outside Surface'),
TapRegionSurface(
child: Row(
children: <Widget>[
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<void> 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(<String>{
'Group 1 A',
'Group 1 B',
}));
clickedOutside.clear();
await click(find.text('Group 1 A'));
expect(
clickedOutside,
equals(<String>{
'No Group',
}));
clickedOutside.clear();
await click(find.text('Group 1 B'));
expect(
clickedOutside,
equals(<String>{
'No Group',
}));
clickedOutside.clear();
await click(find.text('Outside'));
expect(
clickedOutside,
unorderedEquals(<String>{
'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<String> clickedInside = <String>{};
await tester.pumpWidget(
Directionality(
textDirection: TextDirection.ltr,
child: Column(
children: <Widget>[
const Text('Outside Surface'),
TapRegionSurface(
child: Row(
children: <Widget>[
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<void> 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(<String>{
'No Group',
}));
clickedInside.clear();
await click(find.text('Group 1 A'));
expect(
clickedInside,
equals(<String>{
'Group 1 A',
'Group 1 B',
}));
clickedInside.clear();
await click(find.text('Group 1 B'));
expect(
clickedInside,
equals(<String>{
'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);
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment