onTapOutside property
Called for each tap down that occurs outside of the TextFieldTapRegion group when the text field is focused.
If this is null, EditableTextTapOutsideIntent will be invoked. In the default implementation, 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 or EditableTextTapOutsideIntent may be overridden.
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.
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.
To see it in action, copy and run this code snippet on DartPad.
import 'package:flutter/services.dart';
import 'package:material_ui/material_ui.dart';
/// Flutter code sample for [TextFieldTapRegion].
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 .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: .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: .center,
children: <Widget>[
Expanded(
child: OutlinedButton(
onPressed: _increment,
child: const Icon(Icons.add),
),
),
Expanded(
child: OutlinedButton(
onPressed: _decrement,
child: const Icon(Icons.remove),
),
),
],
),
),
],
),
);
}
}
See also:
- TapRegion for how the region group is determined.
Implementation
// TODO(framework): Replace the following block with a @dartpad directive
// when it's supported. https://github.com/dart-lang/dartdoc/issues/4123
/// {@macro material_ui.dartpad_guide}
///
/// {@example /example/lib/text_field/text_field_tap_region.0.dart#body}
///
/// </callout-box>
///
/// See also:
///
/// * [TapRegion] for how the region group is determined.
final TapRegionCallback? onTapOutside;