Comment s’assurer qu’un TextField ou un TextFormField soit visible quand il a le focus sans être recouvert par le clavier ?

Difficulté: Intermédiaire

(Modification 29 août 2018 pour corriger un bug dans la routine _ensureVisible, suivant des modifications réalisées dans le framework Flutter v.0.5.7)

Comme beaucoup de développeurs Flutter, j’ai récemment été confronté à ce problème en traitant un formulaire qui inclut des TextField ou TextFormField.

Lorsque ces champs obtiennent le focus, le clavier est affiché et peut les recouvrir.

En naviguant sur Internet, j’ai trouvé un code source sur GitHub, donné par Collin Jackson (lien).

Ce code résout partiellement le problème mais pas entièrement: si l’utilisateur ferme le clavier et clique ensuite sur le même TextField ou TextFormField, la solution ne fonctionne pas.

Cet article complète cette solution et garantit que ces champs de saisie soient toujours visibles lorsque le clavier est affiché (même après avoir été fermé).

Cette solution ne fonctionne que lorsque les TextFields sont présents dans une zone Scrollable.

La solution repose sur l’utilisation des 2 notions suivantes:

FocusNode

La classe FocusNode doit être utilisée pour être notifiée lorsqu’un Widget obtient ou perd le focus.

Comment utiliser FocusNode ?

Le morceau de code suivant illustre une implémentation très basique d’un formulaire avec 2 TextFormField et nous voulons être avertis lorsque le premier champ obtient et perd le 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(
                                ...
                            ),
                        ],
                    ),
                ),
            ),
        );
    }
}

Explications:

  • Line #7: instancie un Widget FocusNode.
  • Line #12: nous initialisons un listener qui sera appelé lorsque le champ reçoit ou perd le focus.
  • Line #17: très important nous devons libérer le listener lorsque la page est fermée.
  • Lines #21-27: implementation du listener en tant que fonction async (asynchrône).
  • Line #42: nous lions le TextFormField au listener.

WidgetsBindingObserver

Le WidgetsBindingObserver expose des fonctions override qui sont appelées lorsqu’un événement survient au niveau de l’Application, Screen, Memory, Route et Locale (langues).

Pour plus de détails, veuillez vous référer à la documentation.

Dans le cas couvert par cet article, nous ne souhaitons être informés que lorsque les dimensions (metrics) de l’écran changent (ce qui est le cas lorsque le clavier s’ouvre ou se ferme).

Pour utiliser cet Observer, nous devons implémenter un mixin. (Même si cette page est obsolète, elle reste très intéressante pour comprendre le concept).

En particulier, nous l’implémentons comme suit:

 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(){
        ...
    }
}

La Solution

La solution consiste à déléguer le contrôle de la visibilité du Textfield ou TextFormField à un Widget auxiliaire (helper widget) qui devient le conteneur “parent” du TextField ou TextFormField.

Helper Widget

Le “helper widget” (EnsureVisibleWhenFocused) implémente les 2 notions précédemment expliquées.

Voici le code source complet:

  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 in English).
  ///
  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;
  }
}

Exemple

Ce morceau de code suivant illustre la mise en œuvre de la 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

Cette solution fonctionne pour moi et je voulais la partager avec vous.

J’espère que vous trouverez cela utile et intéressant.

Restez à l’écoute pour de nouveaux articles et bon codage.