Replace FocusTrap with TapRegionSurface (#107262)
This commit is contained in:
parent
347de992ab
commit
f5e4d2b427
@ -631,13 +631,11 @@ class _MyHomePageState extends State<MyHomePage> {
|
|||||||
data: Theme.of(context).copyWith(visualDensity: _model.density),
|
data: Theme.of(context).copyWith(visualDensity: _model.density),
|
||||||
child: Directionality(
|
child: Directionality(
|
||||||
textDirection: _model.rtl ? TextDirection.rtl : TextDirection.ltr,
|
textDirection: _model.rtl ? TextDirection.rtl : TextDirection.ltr,
|
||||||
child: Scrollbar(
|
child: MediaQuery(
|
||||||
child: MediaQuery(
|
data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size),
|
||||||
data: MediaQuery.of(context).copyWith(textScaleFactor: _model.size),
|
child: SizedBox.expand(
|
||||||
child: SizedBox.expand(
|
child: ListView(
|
||||||
child: ListView(
|
children: tiles,
|
||||||
children: tiles,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
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,22 +230,26 @@ class CupertinoTabBar extends StatelessWidget implements PreferredSizeWidget {
|
|||||||
_wrapActiveItem(
|
_wrapActiveItem(
|
||||||
context,
|
context,
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Semantics(
|
// Make tab items part of the EditableText tap region so that
|
||||||
selected: active,
|
// switching tabs doesn't unfocus text fields.
|
||||||
hint: localizations.tabSemanticsLabel(
|
child: TextFieldTapRegion(
|
||||||
tabIndex: index + 1,
|
child: Semantics(
|
||||||
tabCount: items.length,
|
selected: active,
|
||||||
),
|
hint: localizations.tabSemanticsLabel(
|
||||||
child: MouseRegion(
|
tabIndex: index + 1,
|
||||||
cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
|
tabCount: items.length,
|
||||||
child: GestureDetector(
|
),
|
||||||
behavior: HitTestBehavior.opaque,
|
child: MouseRegion(
|
||||||
onTap: onTap == null ? null : () { onTap!(index); },
|
cursor: kIsWeb ? SystemMouseCursors.click : MouseCursor.defer,
|
||||||
child: Padding(
|
child: GestureDetector(
|
||||||
padding: const EdgeInsets.only(bottom: 4.0),
|
behavior: HitTestBehavior.opaque,
|
||||||
child: Column(
|
onTap: onTap == null ? null : () { onTap!(index); },
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
child: Padding(
|
||||||
children: _buildSingleTabItem(items[index], active),
|
padding: const EdgeInsets.only(bottom: 4.0),
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
|
children: _buildSingleTabItem(items[index], active),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -251,6 +251,7 @@ class CupertinoTextField extends StatefulWidget {
|
|||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.onEditingComplete,
|
this.onEditingComplete,
|
||||||
this.onSubmitted,
|
this.onSubmitted,
|
||||||
|
this.onTapOutside,
|
||||||
this.inputFormatters,
|
this.inputFormatters,
|
||||||
this.enabled,
|
this.enabled,
|
||||||
this.cursorWidth = 2.0,
|
this.cursorWidth = 2.0,
|
||||||
@ -411,6 +412,7 @@ class CupertinoTextField extends StatefulWidget {
|
|||||||
this.onChanged,
|
this.onChanged,
|
||||||
this.onEditingComplete,
|
this.onEditingComplete,
|
||||||
this.onSubmitted,
|
this.onSubmitted,
|
||||||
|
this.onTapOutside,
|
||||||
this.inputFormatters,
|
this.inputFormatters,
|
||||||
this.enabled,
|
this.enabled,
|
||||||
this.cursorWidth = 2.0,
|
this.cursorWidth = 2.0,
|
||||||
@ -692,6 +694,9 @@ class CupertinoTextField extends StatefulWidget {
|
|||||||
/// the user is done editing.
|
/// the user is done editing.
|
||||||
final ValueChanged<String>? onSubmitted;
|
final ValueChanged<String>? onSubmitted;
|
||||||
|
|
||||||
|
/// {@macro flutter.widgets.editableText.onTapOutside}
|
||||||
|
final TapRegionCallback? onTapOutside;
|
||||||
|
|
||||||
/// {@macro flutter.widgets.editableText.inputFormatters}
|
/// {@macro flutter.widgets.editableText.inputFormatters}
|
||||||
final List<TextInputFormatter>? inputFormatters;
|
final List<TextInputFormatter>? inputFormatters;
|
||||||
|
|
||||||
@ -1277,6 +1282,7 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
|
|||||||
onSelectionChanged: _handleSelectionChanged,
|
onSelectionChanged: _handleSelectionChanged,
|
||||||
onEditingComplete: widget.onEditingComplete,
|
onEditingComplete: widget.onEditingComplete,
|
||||||
onSubmitted: widget.onSubmitted,
|
onSubmitted: widget.onSubmitted,
|
||||||
|
onTapOutside: widget.onTapOutside,
|
||||||
inputFormatters: formatters,
|
inputFormatters: formatters,
|
||||||
rendererIgnoresPointer: true,
|
rendererIgnoresPointer: true,
|
||||||
cursorWidth: widget.cursorWidth,
|
cursorWidth: widget.cursorWidth,
|
||||||
@ -1315,18 +1321,20 @@ class _CupertinoTextFieldState extends State<CupertinoTextField> with Restoratio
|
|||||||
_requestKeyboard();
|
_requestKeyboard();
|
||||||
},
|
},
|
||||||
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
|
onDidGainAccessibilityFocus: handleDidGainAccessibilityFocus,
|
||||||
child: IgnorePointer(
|
child: TextFieldTapRegion(
|
||||||
ignoring: !enabled,
|
child: IgnorePointer(
|
||||||
child: Container(
|
ignoring: !enabled,
|
||||||
decoration: effectiveDecoration,
|
child: Container(
|
||||||
color: !enabled && effectiveDecoration == null ? disabledColor : null,
|
decoration: effectiveDecoration,
|
||||||
child: _selectionGestureDetectorBuilder.buildGestureDetector(
|
color: !enabled && effectiveDecoration == null ? disabledColor : null,
|
||||||
behavior: HitTestBehavior.translucent,
|
child: _selectionGestureDetectorBuilder.buildGestureDetector(
|
||||||
child: Align(
|
behavior: HitTestBehavior.translucent,
|
||||||
alignment: Alignment(-1.0, _textAlignVertical.y),
|
child: Align(
|
||||||
widthFactor: 1.0,
|
alignment: Alignment(-1.0, _textAlignVertical.y),
|
||||||
heightFactor: 1.0,
|
widthFactor: 1.0,
|
||||||
child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle),
|
heightFactor: 1.0,
|
||||||
|
child: _addTextDependentAttachments(paddedEditable, textStyle, placeholderStyle),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -320,6 +320,7 @@ class TextField extends StatefulWidget {
|
|||||||
bool? enableInteractiveSelection,
|
bool? enableInteractiveSelection,
|
||||||
this.selectionControls,
|
this.selectionControls,
|
||||||
this.onTap,
|
this.onTap,
|
||||||
|
this.onTapOutside,
|
||||||
this.mouseCursor,
|
this.mouseCursor,
|
||||||
this.buildCounter,
|
this.buildCounter,
|
||||||
this.scrollController,
|
this.scrollController,
|
||||||
@ -675,6 +676,24 @@ class TextField extends StatefulWidget {
|
|||||||
/// {@endtemplate}
|
/// {@endtemplate}
|
||||||
final GestureTapCallback? onTap;
|
final GestureTapCallback? onTap;
|
||||||
|
|
||||||
|
/// {@macro flutter.widgets.editableText.onTapOutside}
|
||||||
|
///
|
||||||
|
/// {@tool dartpad}
|
||||||
|
/// This example shows how to use a `TextFieldTapRegion` to wrap a set of
|
||||||
|
/// "spinner" buttons that increment and decrement a value in the [TextField]
|
||||||
|
/// without causing the text field to lose keyboard focus.
|
||||||
|
///
|
||||||
|
/// This example includes a generic `SpinnerField<T>` class that you can copy
|
||||||
|
/// into your own project and customize.
|
||||||
|
///
|
||||||
|
/// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart **
|
||||||
|
/// {@end-tool}
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [TapRegion] for how the region group is determined.
|
||||||
|
final TapRegionCallback? onTapOutside;
|
||||||
|
|
||||||
/// The cursor for a mouse pointer when it enters or is hovering over the
|
/// The cursor for a mouse pointer when it enters or is hovering over the
|
||||||
/// widget.
|
/// widget.
|
||||||
///
|
///
|
||||||
@ -1267,6 +1286,7 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
|
|||||||
onSubmitted: widget.onSubmitted,
|
onSubmitted: widget.onSubmitted,
|
||||||
onAppPrivateCommand: widget.onAppPrivateCommand,
|
onAppPrivateCommand: widget.onAppPrivateCommand,
|
||||||
onSelectionHandleTapped: _handleSelectionHandleTapped,
|
onSelectionHandleTapped: _handleSelectionHandleTapped,
|
||||||
|
onTapOutside: widget.onTapOutside,
|
||||||
inputFormatters: formatters,
|
inputFormatters: formatters,
|
||||||
rendererIgnoresPointer: true,
|
rendererIgnoresPointer: true,
|
||||||
mouseCursor: MouseCursor.defer, // TextField will handle the cursor
|
mouseCursor: MouseCursor.defer, // TextField will handle the cursor
|
||||||
@ -1334,12 +1354,11 @@ class _TextFieldState extends State<TextField> with RestorationMixin implements
|
|||||||
semanticsMaxValueLength = null;
|
semanticsMaxValueLength = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
return FocusTrapArea(
|
return MouseRegion(
|
||||||
focusNode: focusNode,
|
cursor: effectiveMouseCursor,
|
||||||
child: MouseRegion(
|
onEnter: (PointerEnterEvent event) => _handleHover(true),
|
||||||
cursor: effectiveMouseCursor,
|
onExit: (PointerExitEvent event) => _handleHover(false),
|
||||||
onEnter: (PointerEnterEvent event) => _handleHover(true),
|
child: TextFieldTapRegion(
|
||||||
onExit: (PointerExitEvent event) => _handleHover(false),
|
|
||||||
child: IgnorePointer(
|
child: IgnorePointer(
|
||||||
ignoring: !_isEnabled,
|
ignoring: !_isEnabled,
|
||||||
child: AnimatedBuilder(
|
child: AnimatedBuilder(
|
||||||
|
@ -26,6 +26,7 @@ import 'scrollable.dart';
|
|||||||
import 'semantics_debugger.dart';
|
import 'semantics_debugger.dart';
|
||||||
import 'shared_app_data.dart';
|
import 'shared_app_data.dart';
|
||||||
import 'shortcuts.dart';
|
import 'shortcuts.dart';
|
||||||
|
import 'tap_region.dart';
|
||||||
import 'text.dart';
|
import 'text.dart';
|
||||||
import 'title.dart';
|
import 'title.dart';
|
||||||
import 'widget_inspector.dart';
|
import 'widget_inspector.dart';
|
||||||
@ -1740,8 +1741,10 @@ class _WidgetsAppState extends State<WidgetsApp> with WidgetsBindingObserver {
|
|||||||
actions: widget.actions ?? WidgetsApp.defaultActions,
|
actions: widget.actions ?? WidgetsApp.defaultActions,
|
||||||
child: FocusTraversalGroup(
|
child: FocusTraversalGroup(
|
||||||
policy: ReadingOrderTraversalPolicy(),
|
policy: ReadingOrderTraversalPolicy(),
|
||||||
child: ShortcutRegistrar(
|
child: TapRegionSurface(
|
||||||
child: child,
|
child: ShortcutRegistrar(
|
||||||
|
child: child,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -32,6 +32,7 @@ import 'scroll_controller.dart';
|
|||||||
import 'scroll_physics.dart';
|
import 'scroll_physics.dart';
|
||||||
import 'scrollable.dart';
|
import 'scrollable.dart';
|
||||||
import 'shortcuts.dart';
|
import 'shortcuts.dart';
|
||||||
|
import 'tap_region.dart';
|
||||||
import 'text.dart';
|
import 'text.dart';
|
||||||
import 'text_editing_intents.dart';
|
import 'text_editing_intents.dart';
|
||||||
import 'text_selection.dart';
|
import 'text_selection.dart';
|
||||||
@ -608,6 +609,7 @@ class EditableText extends StatefulWidget {
|
|||||||
this.onAppPrivateCommand,
|
this.onAppPrivateCommand,
|
||||||
this.onSelectionChanged,
|
this.onSelectionChanged,
|
||||||
this.onSelectionHandleTapped,
|
this.onSelectionHandleTapped,
|
||||||
|
this.onTapOutside,
|
||||||
List<TextInputFormatter>? inputFormatters,
|
List<TextInputFormatter>? inputFormatters,
|
||||||
this.mouseCursor,
|
this.mouseCursor,
|
||||||
this.rendererIgnoresPointer = false,
|
this.rendererIgnoresPointer = false,
|
||||||
@ -1213,6 +1215,46 @@ class EditableText extends StatefulWidget {
|
|||||||
/// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
|
/// {@macro flutter.widgets.SelectionOverlay.onSelectionHandleTapped}
|
||||||
final VoidCallback? onSelectionHandleTapped;
|
final VoidCallback? onSelectionHandleTapped;
|
||||||
|
|
||||||
|
/// {@template flutter.widgets.editableText.onTapOutside}
|
||||||
|
/// Called for each tap that occurs outside of the[TextFieldTapRegion] group
|
||||||
|
/// when the text field is focused.
|
||||||
|
///
|
||||||
|
/// If this is null, [FocusNode.unfocus] will be called on the [focusNode] for
|
||||||
|
/// this text field when a [PointerDownEvent] is received on another part of
|
||||||
|
/// the UI. However, it will not unfocus as a result of mobile application
|
||||||
|
/// touch events (which does not include mouse clicks), to conform with the
|
||||||
|
/// platform conventions. To change this behavior, a callback may be set here
|
||||||
|
/// that operates differently from the default.
|
||||||
|
///
|
||||||
|
/// When adding additional controls to a text field (for example, a spinner, a
|
||||||
|
/// button that copies the selected text, or modifies formatting), it is
|
||||||
|
/// helpful if tapping on that control doesn't unfocus the text field. In
|
||||||
|
/// order for an external widget to be considered as part of the text field
|
||||||
|
/// for the purposes of tapping "outside" of the field, wrap the control in a
|
||||||
|
/// [TextFieldTapRegion].
|
||||||
|
///
|
||||||
|
/// The [PointerDownEvent] passed to the function is the event that caused the
|
||||||
|
/// notification. It is possible that the event may occur outside of the
|
||||||
|
/// immediate bounding box defined by the text field, although it will be
|
||||||
|
/// within the bounding box of a [TextFieldTapRegion] member.
|
||||||
|
/// {@endtemplate}
|
||||||
|
///
|
||||||
|
/// {@tool dartpad}
|
||||||
|
/// This example shows how to use a `TextFieldTapRegion` to wrap a set of
|
||||||
|
/// "spinner" buttons that increment and decrement a value in the [TextField]
|
||||||
|
/// without causing the text field to lose keyboard focus.
|
||||||
|
///
|
||||||
|
/// This example includes a generic `SpinnerField<T>` class that you can copy
|
||||||
|
/// into your own project and customize.
|
||||||
|
///
|
||||||
|
/// ** See code in examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart **
|
||||||
|
/// {@end-tool}
|
||||||
|
///
|
||||||
|
/// See also:
|
||||||
|
///
|
||||||
|
/// * [TapRegion] for how the region group is determined.
|
||||||
|
final TapRegionCallback? onTapOutside;
|
||||||
|
|
||||||
/// {@template flutter.widgets.editableText.inputFormatters}
|
/// {@template flutter.widgets.editableText.inputFormatters}
|
||||||
/// Optional input validation and formatting overrides.
|
/// Optional input validation and formatting overrides.
|
||||||
///
|
///
|
||||||
@ -3421,6 +3463,43 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
return Actions.invoke(context, intent);
|
return Actions.invoke(context, intent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/// The default behavior used if [onTapOutside] is null.
|
||||||
|
///
|
||||||
|
/// The `event` argument is the [PointerDownEvent] that caused the notification.
|
||||||
|
void _defaultOnTapOutside(PointerDownEvent event) {
|
||||||
|
/// The focus dropping behavior is only present on desktop platforms
|
||||||
|
/// and mobile browsers.
|
||||||
|
switch (defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
// On mobile platforms, we don't unfocus on touch events unless they're
|
||||||
|
// in the web browser, but we do unfocus for all other kinds of events.
|
||||||
|
switch (event.kind) {
|
||||||
|
case ui.PointerDeviceKind.touch:
|
||||||
|
if (kIsWeb) {
|
||||||
|
widget.focusNode.unfocus();
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case ui.PointerDeviceKind.mouse:
|
||||||
|
case ui.PointerDeviceKind.stylus:
|
||||||
|
case ui.PointerDeviceKind.invertedStylus:
|
||||||
|
case ui.PointerDeviceKind.unknown:
|
||||||
|
widget.focusNode.unfocus();
|
||||||
|
break;
|
||||||
|
case ui.PointerDeviceKind.trackpad:
|
||||||
|
throw UnimplementedError('Unexpected pointer down event for trackpad');
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
widget.focusNode.unfocus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
|
late final Map<Type, Action<Intent>> _actions = <Type, Action<Intent>>{
|
||||||
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
|
DoNothingAndStopPropagationTextIntent: DoNothingAction(consumesKey: false),
|
||||||
ReplaceTextIntent: _replaceTextAction,
|
ReplaceTextIntent: _replaceTextAction,
|
||||||
@ -3458,96 +3537,100 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
super.build(context); // See AutomaticKeepAliveClientMixin.
|
super.build(context); // See AutomaticKeepAliveClientMixin.
|
||||||
|
|
||||||
final TextSelectionControls? controls = widget.selectionControls;
|
final TextSelectionControls? controls = widget.selectionControls;
|
||||||
return MouseRegion(
|
return TextFieldTapRegion(
|
||||||
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
|
onTapOutside: widget.onTapOutside ?? _defaultOnTapOutside,
|
||||||
child: Actions(
|
debugLabel: kReleaseMode ? null : 'EditableText',
|
||||||
actions: _actions,
|
child: MouseRegion(
|
||||||
child: _TextEditingHistory(
|
cursor: widget.mouseCursor ?? SystemMouseCursors.text,
|
||||||
controller: widget.controller,
|
child: Actions(
|
||||||
onTriggered: (TextEditingValue value) {
|
actions: _actions,
|
||||||
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
|
child: _TextEditingHistory(
|
||||||
},
|
controller: widget.controller,
|
||||||
child: Focus(
|
onTriggered: (TextEditingValue value) {
|
||||||
focusNode: widget.focusNode,
|
userUpdateTextEditingValue(value, SelectionChangedCause.keyboard);
|
||||||
includeSemantics: false,
|
},
|
||||||
debugLabel: 'EditableText',
|
child: Focus(
|
||||||
child: Scrollable(
|
focusNode: widget.focusNode,
|
||||||
excludeFromSemantics: true,
|
includeSemantics: false,
|
||||||
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
|
debugLabel: kReleaseMode ? null : 'EditableText',
|
||||||
controller: _scrollController,
|
child: Scrollable(
|
||||||
physics: widget.scrollPhysics,
|
excludeFromSemantics: true,
|
||||||
dragStartBehavior: widget.dragStartBehavior,
|
axisDirection: _isMultiline ? AxisDirection.down : AxisDirection.right,
|
||||||
restorationId: widget.restorationId,
|
controller: _scrollController,
|
||||||
// If a ScrollBehavior is not provided, only apply scrollbars when
|
physics: widget.scrollPhysics,
|
||||||
// multiline. The overscroll indicator should not be applied in
|
dragStartBehavior: widget.dragStartBehavior,
|
||||||
// either case, glowing or stretching.
|
restorationId: widget.restorationId,
|
||||||
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
|
// If a ScrollBehavior is not provided, only apply scrollbars when
|
||||||
scrollbars: _isMultiline,
|
// multiline. The overscroll indicator should not be applied in
|
||||||
overscroll: false,
|
// either case, glowing or stretching.
|
||||||
),
|
scrollBehavior: widget.scrollBehavior ?? ScrollConfiguration.of(context).copyWith(
|
||||||
viewportBuilder: (BuildContext context, ViewportOffset offset) {
|
scrollbars: _isMultiline,
|
||||||
return CompositedTransformTarget(
|
overscroll: false,
|
||||||
link: _toolbarLayerLink,
|
),
|
||||||
child: Semantics(
|
viewportBuilder: (BuildContext context, ViewportOffset offset) {
|
||||||
onCopy: _semanticsOnCopy(controls),
|
return CompositedTransformTarget(
|
||||||
onCut: _semanticsOnCut(controls),
|
link: _toolbarLayerLink,
|
||||||
onPaste: _semanticsOnPaste(controls),
|
child: Semantics(
|
||||||
child: _ScribbleFocusable(
|
onCopy: _semanticsOnCopy(controls),
|
||||||
focusNode: widget.focusNode,
|
onCut: _semanticsOnCut(controls),
|
||||||
editableKey: _editableKey,
|
onPaste: _semanticsOnPaste(controls),
|
||||||
enabled: widget.scribbleEnabled,
|
child: _ScribbleFocusable(
|
||||||
updateSelectionRects: () {
|
focusNode: widget.focusNode,
|
||||||
_openInputConnection();
|
editableKey: _editableKey,
|
||||||
_updateSelectionRects(force: true);
|
enabled: widget.scribbleEnabled,
|
||||||
},
|
updateSelectionRects: () {
|
||||||
child: _Editable(
|
_openInputConnection();
|
||||||
key: _editableKey,
|
_updateSelectionRects(force: true);
|
||||||
startHandleLayerLink: _startHandleLayerLink,
|
},
|
||||||
endHandleLayerLink: _endHandleLayerLink,
|
child: _Editable(
|
||||||
inlineSpan: buildTextSpan(),
|
key: _editableKey,
|
||||||
value: _value,
|
startHandleLayerLink: _startHandleLayerLink,
|
||||||
cursorColor: _cursorColor,
|
endHandleLayerLink: _endHandleLayerLink,
|
||||||
backgroundCursorColor: widget.backgroundCursorColor,
|
inlineSpan: buildTextSpan(),
|
||||||
showCursor: EditableText.debugDeterministicCursor
|
value: _value,
|
||||||
? ValueNotifier<bool>(widget.showCursor)
|
cursorColor: _cursorColor,
|
||||||
: _cursorVisibilityNotifier,
|
backgroundCursorColor: widget.backgroundCursorColor,
|
||||||
forceLine: widget.forceLine,
|
showCursor: EditableText.debugDeterministicCursor
|
||||||
readOnly: widget.readOnly,
|
? ValueNotifier<bool>(widget.showCursor)
|
||||||
hasFocus: _hasFocus,
|
: _cursorVisibilityNotifier,
|
||||||
maxLines: widget.maxLines,
|
forceLine: widget.forceLine,
|
||||||
minLines: widget.minLines,
|
readOnly: widget.readOnly,
|
||||||
expands: widget.expands,
|
hasFocus: _hasFocus,
|
||||||
strutStyle: widget.strutStyle,
|
maxLines: widget.maxLines,
|
||||||
selectionColor: widget.selectionColor,
|
minLines: widget.minLines,
|
||||||
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
|
expands: widget.expands,
|
||||||
textAlign: widget.textAlign,
|
strutStyle: widget.strutStyle,
|
||||||
textDirection: _textDirection,
|
selectionColor: widget.selectionColor,
|
||||||
locale: widget.locale,
|
textScaleFactor: widget.textScaleFactor ?? MediaQuery.textScaleFactorOf(context),
|
||||||
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
|
textAlign: widget.textAlign,
|
||||||
textWidthBasis: widget.textWidthBasis,
|
textDirection: _textDirection,
|
||||||
obscuringCharacter: widget.obscuringCharacter,
|
locale: widget.locale,
|
||||||
obscureText: widget.obscureText,
|
textHeightBehavior: widget.textHeightBehavior ?? DefaultTextHeightBehavior.of(context),
|
||||||
offset: offset,
|
textWidthBasis: widget.textWidthBasis,
|
||||||
onCaretChanged: _handleCaretChanged,
|
obscuringCharacter: widget.obscuringCharacter,
|
||||||
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
obscureText: widget.obscureText,
|
||||||
cursorWidth: widget.cursorWidth,
|
offset: offset,
|
||||||
cursorHeight: widget.cursorHeight,
|
onCaretChanged: _handleCaretChanged,
|
||||||
cursorRadius: widget.cursorRadius,
|
rendererIgnoresPointer: widget.rendererIgnoresPointer,
|
||||||
cursorOffset: widget.cursorOffset ?? Offset.zero,
|
cursorWidth: widget.cursorWidth,
|
||||||
selectionHeightStyle: widget.selectionHeightStyle,
|
cursorHeight: widget.cursorHeight,
|
||||||
selectionWidthStyle: widget.selectionWidthStyle,
|
cursorRadius: widget.cursorRadius,
|
||||||
paintCursorAboveText: widget.paintCursorAboveText,
|
cursorOffset: widget.cursorOffset ?? Offset.zero,
|
||||||
enableInteractiveSelection: widget._userSelectionEnabled,
|
selectionHeightStyle: widget.selectionHeightStyle,
|
||||||
textSelectionDelegate: this,
|
selectionWidthStyle: widget.selectionWidthStyle,
|
||||||
devicePixelRatio: _devicePixelRatio,
|
paintCursorAboveText: widget.paintCursorAboveText,
|
||||||
promptRectRange: _currentPromptRectRange,
|
enableInteractiveSelection: widget._userSelectionEnabled,
|
||||||
promptRectColor: widget.autocorrectionTextRectColor,
|
textSelectionDelegate: this,
|
||||||
clipBehavior: widget.clipBehavior,
|
devicePixelRatio: _devicePixelRatio,
|
||||||
|
promptRectRange: _currentPromptRectRange,
|
||||||
|
promptRectColor: widget.autocorrectionTextRectColor,
|
||||||
|
clipBehavior: widget.clipBehavior,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
},
|
||||||
},
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
@ -6,7 +6,6 @@ import 'dart:async';
|
|||||||
import 'dart:ui' as ui;
|
import 'dart:ui' as ui;
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:flutter/gestures.dart';
|
|
||||||
import 'package:flutter/rendering.dart';
|
import 'package:flutter/rendering.dart';
|
||||||
import 'package:flutter/scheduler.dart';
|
import 'package:flutter/scheduler.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
@ -886,45 +885,42 @@ class _ModalScopeState<T> extends State<_ModalScope<T>> {
|
|||||||
controller: primaryScrollController,
|
controller: primaryScrollController,
|
||||||
child: FocusScope(
|
child: FocusScope(
|
||||||
node: focusScopeNode, // immutable
|
node: focusScopeNode, // immutable
|
||||||
child: FocusTrap(
|
child: RepaintBoundary(
|
||||||
focusScopeNode: focusScopeNode,
|
child: AnimatedBuilder(
|
||||||
child: RepaintBoundary(
|
animation: _listenable, // immutable
|
||||||
child: AnimatedBuilder(
|
builder: (BuildContext context, Widget? child) {
|
||||||
animation: _listenable, // immutable
|
return widget.route.buildTransitions(
|
||||||
builder: (BuildContext context, Widget? child) {
|
context,
|
||||||
return widget.route.buildTransitions(
|
widget.route.animation!,
|
||||||
context,
|
widget.route.secondaryAnimation!,
|
||||||
widget.route.animation!,
|
// This additional AnimatedBuilder is include because if the
|
||||||
widget.route.secondaryAnimation!,
|
// value of the userGestureInProgressNotifier changes, it's
|
||||||
// This additional AnimatedBuilder is include because if the
|
// only necessary to rebuild the IgnorePointer widget and set
|
||||||
// value of the userGestureInProgressNotifier changes, it's
|
// the focus node's ability to focus.
|
||||||
// only necessary to rebuild the IgnorePointer widget and set
|
AnimatedBuilder(
|
||||||
// the focus node's ability to focus.
|
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
|
||||||
AnimatedBuilder(
|
builder: (BuildContext context, Widget? child) {
|
||||||
animation: widget.route.navigator?.userGestureInProgressNotifier ?? ValueNotifier<bool>(false),
|
final bool ignoreEvents = _shouldIgnoreFocusRequest;
|
||||||
builder: (BuildContext context, Widget? child) {
|
focusScopeNode.canRequestFocus = !ignoreEvents;
|
||||||
final bool ignoreEvents = _shouldIgnoreFocusRequest;
|
return IgnorePointer(
|
||||||
focusScopeNode.canRequestFocus = !ignoreEvents;
|
ignoring: ignoreEvents,
|
||||||
return IgnorePointer(
|
child: child,
|
||||||
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: 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.
|
/// See [ModalRoute.buildTransitions] for complete definition of the parameters.
|
||||||
typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child);
|
typedef RouteTransitionsBuilder = Widget Function(BuildContext context, Animation<double> animation, Animation<double> secondaryAnimation, Widget child);
|
||||||
|
|
||||||
/// The [FocusTrap] widget removes focus when a mouse primary pointer makes contact with another
|
|
||||||
/// region of the screen.
|
|
||||||
///
|
|
||||||
/// When a primary pointer makes contact with the screen, this widget determines if that pointer
|
|
||||||
/// contacted an existing focused widget. If not, this asks the [FocusScopeNode] to reset the
|
|
||||||
/// focus state. This allows [TextField]s and other focusable widgets to give up their focus
|
|
||||||
/// state, without creating a gesture detector that competes with others on screen.
|
|
||||||
///
|
|
||||||
/// In cases where focus is conceptually larger than the focused render object, a [FocusTrapArea]
|
|
||||||
/// can be used to expand the focus area to include all render objects below that. This is used by
|
|
||||||
/// the [TextField] widgets to prevent a loss of focus when interacting with decorations on the
|
|
||||||
/// text area.
|
|
||||||
///
|
|
||||||
/// See also:
|
|
||||||
///
|
|
||||||
/// * [FocusTrapArea], the widget that allows expanding the conceptual focus area.
|
|
||||||
class FocusTrap extends SingleChildRenderObjectWidget {
|
|
||||||
|
|
||||||
/// Create a new [FocusTrap] widget scoped to the provided [focusScopeNode].
|
|
||||||
const FocusTrap({
|
|
||||||
required this.focusScopeNode,
|
|
||||||
required Widget super.child,
|
|
||||||
super.key,
|
|
||||||
});
|
|
||||||
|
|
||||||
/// The [focusScopeNode] that this focus trap widget operates on.
|
|
||||||
final FocusScopeNode focusScopeNode;
|
|
||||||
|
|
||||||
@override
|
|
||||||
RenderObject createRenderObject(BuildContext context) {
|
|
||||||
return _RenderFocusTrap(focusScopeNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
|
||||||
if (renderObject is _RenderFocusTrap) {
|
|
||||||
renderObject.focusScopeNode = focusScopeNode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Declares a widget subtree which is part of the provided [focusNode]'s focus area
|
|
||||||
/// without attaching focus to that region.
|
|
||||||
///
|
|
||||||
/// This is used by text field widgets which decorate a smaller editable text area.
|
|
||||||
/// This area is conceptually part of the editable text, but not attached to the
|
|
||||||
/// focus context. The [FocusTrapArea] is used to inform the framework of this
|
|
||||||
/// relationship, so that primary pointer contact inside of this region but above
|
|
||||||
/// the editable text focus will not trigger loss of focus.
|
|
||||||
///
|
|
||||||
/// See also:
|
|
||||||
///
|
|
||||||
/// * [FocusTrap], the widget which removes focus based on primary pointer interactions.
|
|
||||||
class FocusTrapArea extends SingleChildRenderObjectWidget {
|
|
||||||
|
|
||||||
/// Create a new [FocusTrapArea] that expands the area of the provided [focusNode].
|
|
||||||
const FocusTrapArea({required this.focusNode, super.key, super.child});
|
|
||||||
|
|
||||||
/// The [FocusNode] that the focus trap area will expand to.
|
|
||||||
final FocusNode focusNode;
|
|
||||||
|
|
||||||
@override
|
|
||||||
RenderObject createRenderObject(BuildContext context) {
|
|
||||||
return _RenderFocusTrapArea(focusNode);
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void updateRenderObject(BuildContext context, RenderObject renderObject) {
|
|
||||||
if (renderObject is _RenderFocusTrapArea) {
|
|
||||||
renderObject.focusNode = focusNode;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RenderFocusTrapArea extends RenderProxyBox {
|
|
||||||
_RenderFocusTrapArea(this.focusNode);
|
|
||||||
|
|
||||||
FocusNode focusNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
class _RenderFocusTrap extends RenderProxyBoxWithHitTestBehavior {
|
|
||||||
_RenderFocusTrap(this._focusScopeNode);
|
|
||||||
|
|
||||||
Rect? currentFocusRect;
|
|
||||||
Expando<BoxHitTestResult> cachedResults = Expando<BoxHitTestResult>();
|
|
||||||
|
|
||||||
FocusScopeNode _focusScopeNode;
|
|
||||||
FocusNode? _previousFocus;
|
|
||||||
FocusScopeNode get focusScopeNode => _focusScopeNode;
|
|
||||||
set focusScopeNode(FocusScopeNode value) {
|
|
||||||
if (focusScopeNode == value) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
_focusScopeNode = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
bool hitTest(BoxHitTestResult result, { required Offset position }) {
|
|
||||||
bool hitTarget = false;
|
|
||||||
if (size.contains(position)) {
|
|
||||||
hitTarget = hitTestChildren(result, position: position) || hitTestSelf(position);
|
|
||||||
if (hitTarget) {
|
|
||||||
final BoxHitTestEntry entry = BoxHitTestEntry(this, position);
|
|
||||||
cachedResults[entry] = result;
|
|
||||||
result.add(entry);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return hitTarget;
|
|
||||||
}
|
|
||||||
|
|
||||||
/// The focus dropping behavior is only present on desktop platforms
|
|
||||||
/// and mobile browsers.
|
|
||||||
bool get _shouldIgnoreEvents {
|
|
||||||
switch (defaultTargetPlatform) {
|
|
||||||
case TargetPlatform.android:
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
return !kIsWeb;
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
void _checkForUnfocus() {
|
|
||||||
if (_previousFocus == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
// Only continue to unfocus if the previous focus matches the current focus.
|
|
||||||
// If the focus has changed in the meantime, it was probably intentional.
|
|
||||||
if (FocusManager.instance.primaryFocus == _previousFocus) {
|
|
||||||
_previousFocus!.unfocus();
|
|
||||||
}
|
|
||||||
_previousFocus = null;
|
|
||||||
}
|
|
||||||
|
|
||||||
@override
|
|
||||||
void handleEvent(PointerEvent event, HitTestEntry entry) {
|
|
||||||
assert(debugHandleEvent(event, entry));
|
|
||||||
if (event is! PointerDownEvent
|
|
||||||
|| event.buttons != kPrimaryButton
|
|
||||||
|| event.kind != PointerDeviceKind.mouse
|
|
||||||
|| _shouldIgnoreEvents
|
|
||||||
|| _focusScopeNode.focusedChild == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
final BoxHitTestResult? result = cachedResults[entry];
|
|
||||||
final FocusNode? focusNode = _focusScopeNode.focusedChild;
|
|
||||||
if (focusNode == null || result == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
final RenderObject? renderObject = focusNode.context?.findRenderObject();
|
|
||||||
if (renderObject == null) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hitCurrentFocus = false;
|
|
||||||
for (final HitTestEntry entry in result.path) {
|
|
||||||
final HitTestTarget target = entry.target;
|
|
||||||
if (target == renderObject) {
|
|
||||||
hitCurrentFocus = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
if (target is _RenderFocusTrapArea && target.focusNode == focusNode) {
|
|
||||||
hitCurrentFocus = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (!hitCurrentFocus) {
|
|
||||||
_previousFocus = focusNode;
|
|
||||||
// Check post-frame to see that the focus hasn't changed before
|
|
||||||
// unfocusing. This also allows a button tap to capture the previously
|
|
||||||
// active focus before FocusTrap tries to unfocus it, and avoids a bounce
|
|
||||||
// through the scope's focus node in between.
|
|
||||||
SchedulerBinding.instance.scheduleTask<void>(_checkForUnfocus, Priority.touch);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
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 'framework.dart';
|
||||||
import 'gesture_detector.dart';
|
import 'gesture_detector.dart';
|
||||||
import 'overlay.dart';
|
import 'overlay.dart';
|
||||||
|
import 'tap_region.dart';
|
||||||
import 'ticker_provider.dart';
|
import 'ticker_provider.dart';
|
||||||
import 'transitions.dart';
|
import 'transitions.dart';
|
||||||
|
|
||||||
@ -958,8 +959,10 @@ class SelectionOverlay {
|
|||||||
dragStartBehavior: dragStartBehavior,
|
dragStartBehavior: dragStartBehavior,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ExcludeSemantics(
|
return TextFieldTapRegion(
|
||||||
child: handle,
|
child: ExcludeSemantics(
|
||||||
|
child: handle,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -983,8 +986,10 @@ class SelectionOverlay {
|
|||||||
dragStartBehavior: dragStartBehavior,
|
dragStartBehavior: dragStartBehavior,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
return ExcludeSemantics(
|
return TextFieldTapRegion(
|
||||||
child: handle,
|
child: ExcludeSemantics(
|
||||||
|
child: handle,
|
||||||
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -1015,19 +1020,21 @@ class SelectionOverlay {
|
|||||||
selectionEndpoints.first.point.dy - lineHeightAtStart,
|
selectionEndpoints.first.point.dy - lineHeightAtStart,
|
||||||
);
|
);
|
||||||
|
|
||||||
return Directionality(
|
return TextFieldTapRegion(
|
||||||
textDirection: Directionality.of(this.context),
|
child: Directionality(
|
||||||
child: _SelectionToolbarOverlay(
|
textDirection: Directionality.of(this.context),
|
||||||
preferredLineHeight: lineHeightAtStart,
|
child: _SelectionToolbarOverlay(
|
||||||
toolbarLocation: toolbarLocation,
|
preferredLineHeight: lineHeightAtStart,
|
||||||
layerLink: toolbarLayerLink,
|
toolbarLocation: toolbarLocation,
|
||||||
editingRegion: editingRegion,
|
layerLink: toolbarLayerLink,
|
||||||
selectionControls: selectionControls,
|
editingRegion: editingRegion,
|
||||||
midpoint: midpoint,
|
selectionControls: selectionControls,
|
||||||
selectionEndpoints: selectionEndpoints,
|
midpoint: midpoint,
|
||||||
visibility: toolbarVisible,
|
selectionEndpoints: selectionEndpoints,
|
||||||
selectionDelegate: selectionDelegate,
|
visibility: toolbarVisible,
|
||||||
clipboardStatus: clipboardStatus,
|
selectionDelegate: selectionDelegate,
|
||||||
|
clipboardStatus: clipboardStatus,
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -128,6 +128,7 @@ export 'src/widgets/slotted_render_object_widget.dart';
|
|||||||
export 'src/widgets/spacer.dart';
|
export 'src/widgets/spacer.dart';
|
||||||
export 'src/widgets/status_transitions.dart';
|
export 'src/widgets/status_transitions.dart';
|
||||||
export 'src/widgets/table.dart';
|
export 'src/widgets/table.dart';
|
||||||
|
export 'src/widgets/tap_region.dart';
|
||||||
export 'src/widgets/text.dart';
|
export 'src/widgets/text.dart';
|
||||||
export 'src/widgets/text_editing_intents.dart';
|
export 'src/widgets/text_editing_intents.dart';
|
||||||
export 'src/widgets/text_selection.dart';
|
export 'src/widgets/text_selection.dart';
|
||||||
|
@ -5960,4 +5960,144 @@ void main() {
|
|||||||
expect(controller.selection.extentOffset, 5);
|
expect(controller.selection.extentOffset, 5);
|
||||||
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
|
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
|
||||||
});
|
});
|
||||||
|
|
||||||
|
group('TapRegion integration', () {
|
||||||
|
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: CupertinoTextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
// Tap outside the border.
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(focusNode.hasPrimaryFocus, isFalse);
|
||||||
|
}, variant: TargetPlatformVariant.desktop());
|
||||||
|
|
||||||
|
testWidgets("Tapping outside doesn't lose focus on mobile", (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: CupertinoTextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
// Tap just outside the border, but not inside the EditableText.
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Focus is lost on mobile browsers, but not mobile apps.
|
||||||
|
expect(focusNode.hasPrimaryFocus, kIsWeb ? isFalse : isTrue);
|
||||||
|
}, variant: TargetPlatformVariant.mobile());
|
||||||
|
|
||||||
|
testWidgets("tapping on toolbar doesn't lose focus", (WidgetTester tester) async {
|
||||||
|
final TextEditingController controller;
|
||||||
|
final EditableTextState state;
|
||||||
|
|
||||||
|
controller = TextEditingController(text: 'A B C');
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
debugShowCheckedModeBanner: false,
|
||||||
|
home: CupertinoPageScaffold(
|
||||||
|
child: Align(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 200,
|
||||||
|
height: 200,
|
||||||
|
child: CupertinoTextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
controller: controller,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
state = tester.state<EditableTextState>(find.byType(EditableText));
|
||||||
|
|
||||||
|
// Select the first 2 words.
|
||||||
|
state.renderEditable.selectPositionAt(
|
||||||
|
from: textOffsetToPosition(tester, 0),
|
||||||
|
to: textOffsetToPosition(tester, 2),
|
||||||
|
cause: SelectionChangedCause.tap,
|
||||||
|
);
|
||||||
|
|
||||||
|
final Offset midSelection = textOffsetToPosition(tester, 2);
|
||||||
|
|
||||||
|
// Right click the selection.
|
||||||
|
final TestGesture gesture = await tester.startGesture(
|
||||||
|
midSelection,
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(find.text('Copy'), findsOneWidget);
|
||||||
|
|
||||||
|
// Copy the first word.
|
||||||
|
await tester.tap(find.text('Copy'));
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
},
|
||||||
|
variant: TargetPlatformVariant.all(),
|
||||||
|
skip: kIsWeb, // [intended] The toolbar isn't rendered by Flutter on the web, it's rendered by the browser.
|
||||||
|
);
|
||||||
|
testWidgets("Tapping on border doesn't lose focus", (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
CupertinoApp(
|
||||||
|
home: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: CupertinoTextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
final Rect borderBox = tester.getRect(find.byType(CupertinoTextField));
|
||||||
|
// Tap just inside the border, but not inside the EditableText.
|
||||||
|
await tester.tapAt(borderBox.topLeft + const Offset(1, 1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -134,7 +134,6 @@ void main() {
|
|||||||
' _FadeUpwardsPageTransition\n'
|
' _FadeUpwardsPageTransition\n'
|
||||||
' AnimatedBuilder\n'
|
' AnimatedBuilder\n'
|
||||||
' RepaintBoundary\n'
|
' RepaintBoundary\n'
|
||||||
' FocusTrap\n'
|
|
||||||
' _FocusMarker\n'
|
' _FocusMarker\n'
|
||||||
' Semantics\n'
|
' Semantics\n'
|
||||||
' FocusScope\n'
|
' FocusScope\n'
|
||||||
@ -192,6 +191,7 @@ void main() {
|
|||||||
' Focus\n'
|
' Focus\n'
|
||||||
' Shortcuts\n'
|
' Shortcuts\n'
|
||||||
' ShortcutRegistrar\n'
|
' ShortcutRegistrar\n'
|
||||||
|
' TapRegionSurface\n'
|
||||||
' _FocusMarker\n'
|
' _FocusMarker\n'
|
||||||
' Focus\n'
|
' Focus\n'
|
||||||
' _FocusTraversalGroupMarker\n'
|
' _FocusTraversalGroupMarker\n'
|
||||||
|
@ -5417,7 +5417,7 @@ void main() {
|
|||||||
|
|
||||||
final double floatedLabelWidth = getLabelRect(tester).width;
|
final double floatedLabelWidth = getLabelRect(tester).width;
|
||||||
|
|
||||||
expect(floatedLabelWidth > labelWidth, isTrue);
|
expect(floatedLabelWidth, greaterThan(labelWidth));
|
||||||
|
|
||||||
final Widget target = getLabeledInputDecorator(FloatingLabelBehavior.auto);
|
final Widget target = getLabeledInputDecorator(FloatingLabelBehavior.auto);
|
||||||
await tester.pumpWidget(target);
|
await tester.pumpWidget(target);
|
||||||
@ -5430,8 +5430,8 @@ void main() {
|
|||||||
// Default animation duration is 200 millisecond.
|
// Default animation duration is 200 millisecond.
|
||||||
await tester.pumpFrames(target, const Duration(milliseconds: 100));
|
await tester.pumpFrames(target, const Duration(milliseconds: 100));
|
||||||
|
|
||||||
expect(getLabelRect(tester).width > labelWidth, isTrue);
|
expect(getLabelRect(tester).width, greaterThan(labelWidth));
|
||||||
expect(getLabelRect(tester).width < floatedLabelWidth, isTrue);
|
expect(getLabelRect(tester).width, lessThanOrEqualTo(floatedLabelWidth));
|
||||||
|
|
||||||
await tester.pumpAndSettle();
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
@ -186,7 +186,7 @@ void main() {
|
|||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
expect(tester.testTextInput.isVisible, isTrue);
|
expect(tester.testTextInput.isVisible, isTrue);
|
||||||
|
|
||||||
await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
|
await tester.drag(find.byType(TextField), const Offset(0.0, -1000.0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(TextField, skipOffstage: false), findsOneWidget);
|
expect(find.byType(TextField, skipOffstage: false), findsOneWidget);
|
||||||
expect(tester.testTextInput.isVisible, isTrue);
|
expect(tester.testTextInput.isVisible, isTrue);
|
||||||
@ -225,7 +225,7 @@ void main() {
|
|||||||
FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode);
|
FocusScope.of(tester.element(find.byType(TextField))).requestFocus(focusNode);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(TextField), findsOneWidget);
|
expect(find.byType(TextField), findsOneWidget);
|
||||||
await tester.drag(find.byType(ListView), const Offset(0.0, -1000.0));
|
await tester.drag(find.byType(TextField), const Offset(0.0, -1000.0));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
expect(find.byType(TextField, skipOffstage: false), findsOneWidget);
|
expect(find.byType(TextField, skipOffstage: false), findsOneWidget);
|
||||||
await tester.pumpWidget(makeTest('test'));
|
await tester.pumpWidget(makeTest('test'));
|
||||||
@ -490,8 +490,8 @@ void main() {
|
|||||||
}, variant: TargetPlatformVariant.desktop());
|
}, variant: TargetPlatformVariant.desktop());
|
||||||
|
|
||||||
testWidgets('A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop after tab navigation', (WidgetTester tester) async {
|
testWidgets('A Focused text-field will lose focus when clicking outside of its hitbox with a mouse on desktop after tab navigation', (WidgetTester tester) async {
|
||||||
final FocusNode focusNodeA = FocusNode();
|
final FocusNode focusNodeA = FocusNode(debugLabel: 'A');
|
||||||
final FocusNode focusNodeB = FocusNode();
|
final FocusNode focusNodeB = FocusNode(debugLabel: 'B');
|
||||||
final Key key = UniqueKey();
|
final Key key = UniqueKey();
|
||||||
|
|
||||||
await tester.pumpWidget(
|
await tester.pumpWidget(
|
||||||
@ -518,30 +518,33 @@ void main() {
|
|||||||
);
|
);
|
||||||
// Tab over to the 3rd text field.
|
// Tab over to the 3rd text field.
|
||||||
for (int i = 0; i < 3; i += 1) {
|
for (int i = 0; i < 3; i += 1) {
|
||||||
await tester.sendKeyDownEvent(LogicalKeyboardKey.tab);
|
await tester.sendKeyEvent(LogicalKeyboardKey.tab);
|
||||||
await tester.sendKeyUpEvent(LogicalKeyboardKey.tab);
|
await tester.pump();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> click(Finder finder) async {
|
||||||
|
final TestGesture gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(finder),
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
);
|
||||||
|
await gesture.up();
|
||||||
|
await gesture.removePointer();
|
||||||
}
|
}
|
||||||
|
|
||||||
expect(focusNodeA.hasFocus, true);
|
expect(focusNodeA.hasFocus, true);
|
||||||
expect(focusNodeB.hasFocus, false);
|
expect(focusNodeB.hasFocus, false);
|
||||||
|
|
||||||
// Click on the container to not hit either text field.
|
// Click on the container to not hit either text field.
|
||||||
final TestGesture down2 = await tester.startGesture(tester.getCenter(find.byKey(key)), kind: PointerDeviceKind.mouse);
|
await click(find.byKey(key));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await down2.up();
|
|
||||||
await down2.removePointer();
|
|
||||||
|
|
||||||
expect(focusNodeA.hasFocus, false);
|
expect(focusNodeA.hasFocus, false);
|
||||||
expect(focusNodeB.hasFocus, false);
|
expect(focusNodeB.hasFocus, false);
|
||||||
|
|
||||||
// Second text field can still gain focus.
|
// Second text field can still gain focus.
|
||||||
|
|
||||||
final TestGesture down3 = await tester.startGesture(tester.getCenter(find.byType(TextField).last), kind: PointerDeviceKind.mouse);
|
await click(find.byType(TextField).last);
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
await tester.pumpAndSettle();
|
|
||||||
await down3.up();
|
|
||||||
await down3.removePointer();
|
|
||||||
|
|
||||||
expect(focusNodeA.hasFocus, false);
|
expect(focusNodeA.hasFocus, false);
|
||||||
expect(focusNodeB.hasFocus, true);
|
expect(focusNodeB.hasFocus, true);
|
||||||
|
@ -11785,4 +11785,224 @@ void main() {
|
|||||||
expect(controller.selection.extentOffset, 5);
|
expect(controller.selection.extentOffset, 5);
|
||||||
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
|
}, variant: const TargetPlatformVariant(<TargetPlatform>{ TargetPlatform.iOS, TargetPlatform.macOS }));
|
||||||
});
|
});
|
||||||
|
group('TapRegion integration', () {
|
||||||
|
testWidgets('Tapping outside loses focus on desktop', (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Placeholder',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(focusNode.hasPrimaryFocus, isFalse);
|
||||||
|
}, variant: TargetPlatformVariant.desktop());
|
||||||
|
|
||||||
|
testWidgets("Tapping outside doesn't lose focus on mobile", (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Placeholder',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
await tester.tapAt(const Offset(10, 10));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
// Focus is lost on mobile browsers, but not mobile apps.
|
||||||
|
expect(focusNode.hasPrimaryFocus, kIsWeb ? isFalse : isTrue);
|
||||||
|
}, variant: TargetPlatformVariant.mobile());
|
||||||
|
|
||||||
|
testWidgets("Tapping on toolbar doesn't lose focus", (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
final TextEditingController controller = TextEditingController(text: 'A B C');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
decoration: const InputDecoration(hintText: 'Placeholder'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// The selectWordsInRange with SelectionChangedCause.tap seems to be needed to show the toolbar.
|
||||||
|
final EditableTextState state = tester.state<EditableTextState>(find.byType(EditableText));
|
||||||
|
state.renderEditable.selectWordsInRange(from: Offset.zero, cause: SelectionChangedCause.tap);
|
||||||
|
|
||||||
|
final Offset aPosition = textOffsetToPosition(tester, 1);
|
||||||
|
|
||||||
|
// Right clicking shows the menu.
|
||||||
|
final TestGesture gesture = await tester.startGesture(
|
||||||
|
aPosition,
|
||||||
|
kind: PointerDeviceKind.mouse,
|
||||||
|
buttons: kSecondaryMouseButton,
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
await gesture.up();
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
// Sanity check that the toolbar widget exists.
|
||||||
|
expect(find.text('Copy'), findsOneWidget);
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
// Now tap on it to see if we lose focus.
|
||||||
|
await tester.tap(find.text('Copy'));
|
||||||
|
await tester.pumpAndSettle();
|
||||||
|
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
},
|
||||||
|
variant: TargetPlatformVariant.all(),
|
||||||
|
skip: isBrowser, // [intended] On the web, the toolbar isn't rendered by Flutter.
|
||||||
|
);
|
||||||
|
|
||||||
|
testWidgets("Tapping on input decorator doesn't lose focus", (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test Node');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: 100,
|
||||||
|
height: 100,
|
||||||
|
child: Opacity(
|
||||||
|
opacity: 0.5,
|
||||||
|
child: TextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: 'Placeholder',
|
||||||
|
border: OutlineInputBorder(),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
final Rect decorationBox = tester.getRect(find.byType(TextField));
|
||||||
|
// Tap just inside the decoration, but not inside the EditableText.
|
||||||
|
await tester.tapAt(decorationBox.topLeft + const Offset(1, 1));
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
|
|
||||||
|
// PointerDownEvents can't be trackpad events, apparently, so we skip that one.
|
||||||
|
for (final PointerDeviceKind pointerDeviceKind in PointerDeviceKind.values.toSet()..remove(PointerDeviceKind.trackpad)) {
|
||||||
|
testWidgets('Default TextField handling of onTapOutside follows platform conventions for ${pointerDeviceKind.name}', (WidgetTester tester) async {
|
||||||
|
final FocusNode focusNode = FocusNode(debugLabel: 'Test');
|
||||||
|
await tester.pumpWidget(
|
||||||
|
MaterialApp(
|
||||||
|
home: Scaffold(
|
||||||
|
body: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
const Text('Outside'),
|
||||||
|
TextField(
|
||||||
|
autofocus: true,
|
||||||
|
focusNode: focusNode,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
Future<void> click(Finder finder) async {
|
||||||
|
final TestGesture gesture = await tester.startGesture(
|
||||||
|
tester.getCenter(finder),
|
||||||
|
kind: pointerDeviceKind,
|
||||||
|
);
|
||||||
|
await gesture.up();
|
||||||
|
await gesture.removePointer();
|
||||||
|
}
|
||||||
|
|
||||||
|
expect(focusNode.hasPrimaryFocus, isTrue);
|
||||||
|
|
||||||
|
await click(find.text('Outside'));
|
||||||
|
|
||||||
|
switch(pointerDeviceKind) {
|
||||||
|
case PointerDeviceKind.touch:
|
||||||
|
switch(defaultTargetPlatform) {
|
||||||
|
case TargetPlatform.iOS:
|
||||||
|
case TargetPlatform.android:
|
||||||
|
case TargetPlatform.fuchsia:
|
||||||
|
expect(focusNode.hasPrimaryFocus, equals(!kIsWeb));
|
||||||
|
break;
|
||||||
|
case TargetPlatform.linux:
|
||||||
|
case TargetPlatform.macOS:
|
||||||
|
case TargetPlatform.windows:
|
||||||
|
expect(focusNode.hasPrimaryFocus, isFalse);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
break;
|
||||||
|
case PointerDeviceKind.mouse:
|
||||||
|
case PointerDeviceKind.stylus:
|
||||||
|
case PointerDeviceKind.invertedStylus:
|
||||||
|
case PointerDeviceKind.trackpad:
|
||||||
|
case PointerDeviceKind.unknown:
|
||||||
|
expect(focusNode.hasPrimaryFocus, isFalse);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}, variant: TargetPlatformVariant.all());
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
@ -1475,6 +1475,7 @@ void main() {
|
|||||||
offset: 2,
|
offset: 2,
|
||||||
);
|
);
|
||||||
await tester.pumpWidget(buildEditableText());
|
await tester.pumpWidget(buildEditableText());
|
||||||
|
await tester.pump(); // Wait for autofocus to take effect.
|
||||||
|
|
||||||
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown));
|
await sendKeyCombination(tester, const SingleActivator(LogicalKeyboardKey.arrowDown));
|
||||||
await tester.pump();
|
await tester.pump();
|
||||||
|
@ -12595,13 +12595,22 @@ class MockTextFormatter extends TextInputFormatter {
|
|||||||
|
|
||||||
class MockTextSelectionControls extends Fake implements TextSelectionControls {
|
class MockTextSelectionControls extends Fake implements TextSelectionControls {
|
||||||
@override
|
@override
|
||||||
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) {
|
Widget buildToolbar(
|
||||||
return Container();
|
BuildContext context,
|
||||||
|
Rect globalEditableRegion,
|
||||||
|
double textLineHeight,
|
||||||
|
Offset position,
|
||||||
|
List<TextSelectionPoint> endpoints,
|
||||||
|
TextSelectionDelegate delegate,
|
||||||
|
ClipboardStatusNotifier? clipboardStatus,
|
||||||
|
Offset? lastSecondaryTapDownPosition,
|
||||||
|
) {
|
||||||
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
|
Widget buildHandle(BuildContext context, TextSelectionHandleType type, double textLineHeight, [VoidCallback? onTap]) {
|
||||||
return Container();
|
return const SizedBox();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -12671,7 +12680,16 @@ class _CustomTextSelectionControls extends TextSelectionControls {
|
|||||||
final VoidCallback? onCut;
|
final VoidCallback? onCut;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget buildToolbar(BuildContext context, Rect globalEditableRegion, double textLineHeight, Offset position, List<TextSelectionPoint> endpoints, TextSelectionDelegate delegate, ClipboardStatusNotifier? clipboardStatus, Offset? lastSecondaryTapDownPosition) {
|
Widget buildToolbar(
|
||||||
|
BuildContext context,
|
||||||
|
Rect globalEditableRegion,
|
||||||
|
double textLineHeight,
|
||||||
|
Offset position,
|
||||||
|
List<TextSelectionPoint> endpoints,
|
||||||
|
TextSelectionDelegate delegate,
|
||||||
|
ClipboardStatusNotifier? clipboardStatus,
|
||||||
|
Offset? lastSecondaryTapDownPosition,
|
||||||
|
) {
|
||||||
final Offset selectionMidpoint = position;
|
final Offset selectionMidpoint = position;
|
||||||
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
|
final TextSelectionPoint startTextSelectionPoint = endpoints[0];
|
||||||
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
|
final TextSelectionPoint endTextSelectionPoint = endpoints.length > 1
|
||||||
|
@ -5,7 +5,6 @@
|
|||||||
import 'dart:collection';
|
import 'dart:collection';
|
||||||
import 'dart:ui';
|
import 'dart:ui';
|
||||||
|
|
||||||
import 'package:flutter/foundation.dart';
|
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'package:flutter_test/flutter_test.dart';
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
@ -1976,143 +1975,6 @@ void main() {
|
|||||||
await tester.restoreFrom(restorationData);
|
await tester.restoreFrom(restorationData);
|
||||||
expect(find.byType(AlertDialog), findsOneWidget);
|
expect(find.byType(AlertDialog), findsOneWidget);
|
||||||
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615
|
}, skip: isBrowser); // https://github.com/flutter/flutter/issues/33615
|
||||||
|
|
||||||
testWidgets('FocusTrap moves focus to given focus scope when triggered', (WidgetTester tester) async {
|
|
||||||
final FocusScopeNode focusScope = FocusScopeNode();
|
|
||||||
final FocusNode focusNode = FocusNode(debugLabel: 'Test');
|
|
||||||
await tester.pumpWidget(
|
|
||||||
Directionality(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
child: FocusScope(
|
|
||||||
node: focusScope,
|
|
||||||
child: FocusTrap(
|
|
||||||
focusScopeNode: focusScope,
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
const Text('Other Widget'),
|
|
||||||
FocusTrapTestWidget('Focusable', focusNode: focusNode, onTap: () {
|
|
||||||
focusNode.requestFocus();
|
|
||||||
}),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
Future<void> click(Finder finder) async {
|
|
||||||
final TestGesture gesture = await tester.startGesture(
|
|
||||||
tester.getCenter(finder),
|
|
||||||
kind: PointerDeviceKind.mouse,
|
|
||||||
);
|
|
||||||
await gesture.up();
|
|
||||||
await gesture.removePointer();
|
|
||||||
}
|
|
||||||
|
|
||||||
expect(focusScope.hasFocus, isFalse);
|
|
||||||
expect(focusNode.hasFocus, isFalse);
|
|
||||||
|
|
||||||
await click(find.text('Focusable'));
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
expect(focusScope.hasFocus, isTrue);
|
|
||||||
expect(focusNode.hasPrimaryFocus, isTrue);
|
|
||||||
|
|
||||||
await click(find.text('Other Widget'));
|
|
||||||
// Have to wait out the double click timer.
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
switch (defaultTargetPlatform) {
|
|
||||||
case TargetPlatform.iOS:
|
|
||||||
case TargetPlatform.android:
|
|
||||||
if (kIsWeb) {
|
|
||||||
// Web is a desktop platform.
|
|
||||||
expect(focusScope.hasPrimaryFocus, isTrue);
|
|
||||||
expect(focusNode.hasFocus, isFalse);
|
|
||||||
} else {
|
|
||||||
expect(focusScope.hasFocus, isTrue);
|
|
||||||
expect(focusNode.hasPrimaryFocus, isTrue);
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case TargetPlatform.fuchsia:
|
|
||||||
case TargetPlatform.linux:
|
|
||||||
case TargetPlatform.macOS:
|
|
||||||
case TargetPlatform.windows:
|
|
||||||
expect(focusScope.hasPrimaryFocus, isTrue);
|
|
||||||
expect(focusNode.hasFocus, isFalse);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}, variant: TargetPlatformVariant.all());
|
|
||||||
|
|
||||||
testWidgets("FocusTrap doesn't unfocus if focus was set to something else before the frame ends", (WidgetTester tester) async {
|
|
||||||
final FocusScopeNode focusScope = FocusScopeNode();
|
|
||||||
final FocusNode focusNode = FocusNode(debugLabel: 'Test');
|
|
||||||
final FocusNode otherFocusNode = FocusNode(debugLabel: 'Other');
|
|
||||||
FocusNode? previousFocus;
|
|
||||||
await tester.pumpWidget(
|
|
||||||
Directionality(
|
|
||||||
textDirection: TextDirection.ltr,
|
|
||||||
child: FocusScope(
|
|
||||||
node: focusScope,
|
|
||||||
child: FocusTrap(
|
|
||||||
focusScopeNode: focusScope,
|
|
||||||
child: Column(
|
|
||||||
children: <Widget>[
|
|
||||||
FocusTrapTestWidget(
|
|
||||||
'Other Widget',
|
|
||||||
focusNode: otherFocusNode,
|
|
||||||
onTap: () {
|
|
||||||
previousFocus = FocusManager.instance.primaryFocus;
|
|
||||||
otherFocusNode.requestFocus();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
FocusTrapTestWidget(
|
|
||||||
'Focusable',
|
|
||||||
focusNode: focusNode,
|
|
||||||
onTap: () {
|
|
||||||
focusNode.requestFocus();
|
|
||||||
},
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
|
|
||||||
Future<void> click(Finder finder) async {
|
|
||||||
final TestGesture gesture = await tester.startGesture(
|
|
||||||
tester.getCenter(finder),
|
|
||||||
kind: PointerDeviceKind.mouse,
|
|
||||||
);
|
|
||||||
await gesture.up();
|
|
||||||
await gesture.removePointer();
|
|
||||||
}
|
|
||||||
|
|
||||||
await tester.pump();
|
|
||||||
expect(focusScope.hasFocus, isFalse);
|
|
||||||
expect(focusNode.hasPrimaryFocus, isFalse);
|
|
||||||
|
|
||||||
await click(find.text('Focusable'));
|
|
||||||
|
|
||||||
expect(focusScope.hasFocus, isTrue);
|
|
||||||
expect(focusNode.hasPrimaryFocus, isTrue);
|
|
||||||
|
|
||||||
await click(find.text('Other Widget'));
|
|
||||||
await tester.pump(const Duration(seconds: 1));
|
|
||||||
|
|
||||||
// The previous focus as collected by the "Other Widget" should be the
|
|
||||||
// previous focus, not be unfocused to the scope, since the primary focus
|
|
||||||
// was set by something other than the FocusTrap (the "Other Widget") during
|
|
||||||
// the frame.
|
|
||||||
expect(previousFocus, equals(focusNode));
|
|
||||||
|
|
||||||
expect(focusScope.hasFocus, isTrue);
|
|
||||||
expect(focusNode.hasPrimaryFocus, isFalse);
|
|
||||||
expect(otherFocusNode.hasPrimaryFocus, isTrue);
|
|
||||||
}, variant: TargetPlatformVariant.all());
|
|
||||||
}
|
}
|
||||||
|
|
||||||
double _getOpacity(GlobalKey key, WidgetTester tester) {
|
double _getOpacity(GlobalKey key, WidgetTester tester) {
|
||||||
@ -2327,68 +2189,3 @@ class _RestorableDialogTestWidget extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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