How to scroll to bottom of SingleChildScrollView when TextField gets focus?

Chandler Davis picture Chandler Davis · Apr 16, 2019 · Viewed 9.6k times · Source

So, I have a login page with two TextFields, and then a RaisedButton for login at the very bottom. When I tap on the email field and the keyboard pops up, I would like for the SingleChildScrollView (the parent of everything on the page) to scroll to the maxScrollExtent.

Things I have tried that haven't worked:

  • Taking advantage of Scaffold's ability to do this automatically (Scaffold is the parent widget of everything in the app)
  • Using this tutorial in which a helper widget is created. Also uses WidgetBindingsObserver, but the tutorial as a whole did not work for me. I wonder if WidgetBindingsObserver could still be helpful, however.

What almost works:

  • Attaching a FocusNode to the TextForm, then attaching a listener in initState() which will animate to the maxScrollExtent when it has focus.

By almost, here's what I mean (excuse the GIF discoloration):

enter image description here

As you can see, it doesn't work the first time it focuses so I have to tap the password field, then retap the email field for it to animate. I have tried adding a delay (even up to 500ms) so that the viewport has time to fully resize before doing this, but that didn't work either.

If you recognize this login theme, that's because I adapted it from here. The file is pretty lengthy, but here are the relevant bits:

@override
  void initState() {
    super.initState();
    scrollController = ScrollController();
    focusNode = FocusNode();

    focusNode.addListener(() {
      if (focusNode.hasFocus) {
        scrollController.animateTo(scrollController.position.maxScrollExtent,
            duration: Duration(milliseconds: 500), curve: Curves.ease);
      }
    });

    _emailFieldController = TextEditingController();
    _passFieldController = TextEditingController();

    _emailFieldController.addListener(() {
      _emailText = _emailFieldController.text;
    });

    _passFieldController.addListener(() {
      _passText = _passFieldController.text;
    });
  }
 @override
  Widget build(BuildContext context) {
    return SingleChildScrollView(
      controller: scrollController,
      child: Container(
        height: MediaQuery.of(context).size.height,
        decoration: BoxDecoration(
          color: Colors.white,
          image: DecorationImage(
            colorFilter: ColorFilter.mode(
                Colors.black.withOpacity(0.05), BlendMode.dstATop),
            image: AssetImage('assets/images/mountains.jpg'),
            fit: BoxFit.cover,
          ),
        ),
        child: new Column(
          children: <Widget>[
            // this is where all other widgets in the file are

Container(
              width: MediaQuery.of(context).size.width,
              margin: const EdgeInsets.only(left: 40.0, right: 40.0, top: 10.0),
              alignment: Alignment.center,
              decoration: BoxDecoration(
                border: Border(
                  bottom: BorderSide(
                      color: Colors.deepPurple,
                      width: 0.5,
                      style: BorderStyle.solid),
                ),
              ),
              padding: const EdgeInsets.only(left: 0.0, right: 10.0),
              child: Row(
                crossAxisAlignment: CrossAxisAlignment.center,
                mainAxisAlignment: MainAxisAlignment.start,
                children: <Widget>[
                  Expanded(
                    child: TextField(
                      controller: _emailFieldController,
                      keyboardType: TextInputType.emailAddress,
                      focusNode: focusNode,
                      obscureText: false,
                      textAlign: TextAlign.left,
                      decoration: InputDecoration(
                        border: InputBorder.none,
                        hintText: '[email protected]',
                        hintStyle: TextStyle(color: Colors.grey),
                      ),
                    ),
                  ),
                ],
              ),
            ),

Any guidance would be greatly appreciated. Thank you!

Answer

diegoveloper picture diegoveloper · Apr 16, 2019

Use addPostFrameCallback to listen after the widget was built.

          _onLayoutDone(_){
              FocusScope.of(context).requestFocus(focusNode);
          } 

          @override
          void initState() {
            //... your stuff

            WidgetsBinding.instance.addPostFrameCallback(_onLayoutDone);
            super.initState();
          }

UPDATE

I see the error, the first time you use scrollController.position.maxScrollExtent the value is 0, after you tap on password textField and you change the focus to email, now the maxScrollExtent is different because the keyboard is open.

If you want to make it work, do a logic to calculate the space and set the value directly.

If you use

 scrollController.animateTo(180.0,
        duration: Duration(milliseconds: 500), curve: Curves.ease);

It should work.