Replace FocusTrap with TapRegionSurface (#107262)

This commit is contained in:
Greg Spencer 2022-07-29 09:00:07 -07:00 committed by GitHub
parent 347de992ab
commit f5e4d2b427
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
21 changed files with 1808 additions and 591 deletions

View File

@ -631,13 +631,11 @@ class _MyHomePageState extends State<MyHomePage> {
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,
),
),
),

View File

@ -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<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),
),
),
],
),
)
],
),
);
}
}

View File

@ -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;
}

View File

@ -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),
),
),
),
),

View File

@ -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<String>? onSubmitted;
/// {@macro flutter.widgets.editableText.onTapOutside}
final TapRegionCallback? onTapOutside;
/// {@macro flutter.widgets.editableText.inputFormatters}
final List<TextInputFormatter>? inputFormatters;
@ -1277,6 +1282,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> 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<CupertinoTextField> 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),
),
),
),
),

View File

@ -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<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
/// widget.
///
@ -1267,6 +1286,7 @@ class _TextFieldState extends State<TextField> 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<TextField> 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(

View File

@ -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<WidgetsApp> with WidgetsBindingObserver {
actions: widget.actions ?? WidgetsApp.defaultActions,
child: FocusTraversalGroup(
policy: ReadingOrderTraversalPolicy(),
child: ShortcutRegistrar(
child: child,
child: TapRegionSurface(
child: ShortcutRegistrar(
child: child,
),
),
),
),

View File

@ -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<TextInputFormatter>? 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<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}
/// Optional input validation and formatting overrides.
///
@ -3421,6 +3463,43 @@ class EditableTextState extends State<EditableText> 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<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
ReplaceTextIntent: _replaceTextAction,
@ -3458,96 +3537,100 @@ class EditableTextState extends State<EditableText> 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<bool>(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<bool>(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,
),
),
),
),
);
},
);
},
),
),
),
),

View File

@ -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<T> extends State<_ModalScope<T>> {
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<bool>(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<bool>(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<doubl
///
/// See [ModalRoute.buildTransitions] for complete definition of the parameters.
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);
}
}
}

View File

@ -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<String>? 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<RenderTapRegionSurface>();
}
}
/// 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.
/// * <https://flutter.dev/gestures/#gesture-disambiguation> 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<BoxHitTestResult> _cachedResults = Expando<BoxHitTestResult>();
final Set<RenderTapRegion> _registeredRegions = <RenderTapRegion>{};
final Map<Object?, Set<RenderTapRegion>> _groupIdToRegions = <Object?, Set<RenderTapRegion>>{};
@override
void registerTapRegion(RenderTapRegion region) {
assert(_tapRegionDebug('Region $region registered.'));
assert(!_registeredRegions.contains(region));
_registeredRegions.add(region);
if (region.groupId != null) {
_groupIdToRegions[region.groupId] ??= <RenderTapRegion>{};
_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<RenderTapRegion> hitRegions =
_getRegionsHit(_registeredRegions, result.path).cast<RenderTapRegion>().toSet();
final Set<RenderTapRegion> insideRegions = <RenderTapRegion>{};
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<RenderTapRegion> 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<HitTestTarget> _getRegionsHit(Set<RenderTapRegion> detectors, Iterable<HitTestEntry> hitTestPath) {
final Set<HitTestTarget> hitRegions = <HitTestTarget>{};
for (final HitTestEntry<HitTestTarget> 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<Object?>('debugLabel', debugLabel, defaultValue: null));
properties.add(DiagnosticsProperty<Object?>('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<Object?>('debugLabel', debugLabel, defaultValue: null));
properties.add(DiagnosticsProperty<Object?>('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<T>` 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);
}

View File

@ -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,
),
),
);
}

View File

@ -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';

View File

@ -5960,4 +5960,144 @@ void main() {
expect(controller.selection.extentOffset, 5);
}, 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());
});
}

View File

@ -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'

View File

@ -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();

View File

@ -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<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(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);

View File

@ -11785,4 +11785,224 @@ void main() {
expect(controller.selection.extentOffset, 5);
}, 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());
}
});
}

View File

@ -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();

View File

@ -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<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) {
return Container();
Widget buildToolbar(
BuildContext context,
Rect globalEditableRegion,
double textLineHeight,
Offset position,
List<TextSelectionPoint> 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<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 TextSelectionPoint startTextSelectionPoint = endpoints[0];
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1

View File

@ -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: <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) {
@ -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)),
),
),
);
}
}

View File

@ -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<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);
});
}