如何确保TextField或TextFormField在视口中具有焦点而不被键盘覆盖时可见?
难度:中级
(最后更新于2018年8月29日,根据应用于Flutter框架v.0.5.7的更改修复_ensureVisible方法中的错误)
像许多Flutter开发人员一样,我在处理包含TextField或TextFormField的表单时最近遇到了这个问题。当这些字段获得焦点时,键盘会显示并可能覆盖它们。
浏览互联网,我在GitHub上找到了一个源代码,由Collin Jackson(链接)提供。这段代码部分地解决了问题,但并不完全:如果用户解除键盘然后单击相同的TextField或TextFormField,则解决方案不起作用。
本文补充了该解决方案,并确保在显示键盘时(即使在被解除之后)这些输入字段始终在视口中可见。
请注意,此解决方案仅在TextField位于Scrollable区域时有效。
该解决方案依赖于以下2个概念的使用:
FocusNode
使用FocusNode类是为了在Widget获得或失去焦点时得到通知。
如何使用FocusNode?
下面的代码说明了一个带有2个TextFormField的Form的非常基本的实现,我们希望在第一个输入框获得和失去焦点时得到通知。
class TestPage extends StatefulWidget {
@override
_TestPageState createState() => new _TestPageState();
}
class _TestPageState extends State<TestPage> {
FocusNode _focusNode = new FocusNode();
@override
void initState(){
super.initState();
_focusNode.addListener(_focusNodeListener);
}
@override
void dispose(){
_focusNode.removeListener(_focusNodeListener);
super.dispose();
}
Future<Null> _focusNodeListener() async {
if (_focusNode.hasFocus){
print('TextField got the focus');
} else {
print('TextField lost the focus');
}
}
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('My Test Page'),
),
body: new SafeArea(
top: false,
bottom: false,
child: new Form(
child: new Column(
children: <Widget> [
new TextFormField(
focusNode: _focusNode,
),
new TextFormField(
...
),
],
),
),
),
);
}
}
说明:
- 第7行:实例化FocusNode小部件。
- 第12行:当字段接收/失去焦点时,我们初始化要调用的侦听器。
- 第17行:非常重要的是我们需要在页面被关闭时删除监听器。
- 第21-27行:将侦听器实现为异步功能。
- 第42行:我们将TextFormField绑定到侦听器。
WidgetsBindingObserver
WidgetsBindingObserver暴露了一些overridable functions,这些函数在Application, Screen, Memory, Route and Locale触发事件时会被调用。
有关更多详细信息,请参阅文档。
在本文所涉及的情况下,我们只对屏幕指标(metrics)更改时的通知感兴趣(键盘打开或关闭时就是这种情况)。
要使用此Observer,我们需要实现一个mixins。 (即使该页面已过时,阅读以理解该概念仍然很有趣)。
特别是,我们将实施如下:
class _TestPageState extends State<TestPage> with WidgetsBindingObserver {
@override
void initState(){
super.initState();
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose(){
WidgetsBinding.instance.removeObserver(this);
super.dispose();
}
///
/// This routine is invoked when the window metrics have changed.
///
@override
void didChangeMetrics(){
...
}
}
解决方案
解决方案包括将TextField或TextFormField的可见性控制委托给专用的Helper Widget,并使用此帮助程序窗口小部件包装 TextField或TextFormField。
Helper Widget
辅助Widget(EnsureVisibleWhenFocused)实现了本文前面解释的2个通知。这是完整的源代码:
import 'dart:async';
import 'package:flutter/material.dart';
import 'package:flutter/rendering.dart';
import 'package:meta/meta.dart';
///
/// Helper class that ensures a Widget is visible when it has the focus
/// For example, for a TextFormField when the keyboard is displayed
///
/// How to use it:
///
/// In the class that implements the Form,
/// Instantiate a FocusNode
/// FocusNode _focusNode = new FocusNode();
///
/// In the build(BuildContext context), wrap the TextFormField as follows:
///
/// new EnsureVisibleWhenFocused(
/// focusNode: _focusNode,
/// child: new TextFormField(
/// ...
/// focusNode: _focusNode,
/// ),
/// ),
///
/// Initial source code written by Collin Jackson.
/// Extended (see highlighting) to cover the case when the keyboard is dismissed and the
/// user clicks the TextFormField/TextField which still has the focus.
///
class EnsureVisibleWhenFocused extends StatefulWidget {
const EnsureVisibleWhenFocused({
Key key,
@required this.child,
@required this.focusNode,
this.curve: Curves.ease,
this.duration: const Duration(milliseconds: 100),
}) : super(key: key);
/// The node we will monitor to determine if the child is focused
final FocusNode focusNode;
/// The child widget that we are wrapping
final Widget child;
/// The curve we will use to scroll ourselves into view.
///
/// Defaults to Curves.ease.
final Curve curve;
/// The duration we will use to scroll ourselves into view
///
/// Defaults to 100 milliseconds.
final Duration duration;
@override
_EnsureVisibleWhenFocusedState createState() => new _EnsureVisibleWhenFocusedState();
}
///
/// We implement the WidgetsBindingObserver to be notified of any change to the window metrics
///
class _EnsureVisibleWhenFocusedState extends State<EnsureVisibleWhenFocused> with WidgetsBindingObserver {
@override
void initState(){
super.initState();
widget.focusNode.addListener(_ensureVisible);
WidgetsBinding.instance.addObserver(this);
}
@override
void dispose(){
WidgetsBinding.instance.removeObserver(this);
widget.focusNode.removeListener(_ensureVisible);
super.dispose();
}
///
/// This routine is invoked when the window metrics have changed.
/// This happens when the keyboard is open or dismissed, among others.
/// It is the opportunity to check if the field has the focus
/// and to ensure it is fully visible in the viewport when
/// the keyboard is displayed
///
@override
void didChangeMetrics(){
if (widget.focusNode.hasFocus){
_ensureVisible();
}
}
///
/// This routine waits for the keyboard to come into view.
/// In order to prevent some issues if the Widget is dismissed in the
/// middle of the loop, we need to check the "mounted" property
///
/// This method was suggested by Peter Yuen (see discussion).
///
Future<Null> _keyboardToggled() async {
if (mounted){
EdgeInsets edgeInsets = MediaQuery.of(context).viewInsets;
while (mounted && MediaQuery.of(context).viewInsets == edgeInsets) {
await new Future.delayed(const Duration(milliseconds: 10));
}
}
return;
}
Future<Null> _ensureVisible() async {
// Wait for the keyboard to come into view
await Future.any([new Future.delayed(const Duration(milliseconds: 300)), _keyboardToggled()]);
// No need to go any further if the node has not the focus
if (!widget.focusNode.hasFocus){
return;
}
// Find the object which has the focus
final RenderObject object = context.findRenderObject();
final RenderAbstractViewport viewport = RenderAbstractViewport.of(object);
// If we are not working in a Scrollable, skip this routine
if (viewport == null) {
return;
}
// Get the Scrollable state (in order to retrieve its offset)
ScrollableState scrollableState = Scrollable.of(context);
assert(scrollableState != null);
// Get its offset
ScrollPosition position = scrollableState.position;
double alignment;
if (position.pixels > viewport.getOffsetToReveal(object, 0.0).offset) {
// Move down to the top of the viewport
alignment = 0.0;
} else if (position.pixels < viewport.getOffsetToReveal(object, 1.0).offset){
// Move up to the bottom of the viewport
alignment = 1.0;
} else {
// No scrolling is necessary to reveal the child
return;
}
position.ensureVisible(
object,
alignment: alignment,
duration: widget.duration,
curve: widget.curve,
);
}
@override
Widget build(BuildContext context) {
return widget.child;
}
}
示例代码
以下代码说明了解决方案的实现。
class TestPage extends StatefulWidget {
@override
_TestPageState createState() => new _TestPageState();
}
class _TestPageState extends State<TestPage> {
final GlobalKey<FormState> _formKey = new GlobalKey<FormState>();
FocusNode _focusNodeFirstName = new FocusNode();
FocusNode _focusNodeLastName = new FocusNode();
FocusNode _focusNodeDescription = new FocusNode();
static final TextEditingController _firstNameController = new TextEditingController();
static final TextEditingController _lastNameController = new TextEditingController();
static final TextEditingController _descriptionController = new TextEditingController();
@override
Widget build(BuildContext context) {
return new Scaffold(
appBar: new AppBar(
title: new Text('My Test Page'),
),
body: new SafeArea(
top: false,
bottom: false,
child: new Form(
key: _formKey,
child: new SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 16.0),
child: new Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: <Widget>[
/* -- Something large -- */
Container(
width: double.infinity,
height: 150.0,
color: Colors.red,
),
/* -- First Name -- */
new EnsureVisibleWhenFocused(
focusNode: _focusNodeFirstName,
child: new TextFormField(
decoration: const InputDecoration(
border: const UnderlineInputBorder(),
filled: true,
icon: const Icon(Icons.person),
hintText: 'Enter your first name',
labelText: 'First name *',
),
onSaved: (String value) {
//TODO
},
controller: _firstNameController,
focusNode: _focusNodeFirstName,
),
),
const SizedBox(height: 24.0),
/* -- Last Name -- */
new EnsureVisibleWhenFocused(
focusNode: _focusNodeLastName,
child: new TextFormField(
decoration: const InputDecoration(
border: const UnderlineInputBorder(),
filled: true,
icon: const Icon(Icons.person),
hintText: 'Enter your last name',
labelText: 'Last name *',
),
onSaved: (String value) {
//TODO
},
controller: _lastNameController,
focusNode: _focusNodeLastName,
),
),
const SizedBox(height: 24.0),
/* -- Some other fields -- */
new Container(
width: double.infinity,
height: 250.0,
color: Colors.blue,
),
/* -- Description -- */
new EnsureVisibleWhenFocused(
focusNode: _focusNodeDescription,
child: new TextFormField(
decoration: const InputDecoration(
border: const OutlineInputBorder(),
hintText: 'Tell us about yourself',
labelText: 'Describe yourself',
),
onSaved: (String value) {
//TODO
},
maxLines: 5,
controller: _descriptionController,
focusNode: _focusNodeDescription,
),
),
const SizedBox(height: 24.0),
/* -- Save Button -- */
new Center(
child: new RaisedButton(
child: const Text('Save'),
onPressed: () {
//TODO
},
),
),
const SizedBox(height: 24.0),
],
),
),
),
),
);
}
}
结论
此解决方案适用于我,我想与您分享。
翻译不易,大家且看且珍惜
原文
网友评论