Flutter ListView jumps to top on each setState() call
Asked Answered
S

3

11

I have a ListView of customers where the user is able to check/uncheck each customer to mark them as a 'favourite'. However, each time a ListItem is tapped, the ListView jumps back to the top of the screen.

How do I prevent this from happening? I only want the Checkbox to refresh, not the entire screen, and to prevent the screen from jumping to the top each time.

@override
Widget build(BuildContext context) {
  customers = getAllCustomers();

  return Scaffold(
     appBar: _getAppBar(),
     body: customers != null
          ? FutureBuilder(
              future: getFavouriteCustomers(), // contains IDs of favourite customers
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.done) {
                  if (snapshot.hasData) {
                    List<String> favouriteCustomersList = snapshot.data;

                    return ListView.builder(
                        itemCount: customers?.length ?? 0,
                        itemBuilder: (BuildContext context, int index) {
                          customer c = customers?.elementAt(index);

                          if (favouriteCustomersList.contains(c.id)) {
                            c.isSelected = true;
                          }

                          return ListTile(
                            title: Text(c.name),
                            trailing: Checkbox(
                                value: c.isFavourite,
                                onChanged: (newValue) {}),
                            onTap: () {
                              if (c.isSelected) {
                                setState(() {
                                  c.setFavourite(false);
                                });
                              } else {
                                setState(() {
                                  c.setFavourite(true);
                                }
                              }
                            },
                          );
                        });
                  }
                } else {
                  return CircularProgressIndicator();
                }
              })
          : Center(
              child: CircularProgressIndicator(),
            );
);

}

Soubrette answered 15/5, 2019 at 21:42 Comment(4)
Have you tried adding this ListView.builder(controller: ScrollController(keepScrollOffset: true),) to your ListView?Bitterling
Thanks, but unfortunately that doesn't workSoubrette
Did you ever find a solution for this?Kendo
This was helpful for me: #53999657Dialecticism
M
2
  1. Ensure you are doing this in a StatefulWidget.

  2. Create a ScrollController as field in the state class (not the widget class):

  _controller = ScrollController(keepScrollOffset: true);

This way the controller will be preserved in the state and not recreated on rebuilds.

  1. Pass it to the builder as
  controller: _controller,
  1. Remember to dispose the controller when the state is destroyed:
  @override
  void dispose() {
    _controller.dispose();
    super.dispose();
  }
  1. As Benno mentioned, ensure that all the way up the widget tree there are no widgets with keys being recreated on rebuilds: How to prevent Flutter app from scrolling to top after calling setState?
Metatherian answered 20/3, 2021 at 8:55 Comment(1)
This solution didn't work for me. I can see how the individual controllers are assigned to each Vertical ListView (nested in a Horizontal ListView) but it doesn't 'store' the last offset position for that ListView. I know I am successfully assigning a controller because I changed the initialOffSet and it's working. But when the ListView is redrawn, it resets the offset position.Tonnage
S
1

Not sure if you already found the answer, but I generally solve the problem by splitting your build methods by using two StatefulWidgets. What was going on here was, every time the setState() is called, the whole widget is rebuilt because the state was getting changed for your whole State class. That means the build method was getting called forcing scaffold to be recreated along with all the widget tree in it. Since the widget was rebuilt, it will assume it's original position which was offset 0. I would suggest to create another stateful widget for returning ListTile object. This will break the build method and action performed on each ListTile is performed within it's own build method. This won't force your Scaffold to be recreated.

I don't have your full code so can't suggest complete working solution but something like below may help.


class SomeStatefulWidget extends StatefulWidget {

  @override
  _SomeStatefulWidgetState createState() => _SomeStatefulWidgetState();
}

class SomeStatefulWidgetState extends State<SomeStatefulWidget> {
@override
Widget build(BuildContext context) {
  customers = getAllCustomers();

  return Scaffold(
     appBar: _getAppBar(),
     body: customers != null
          ? FutureBuilder(
              future: getFavouriteCustomers(), // contains IDs of favourite customers
              builder: (context, snapshot) {
                if (snapshot.connectionState == ConnectionState.done) {
                  if (snapshot.hasData) {
                    List<String> favouriteCustomersList = snapshot.data;

                    return ListView.builder(
                        itemCount: customers?.length ?? 0,
                        itemBuilder: (BuildContext context, int index) {
                          customer c = customers?.elementAt(index);

                          if (favouriteCustomersList.contains(c.id)) {
                            c.isSelected = true;
                          }

                          return MyListTile(c);

                        });
                  }
                } else {
                  return CircularProgressIndicator();
                }
              })
          : Center(
              child: CircularProgressIndicator(),
            );
);
}


class MyListTile extends StatefulWidget {

  customer c;

  @override
  _MyListTileState createState(this.c) => _MyListTileState();

}

class _MyListTileState extends State<MyListTile> {

  @override
  Widget build(BuildContext context) {

    var c = widget.c;

    return ListTile(
      title: Text(c.name),
      trailing: Checkbox(
          value: c.isFavourite,
          onChanged: (newValue) {}),
      onTap: () {
        if (c.isSelected) {
          setState(() {
            c.setFavourite(false);
          });
        } else {
          setState(() {
            c.setFavourite(true);
          }
        }
      },
    );
  }
}

By splitting the widget in to two, setState() will now only build the MyListTile object by calling build method of MyListTileState class. You effectively wrapped ListTile in to your own stateful class named MyListTile.

I hope this works for you. As said, I don't have your full code hence made some wrapper classes to make this example clear. Also, I haven't compiled this code and typed everything here so there may be some compile time error. But this explains the concept, reason of jumping to top and solution.

Sandysandye answered 18/5, 2021 at 18:29 Comment(0)
S
0

Instead of using setState() to update the UI, you can try wrapping the listTile inside a StreamBuilder and provide it with a stream which sends data every time the checkbox is tapped. Once StreamBuilder receives data from the stream, it will update just the cell, and not the whole list, and eventually you will be able to avoid the top-scrolling of list.

Scampi answered 24/5, 2021 at 5:47 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.