forked from flutter/plugins
-
Notifications
You must be signed in to change notification settings - Fork 2
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
Replace FocusTrap with TapRegionSurface (#107262)
- Loading branch information
1 parent
347de99
commit f5e4d2b
Showing
21 changed files
with
1,808 additions
and
591 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
250 changes: 250 additions & 0 deletions
250
examples/api/lib/widgets/tap_region/text_field_tap_region.0.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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), | ||
), | ||
), | ||
], | ||
), | ||
) | ||
], | ||
), | ||
); | ||
} | ||
} |
100 changes: 100 additions & 0 deletions
100
examples/api/test/widgets/tap_region/text_field_tap_region.0_test.dart
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -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; | ||
} |
Oops, something went wrong.