How to make sure a TextField or TextFormField is visible in the viewport when it has the focus without being covered by the keyboard?

Difficulty: Intermediate

(Last updated on Aug 29, 2018 to fix a bug in the _ensureVisible method, based on changes applied to the Flutter framework v.0.5.7)

Like many Flutter developers, I lately faced this issue when dealing with a form that includes TextField or TextFormField. When these fields get the focus, the keyboard is displayed and may cover them.

Browsing the Internet, I found a source code on GitHub, given by Collin Jackson (link). This piece of code partially solves the issue but not entirely: if the user dismisses the keyboard and then clicks on the same TextField or TextFormField, the solution does not work.

This article complements that solution and ensures these input fields are always visible in the viewport when the keyboard is displayed (even after having being dismissed).

Please note that this solution only works when the TextFields are located in a Scrollable area.

The solution relies on the use of the following 2 notions:

FocusNode

The FocusNode class is to be used in order to be notified when a Widget gets or looses the focus.

How to use FocusNode?

The following piece of code illustrates a very basic implementation of a Form with 2 TextFormField and we want to be notified when the first field gets and looses the focus.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
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(
                                ...
                            ),
                        ],
                    ),
                ),
            ),
        );
    }
}

Explanation:

  • Line #7: instantiates a FocusNode Widget.
  • Line #12: we initialize a listener to be invoked when the field receives/looses the focus.
  • Line #17: very important we need to remove the listener when the page is dismissed.
  • Lines #21-27: implementation of the listener as an async function.
  • Line #42: we bind the TextFormField to the listener.

WidgetsBindingObserver

The WidgetsBindingObserver exposes overridable functions that are invoked when an event is triggered at the Application, Screen, Memory, Route and Locale levels.

For more details, please refer to the documentation.

In the case covered by this article, we are only interested in being notified when the Screen metrics change (which is the case when the keyboard opens or closes).

To use this Observer, we need to implement a mixins. (Even if that page is obsolete, it is still interesting to read to understand the concept).

In particular, we will implement as follows:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
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(){
        ...
    }
}

The Solution

The solution consists in delegating the control of the visibility of the TextField or TextFormField to a dedicated Helper Widget and wrapping the TextField or TextFormField with this helper widget.

Helper Widget

The helper widget (EnsureVisibleWhenFocused) implements the 2 notions, explained earlier in this article. Here is the full source code:

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
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;
  }
}

Sample Code

This following piece of code illustrates the implementation of the solution.

  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
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),
                            ],
                        ),
                    ),
                ),
            ),
        );
    }
}

Conclusion

This solution works for me and I wanted to share this with you.

I hope you will find this useful and interesting.

Stay tuned for new articles and happy coding.