Unverified Commit 40900782 authored by Michael Goderbauer's avatar Michael Goderbauer Committed by GitHub

Keep TextFields visible when keyboard comes up (#18291)

Fixes #10826.

Also in this PR: If you focus a text field, scroll it out of view and then start typing it will scroll back into view.
parent e713b334
......@@ -25,6 +25,7 @@ Future<TaskResult> runEndToEndTests() async {
'lib/keyboard_resize.dart',
'lib/driver.dart',
'lib/screenshot.dart',
'lib/keyboard_textfield.dart',
];
for (final String entryPoint in entryPoints) {
......
// Copyright 2018 The Chromium 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_driver/driver_extension.dart';
import 'keys.dart' as keys;
void main() {
enableFlutterDriverExtension();
runApp(new MyApp());
}
class MyApp extends StatelessWidget {
@override
Widget build(BuildContext context) {
return new MaterialApp(
title: 'Keyboard & TextField',
theme: new ThemeData(primarySwatch: Colors.blue),
home: new MyHomePage(),
);
}
}
class MyHomePage extends StatefulWidget {
@override
_MyHomePageState createState() => new _MyHomePageState();
}
class _MyHomePageState extends State<MyHomePage> {
final ScrollController _controller = new ScrollController();
double offset = 0.0;
@override
void initState() {
super.initState();
_controller.addListener(() {
setState(() {
offset = _controller.offset;
});
});
}
@override
Widget build(BuildContext context) {
return new Scaffold(
body: new Column(
children: <Widget>[
new Text('$offset',
key: const ValueKey<String>(keys.kOffsetText),
),
new Expanded(
child: new ListView(
key: const ValueKey<String>(keys.kListView),
controller: _controller,
children: <Widget>[
new Container(
height: MediaQuery.of(context).size.height,
),
const TextField(
key: const ValueKey<String>(keys.kDefaultTextField),
),
],
),
),
],
),
);
}
}
......@@ -5,3 +5,5 @@
const String kDefaultTextField = 'default_textfield';
const String kHeightText = 'height_text';
const String kUnfocusButton = 'unfocus_button';
const String kOffsetText = 'offset_text';
const String kListView = 'list_view';
// Copyright 2018 The Chromium 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:async';
import 'package:integration_ui/keys.dart' as keys;
import 'package:flutter_driver/flutter_driver.dart';
import 'package:test/test.dart';
void main() {
group('end-to-end test', () {
FlutterDriver driver;
setUpAll(() async {
driver = await FlutterDriver.connect();
});
tearDownAll(() async {
driver?.close();
});
test('Textfield scrolls back into view after covered by keyboard', () async {
await driver.setTextEntryEmulation(enabled: false); // we want the keyboard to come up
final SerializableFinder listViewFinder = find.byValueKey(keys.kListView);
final SerializableFinder textFieldFinder = find.byValueKey(keys.kDefaultTextField);
final SerializableFinder offsetFinder = find.byValueKey(keys.kOffsetText);
// Align TextField with bottom edge to ensure it would be covered when keyboard comes up.
await driver.waitForAbsent(textFieldFinder);
await driver.scrollUntilVisible(
listViewFinder,
textFieldFinder,
alignment: 1.0,
dyScroll: -20.0,
);
await driver.waitFor(textFieldFinder);
final double scrollOffsetWithoutKeyboard = double.parse(await driver.getText(offsetFinder));
// Bring up keyboard
await driver.tap(textFieldFinder);
await new Future<Null>.delayed(const Duration(seconds: 1));
// Ensure that TextField is visible again
await driver.waitFor(textFieldFinder);
final double scrollOffsetWithKeyboard = double.parse(await driver.getText(offsetFinder));
// Ensure the scroll offset changed appropriately when TextField scrolled back into view.
expect(scrollOffsetWithKeyboard, greaterThan(scrollOffsetWithoutKeyboard));
});
});
}
......@@ -20,6 +20,7 @@ void main() {
expect(passwordField, findsOneWidget);
await tester.enterText(nameField, '');
await tester.pumpAndSettle();
// The submit button isn't initially visible. Drag it into view so that
// it will see the tap.
await tester.drag(nameField, const Offset(0.0, -1200.0));
......@@ -34,6 +35,7 @@ void main() {
expect(find.text('Name is required.'), findsOneWidget);
expect(find.text('Please enter only alphabetical characters.'), findsNothing);
await tester.enterText(nameField, '#');
await tester.pumpAndSettle();
// Make the submit button visible again (by dragging the name field), so
// it will see the tap.
......
......@@ -3,12 +3,15 @@
// found in the LICENSE file.
import 'dart:async';
import 'dart:ui' as ui;
import 'package:flutter/rendering.dart';
import 'package:flutter/scheduler.dart';
import 'package:flutter/services.dart';
import 'automatic_keep_alive.dart';
import 'basic.dart';
import 'binding.dart';
import 'focus_manager.dart';
import 'focus_scope.dart';
import 'framework.dart';
......@@ -319,7 +322,7 @@ class EditableText extends StatefulWidget {
}
/// State for a [EditableText].
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin implements TextInputClient, TextSelectionDelegate {
class EditableTextState extends State<EditableText> with AutomaticKeepAliveClientMixin, WidgetsBindingObserver implements TextInputClient, TextSelectionDelegate {
Timer _cursorTimer;
final ValueNotifier<bool> _showCursor = new ValueNotifier<bool>(false);
final GlobalKey _editableKey = new GlobalKey();
......@@ -389,6 +392,7 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
void updateEditingValue(TextEditingValue value) {
if (value.text != _value.text) {
_hideSelectionOverlayIfNeeded();
_showCaretOnScreen();
if (widget.obscureText && value.text.length == _value.text.length + 1) {
_obscureShowCharTicksPending = _kObscureShowLatestCharCursorTicks;
_obscureLatestCharIndex = _value.selection.baseOffset;
......@@ -444,6 +448,12 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
return scrollOffset;
}
// Calculates where the `caretRect` would be if `_scrollController.offset` is set to `scrollOffset`.
Rect _getCaretRectAtScrollOffset(Rect caretRect, double scrollOffset) {
final double offsetDiff = _scrollController.offset - scrollOffset;
return _isMultiline ? caretRect.translate(0.0, offsetDiff) : caretRect.translate(offsetDiff, 0.0);
}
bool get _hasInputConnection => _textInputConnection != null && _textInputConnection.attached;
void _openInputConnection() {
......@@ -541,22 +551,60 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
}
bool _textChangedSinceLastCaretUpdate = false;
Rect _currentCaretRect;
void _handleCaretChanged(Rect caretRect) {
_currentCaretRect = caretRect;
// If the caret location has changed due to an update to the text or
// selection, then scroll the caret into view.
if (_textChangedSinceLastCaretUpdate) {
_textChangedSinceLastCaretUpdate = false;
scheduleMicrotask(() {
_scrollController.animateTo(
_getScrollOffsetForCaret(caretRect),
curve: Curves.fastOutSlowIn,
duration: const Duration(milliseconds: 50),
);
});
_showCaretOnScreen();
}
}
// Animation configuration for scrolling the caret back on screen.
static const Duration _caretAnimationDuration = const Duration(milliseconds: 100);
static const Curve _caretAnimationCurve = Curves.fastOutSlowIn;
bool _showCaretOnScreenScheduled = false;
void _showCaretOnScreen() {
if (_showCaretOnScreenScheduled) {
return;
}
_showCaretOnScreenScheduled = true;
SchedulerBinding.instance.addPostFrameCallback((Duration _) {
_showCaretOnScreenScheduled = false;
if (_currentCaretRect == null || !_scrollController.hasClients){
return;
}
final double scrollOffsetForCaret = _getScrollOffsetForCaret(_currentCaretRect);
_scrollController.animateTo(
scrollOffsetForCaret,
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
final Rect newCaretRect = _getCaretRectAtScrollOffset(_currentCaretRect, scrollOffsetForCaret);
_editableKey.currentContext.findRenderObject().showOnScreen(
// Inflate ensures that caret is not positioned directly at the edge.
rect: newCaretRect.inflate(20.0),
duration: _caretAnimationDuration,
curve: _caretAnimationCurve,
);
});
}
double _lastBottomViewInset;
@override
void didChangeMetrics() {
if (_lastBottomViewInset < ui.window.viewInsets.bottom) {
_showCaretOnScreen();
}
_lastBottomViewInset = ui.window.viewInsets.bottom;
}
void _formatAndSetValue(TextEditingValue value) {
final bool textChanged = _value?.text != value?.text;
if (widget.inputFormatters != null && widget.inputFormatters.isNotEmpty) {
......@@ -629,12 +677,19 @@ class EditableTextState extends State<EditableText> with AutomaticKeepAliveClien
_openOrCloseInputConnectionIfNeeded();
_startOrStopCursorTimerIfNeeded();
_updateOrDisposeSelectionOverlayIfNeeded();
if (!_hasFocus) {
if (_hasFocus) {
// Listen for changing viewInsets, which indicates keyboard showing up.
WidgetsBinding.instance.addObserver(this);
_lastBottomViewInset = ui.window.viewInsets.bottom;
_showCaretOnScreen();
if (!_value.selection.isValid) {
// Place cursor at the end if the selection is invalid when we receive focus.
widget.controller.selection = new TextSelection.collapsed(offset: _value.text.length);
}
} else {
WidgetsBinding.instance.removeObserver(this);
// Clear the selection and composition state if this widget lost focus.
_value = new TextEditingValue(text: _value.text);
} else if (!_value.selection.isValid) {
// Place cursor at the end if the selection is invalid when we receive focus.
widget.controller.selection = new TextSelection.collapsed(offset: _value.text.length);
}
updateKeepAlive();
}
......
// Copyright 2018 The Chromium 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/rendering.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:flutter/material.dart';
import 'package:flutter/widgets.dart';
import 'package:flutter/services.dart';
void main() {
const TextStyle textStyle = const TextStyle();
const Color cursorColor = const Color.fromARGB(0xFF, 0xFF, 0x00, 0x00);
testWidgets('tapping on a partly visible editable brings it fully on screen', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
final TextEditingController controller = new TextEditingController();
final FocusNode focusNode = new FocusNode();
await tester.pumpWidget(new MaterialApp(
home: new Center(
child: new Container(
height: 300.0,
child: new ListView(
controller: scrollController,
children: <Widget>[
new EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
new Container(
height: 350.0,
),
],
),
),
),
));
// Scroll the EditableText half off screen.
final RenderBox render = tester.renderObject(find.byType(EditableText));
scrollController.jumpTo(render.size.height / 2);
await tester.pumpAndSettle();
expect(scrollController.offset, render.size.height / 2);
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
});
testWidgets('editable comes back on screen when entering text while it is off-screen', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController(initialScrollOffset: 100.0);
final TextEditingController controller = new TextEditingController();
final FocusNode focusNode = new FocusNode();
await tester.pumpWidget(new MaterialApp(
home: new Center(
child: new Container(
height: 300.0,
child: new ListView(
controller: scrollController,
children: <Widget>[
new Container(
height: 350.0,
),
new EditableText(
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
new Container(
height: 350.0,
),
],
),
),
),
));
// Focus the EditableText and scroll it off screen.
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
expect(focusNode.hasFocus, isTrue);
scrollController.jumpTo(0.0);
await tester.pumpAndSettle();
expect(scrollController.offset, 0.0);
expect(find.byType(EditableText), findsNothing);
// Entering text brings it back on screen.
tester.testTextInput.enterText('Hello');
await tester.pumpAndSettle();
expect(scrollController.offset, greaterThan(0.0));
expect(find.byType(EditableText), findsOneWidget);
});
testWidgets('focused multi-line editable scrolls caret back into view when typing', (WidgetTester tester) async {
final ScrollController scrollController = new ScrollController();
final TextEditingController controller = new TextEditingController();
final FocusNode focusNode = new FocusNode();
controller.text = 'Start\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\n\nEnd';
await tester.pumpWidget(new MaterialApp(
home: new Center(
child: new Container(
height: 300.0,
child: new ListView(
controller: scrollController,
children: <Widget>[
new EditableText(
maxLines: null, // multi-line
controller: controller,
focusNode: focusNode,
style: textStyle,
cursorColor: cursorColor,
),
],
),
),
),
));
// Bring keyboard up and verify that end of EditableText is not on screen.
await tester.showKeyboard(find.byType(EditableText));
await tester.pumpAndSettle();
scrollController.jumpTo(0.0);
await tester.pumpAndSettle();
final RenderBox render = tester.renderObject(find.byType(EditableText));
expect(render.size.height, greaterThan(500.0));
expect(scrollController.offset, 0.0);
// Enter text at end, which is off-screen.
final String textToEnter = '${controller.text} HELLO';
tester.testTextInput.updateEditingValue(new TextEditingValue(
text: textToEnter,
selection: new TextSelection.collapsed(offset: textToEnter.length),
));
await tester.pumpAndSettle();
// Caret scrolls into view.
expect(find.byType(EditableText), findsOneWidget);
expect(render.size.height, greaterThan(500.0));
expect(scrollController.offset, greaterThan(0.0));
});
}
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment