Replace FocusTrap with TapRegionSurface (#107262)
This commit is contained in:
parent
347de992ab
commit
f5e4d2b427
@ -631,7 +631,6 @@ 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(
|
||||
@ -645,7 +644,6 @@ class _MyHomePageState extends State<MyHomePage> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
250
examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart
Normal file
250
examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart
Normal 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),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
)
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -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;
|
||||
}
|
@ -230,6 +230,9 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
_wrapActiveItem(
|
||||
context,
|
||||
Expanded(
|
||||
// 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(
|
||||
@ -252,6 +255,7 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
active: active,
|
||||
),
|
||||
);
|
||||
|
@ -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,6 +1321,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
|
||||
_requestKeyboard();
|
||||
},
|
||||
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
|
||||
child: TextFieldTapRegion(
|
||||
child: IgnorePointer(
|
||||
ignoring: !enabled,
|
||||
child: Container(
|
||||
@ -1331,6 +1338,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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(
|
||||
return MouseRegion(
|
||||
cursor: effectiveMouseCursor,
|
||||
onEnter: (PointerEnterEvent event) => _handleHover(true),
|
||||
onExit: (PointerExitEvent event) => _handleHover(false),
|
||||
child: TextFieldTapRegion(
|
||||
child: IgnorePointer(
|
||||
ignoring: !_isEnabled,
|
||||
child: AnimatedBuilder(
|
||||
|
@ -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,6 +1741,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
|
||||
actions: widget.actions ?? WidgetsApp.defaultActions,
|
||||
child: FocusTraversalGroup(
|
||||
policy: ReadingOrderTraversalPolicy(),
|
||||
child: TapRegionSurface(
|
||||
child: ShortcutRegistrar(
|
||||
child: child,
|
||||
),
|
||||
@ -1748,6 +1750,7 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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,7 +3537,10 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
super.build(context); // See AutomaticKeepAliveClientMixin.
|
||||
|
||||
final TextSelectionControls? controls = widget.selectionControls;
|
||||
return MouseRegion(
|
||||
return TextFieldTapRegion(
|
||||
onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside,
|
||||
debugLabel: kReleaseMode ? null : 'EditableText',
|
||||
child: MouseRegion(
|
||||
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
|
||||
child: Actions(
|
||||
actions: _actions,
|
||||
@ -3470,7 +3552,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
child: Focus(
|
||||
focusNode: widget.focusNode,
|
||||
includeSemantics: false,
|
||||
debugLabel: 'EditableText',
|
||||
debugLabel: kReleaseMode ? null : 'EditableText',
|
||||
child: Scrollable(
|
||||
excludeFromSemantics: true,
|
||||
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
|
||||
@ -3552,6 +3634,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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,8 +885,6 @@ 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
|
||||
@ -930,7 +927,6 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
|
||||
),
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
),
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
562
packages/flutter/lib/src/widgets/tap_region.dart
Normal file
562
packages/flutter/lib/src/widgets/tap_region.dart
Normal 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);
|
||||
}
|
@ -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(
|
||||
return TextFieldTapRegion(
|
||||
child: ExcludeSemantics(
|
||||
child: handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -983,8 +986,10 @@ class SelectionOverlay {
|
||||
dragStartBehavior: dragStartBehavior,
|
||||
);
|
||||
}
|
||||
return ExcludeSemantics(
|
||||
return TextFieldTapRegion(
|
||||
child: ExcludeSemantics(
|
||||
child: handle,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@ -1015,7 +1020,8 @@ class SelectionOverlay {
|
||||
selectionEndpoints.first.point.dy - lineHeightAtStart,
|
||||
);
|
||||
|
||||
return Directionality(
|
||||
return TextFieldTapRegion(
|
||||
child: Directionality(
|
||||
textDirection: Directionality.of(this.context),
|
||||
child: _SelectionToolbarOverlay(
|
||||
preferredLineHeight: lineHeightAtStart,
|
||||
@ -1029,6 +1035,7 @@ class SelectionOverlay {
|
||||
selectionDelegate: selectionDelegate,
|
||||
clipboardStatus: clipboardStatus,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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());
|
||||
});
|
||||
}
|
||||
|
@ -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'
|
||||
|
@ -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();
|
||||
|
||||
|
@ -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);
|
||||
|
@ -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());
|
||||
}
|
||||
});
|
||||
}
|
||||
|
@ -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();
|
||||
|
@ -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
|
||||
|
@ -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)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
188
packages/flutter/test/widgets/tap_region_test.dart
Normal file
188
packages/flutter/test/widgets/tap_region_test.dart
Normal 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);
|
||||
});
|
||||
}
|
Loading…
x
Reference in New Issue
Block a user