mirror of
https://github.com/flutter/flutter.git
synced 2025-06-03 00:51:18 +00:00
Hook up soft keyboard "next" and "previous" buttons so that they move the focus by default (#63592)
Focus will be moved automatically if onEditingComplete is not specified, but must by moved manually if onEditingComplete is specified.
This commit is contained in:
parent
7f122c7429
commit
d1eff0b413
@ -1629,18 +1629,25 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
// action; The newline is already inserted. Otherwise, finalize
|
// action; The newline is already inserted. Otherwise, finalize
|
||||||
// editing.
|
// editing.
|
||||||
if (!_isMultiline)
|
if (!_isMultiline)
|
||||||
_finalizeEditing(true);
|
_finalizeEditing(action, shouldUnfocus: true);
|
||||||
break;
|
break;
|
||||||
case TextInputAction.done:
|
case TextInputAction.done:
|
||||||
case TextInputAction.go:
|
case TextInputAction.go:
|
||||||
case TextInputAction.send:
|
case TextInputAction.next:
|
||||||
|
case TextInputAction.previous:
|
||||||
case TextInputAction.search:
|
case TextInputAction.search:
|
||||||
_finalizeEditing(true);
|
case TextInputAction.send:
|
||||||
|
_finalizeEditing(action, shouldUnfocus: true);
|
||||||
break;
|
break;
|
||||||
default:
|
case TextInputAction.continueAction:
|
||||||
|
case TextInputAction.emergencyCall:
|
||||||
|
case TextInputAction.join:
|
||||||
|
case TextInputAction.none:
|
||||||
|
case TextInputAction.route:
|
||||||
|
case TextInputAction.unspecified:
|
||||||
// Finalize editing, but don't give up focus because this keyboard
|
// Finalize editing, but don't give up focus because this keyboard
|
||||||
// action does not imply the user is done inputting information.
|
// action does not imply the user is done inputting information.
|
||||||
_finalizeEditing(false);
|
_finalizeEditing(action, shouldUnfocus: false);
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -1725,16 +1732,38 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _finalizeEditing(bool shouldUnfocus) {
|
void _finalizeEditing(TextInputAction action, {@required bool shouldUnfocus}) {
|
||||||
// Take any actions necessary now that the user has completed editing.
|
// Take any actions necessary now that the user has completed editing.
|
||||||
if (widget.onEditingComplete != null) {
|
if (widget.onEditingComplete != null) {
|
||||||
widget.onEditingComplete();
|
widget.onEditingComplete();
|
||||||
} else {
|
} else {
|
||||||
// Default behavior if the developer did not provide an
|
// Default behavior if the developer did not provide an
|
||||||
// onEditingComplete callback: Finalize editing and remove focus.
|
// onEditingComplete callback: Finalize editing and remove focus, or move
|
||||||
|
// it to the next/previous field, depending on the action.
|
||||||
widget.controller.clearComposing();
|
widget.controller.clearComposing();
|
||||||
if (shouldUnfocus)
|
if (shouldUnfocus) {
|
||||||
widget.focusNode.unfocus();
|
switch (action) {
|
||||||
|
case TextInputAction.none:
|
||||||
|
case TextInputAction.unspecified:
|
||||||
|
case TextInputAction.done:
|
||||||
|
case TextInputAction.go:
|
||||||
|
case TextInputAction.search:
|
||||||
|
case TextInputAction.send:
|
||||||
|
case TextInputAction.continueAction:
|
||||||
|
case TextInputAction.join:
|
||||||
|
case TextInputAction.route:
|
||||||
|
case TextInputAction.emergencyCall:
|
||||||
|
case TextInputAction.newline:
|
||||||
|
widget.focusNode.unfocus();
|
||||||
|
break;
|
||||||
|
case TextInputAction.next:
|
||||||
|
widget.focusNode.nextFocus();
|
||||||
|
break;
|
||||||
|
case TextInputAction.previous:
|
||||||
|
widget.focusNode.previousFocus();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Invoke optional callback with the user's submitted content.
|
// Invoke optional callback with the user's submitted content.
|
||||||
@ -1883,7 +1912,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
|
|||||||
_textInputConnection = null;
|
_textInputConnection = null;
|
||||||
_lastFormattedUnmodifiedTextEditingValue = null;
|
_lastFormattedUnmodifiedTextEditingValue = null;
|
||||||
_receivedRemoteTextEditingValue = null;
|
_receivedRemoteTextEditingValue = null;
|
||||||
_finalizeEditing(true);
|
_finalizeEditing(TextInputAction.done, shouldUnfocus: true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1336,35 +1336,88 @@ void main() {
|
|||||||
expect(changedValue, clipboardContent);
|
expect(changedValue, clipboardContent);
|
||||||
});
|
});
|
||||||
|
|
||||||
testWidgets('Does not lose focus by default when "next" action is pressed', (WidgetTester tester) async {
|
// The variants to test in the focus handling test.
|
||||||
final FocusNode focusNode = FocusNode();
|
final ValueVariant<TextInputAction> focusVariants = ValueVariant<
|
||||||
|
TextInputAction>(
|
||||||
|
TextInputAction.values.toSet(),
|
||||||
|
);
|
||||||
|
|
||||||
final Widget widget = MaterialApp(
|
testWidgets('Handles focus correctly when action is invoked', (WidgetTester tester) async {
|
||||||
home: EditableText(
|
// The expectations for each of the types of TextInputAction.
|
||||||
backgroundCursorColor: Colors.grey,
|
const Map<TextInputAction, bool> actionShouldLoseFocus = <TextInputAction, bool>{
|
||||||
controller: TextEditingController(),
|
TextInputAction.none: false,
|
||||||
focusNode: focusNode,
|
TextInputAction.unspecified: false,
|
||||||
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1,
|
TextInputAction.done: true,
|
||||||
cursorColor: Colors.blue,
|
TextInputAction.go: true,
|
||||||
selectionControls: materialTextSelectionControls,
|
TextInputAction.search: true,
|
||||||
keyboardType: TextInputType.text,
|
TextInputAction.send: true,
|
||||||
),
|
TextInputAction.continueAction: false,
|
||||||
);
|
TextInputAction.join: false,
|
||||||
await tester.pumpWidget(widget);
|
TextInputAction.route: false,
|
||||||
|
TextInputAction.emergencyCall: false,
|
||||||
|
TextInputAction.newline: true,
|
||||||
|
TextInputAction.next: true,
|
||||||
|
TextInputAction.previous: true,
|
||||||
|
};
|
||||||
|
|
||||||
// Select EditableText to give it focus.
|
final TextInputAction action = focusVariants.currentValue;
|
||||||
final Finder textFinder = find.byType(EditableText);
|
expect(actionShouldLoseFocus.containsKey(action), isTrue);
|
||||||
await tester.tap(textFinder);
|
|
||||||
await tester.pump();
|
|
||||||
|
|
||||||
assert(focusNode.hasFocus);
|
Future<void> _ensureCorrectFocusHandlingForAction(
|
||||||
|
TextInputAction action, {
|
||||||
|
@required bool shouldLoseFocus,
|
||||||
|
bool shouldFocusNext = false,
|
||||||
|
bool shouldFocusPrevious = false,
|
||||||
|
}) async {
|
||||||
|
final FocusNode focusNode = FocusNode();
|
||||||
|
final GlobalKey previousKey = GlobalKey();
|
||||||
|
final GlobalKey nextKey = GlobalKey();
|
||||||
|
|
||||||
await tester.testTextInput.receiveAction(TextInputAction.next);
|
final Widget widget = MaterialApp(
|
||||||
await tester.pump();
|
home: Column(
|
||||||
|
children: <Widget>[
|
||||||
|
TextButton(
|
||||||
|
child: Text('Previous Widget', key: previousKey),
|
||||||
|
onPressed: () {}),
|
||||||
|
EditableText(
|
||||||
|
backgroundCursorColor: Colors.grey,
|
||||||
|
controller: TextEditingController(),
|
||||||
|
focusNode: focusNode,
|
||||||
|
style: Typography.material2018(platform: TargetPlatform.android).black.subtitle1,
|
||||||
|
cursorColor: Colors.blue,
|
||||||
|
selectionControls: materialTextSelectionControls,
|
||||||
|
keyboardType: TextInputType.text,
|
||||||
|
autofocus: true,
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
child: Text('Next Widget', key: nextKey), onPressed: () {}),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await tester.pumpWidget(widget);
|
||||||
|
|
||||||
// Still has focus after pressing "next".
|
assert(focusNode.hasFocus);
|
||||||
expect(focusNode.hasFocus, true);
|
|
||||||
});
|
await tester.testTextInput.receiveAction(action);
|
||||||
|
await tester.pump();
|
||||||
|
|
||||||
|
expect(Focus.of(nextKey.currentContext).hasFocus, equals(shouldFocusNext));
|
||||||
|
expect(Focus.of(previousKey.currentContext).hasFocus, equals(shouldFocusPrevious));
|
||||||
|
expect(focusNode.hasFocus, equals(!shouldLoseFocus));
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await _ensureCorrectFocusHandlingForAction(
|
||||||
|
action,
|
||||||
|
shouldLoseFocus: actionShouldLoseFocus[action],
|
||||||
|
shouldFocusNext: action == TextInputAction.next,
|
||||||
|
shouldFocusPrevious: action == TextInputAction.previous,
|
||||||
|
);
|
||||||
|
} on PlatformException {
|
||||||
|
// on Android, continueAction isn't supported.
|
||||||
|
expect(action, equals(TextInputAction.continueAction));
|
||||||
|
}
|
||||||
|
}, variant: focusVariants);
|
||||||
|
|
||||||
testWidgets('Does not lose focus by default when "done" action is pressed and onEditingComplete is provided', (WidgetTester tester) async {
|
testWidgets('Does not lose focus by default when "done" action is pressed and onEditingComplete is provided', (WidgetTester tester) async {
|
||||||
final FocusNode focusNode = FocusNode();
|
final FocusNode focusNode = FocusNode();
|
||||||
|
@ -271,6 +271,61 @@ class TargetPlatformVariant extends TestVariant<TargetPlatform> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// A [TestVariant] that runs separate tests with each of the given values.
|
||||||
|
///
|
||||||
|
/// To use this variant, define it before the test, and then access
|
||||||
|
/// [currentValue] inside the test.
|
||||||
|
///
|
||||||
|
/// The values are typically enums, but they don't have to be. The `toString`
|
||||||
|
/// for the given value will be used to describe the variant. Values will have
|
||||||
|
/// their type name stripped from their `toString` output, so that enum values
|
||||||
|
/// will only print the value, not the type.
|
||||||
|
///
|
||||||
|
/// {@tool snippet}
|
||||||
|
/// This example shows how to set up the test to access the [currentValue]. In
|
||||||
|
/// this example, two tests will be run, one with `value1`, and one with
|
||||||
|
/// `value2`. The test with `value2` will fail. The names of the tests will be:
|
||||||
|
///
|
||||||
|
/// - `Test handling of TestScenario (value1)`
|
||||||
|
/// - `Test handling of TestScenario (value2)`
|
||||||
|
///
|
||||||
|
/// ```dart
|
||||||
|
/// enum TestScenario {
|
||||||
|
/// value1,
|
||||||
|
/// value2,
|
||||||
|
/// value3,
|
||||||
|
/// }
|
||||||
|
///
|
||||||
|
/// final ValueVariant<TestScenario> variants = ValueVariant<TestScenario>(
|
||||||
|
/// <TestScenario>{value1, value2},
|
||||||
|
/// );
|
||||||
|
///
|
||||||
|
/// testWidgets('Test handling of TestScenario', (WidgetTester tester) {
|
||||||
|
/// expect(variants.currentValue, equals(value1));
|
||||||
|
/// }, variant: variants);
|
||||||
|
/// ```
|
||||||
|
/// {@end-tool}
|
||||||
|
class ValueVariant<T> extends TestVariant<T> {
|
||||||
|
/// Creates a [ValueVariant] that tests the given [values].
|
||||||
|
ValueVariant(this.values);
|
||||||
|
|
||||||
|
/// Returns the value currently under test.
|
||||||
|
T get currentValue => _currentValue;
|
||||||
|
T _currentValue;
|
||||||
|
|
||||||
|
@override
|
||||||
|
final Set<T> values;
|
||||||
|
|
||||||
|
@override
|
||||||
|
String describeValue(T value) => value.toString().replaceFirst('$T.', '');
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<T> setUp(T value) async => _currentValue = value;
|
||||||
|
|
||||||
|
@override
|
||||||
|
Future<void> tearDown(T value, T memento) async {}
|
||||||
|
}
|
||||||
|
|
||||||
/// The warning message to show when a benchmark is performed with assert on.
|
/// The warning message to show when a benchmark is performed with assert on.
|
||||||
const String kDebugWarning = '''
|
const String kDebugWarning = '''
|
||||||
┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓
|
┏╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍╍┓
|
||||||
|
Loading…
Reference in New Issue
Block a user