3

I have a very simple (stateful) widget that contains a Text widget that displays the length of a list which is a member variable of the widget's state.

Inside the initState() method, I override the list variable (formerly being null) with a list that has four elements using setState(). However, the Text widget still shows "0".

The prints I added imply that a rebuild of the widget has not been triggered although my perception was that this is the sole purpose of the setState() method.

Here ist the code:

import 'package:flutter/material.dart';

class Scan extends StatefulWidget {
  @override
  _ScanState createState() => _ScanState();
}

class _ScanState extends State<Scan> {
  List<int> numbers;

  @override
  void initState() {
    super.initState();
    _initializeController();
  }

  @override
  Widget build(BuildContext context) {
    print('Build was scheduled');
    return Center(
      child: Text(
        numbers == null ? '0' : numbers.length.toString()
      )
    );
  }

  Future<List<int>> _getAsyncNumberList() {
    return Future.delayed(Duration(seconds: 5), () => [1, 2, 3, 4]);
  }

  _initializeController() async {
    List<int> newNumbersList = await _getAsyncNumberList();

    print("Number list was updated to list of length ${newNumbersList.length}");

    setState(() {
      numbers = newNumbersList;
    });
  }
}

My question: why does the widget only build once? I would have expected to have at least two builds, the second one being triggered by the execution of setState().

Schnodderbalken
  • 3,257
  • 4
  • 34
  • 60
  • 2
    your async function completed before build is finished, use FutureBuilder –  Sep 28 '20 at 10:40
  • However, if I wrap `_initializeController()` in `WidgetsBinding.instance.addPostFrameCallback` which causes the inner function to be executed after build, it does not work either. How do you explain that? – Schnodderbalken Sep 28 '20 at 10:44
  • there is nothing to explain, it works –  Sep 28 '20 at 10:49
  • I just realized that it only happens on a real device. On Dartpad and in the emulator, it works. So I'm gonna create a new question about that topic. – Schnodderbalken Sep 28 '20 at 10:53
  • Please use a `FutureBuilder` as described [here](https://stackoverflow.com/questions/63017280/what-is-a-future-and-how-do-i-use-it). – nvoigt Sep 28 '20 at 12:18
  • Okay but why is a `FutureBuilder` necessary if I call `setState()` at the end of the method execution? Shouldn't that trigger a new build anyways? – Schnodderbalken Sep 29 '20 at 07:27
  • Actually, the async function completes *after* the build is finished. This can be forced by using `Future.delayed` in `_getAsyncNumberList()`. So why does `setState()` not trigger a rebuild causing the `Text` widget to display the new length? – Schnodderbalken Sep 29 '20 at 10:59
  • it shows 4 after five seconds –  Sep 29 '20 at 11:47

3 Answers3

2

I have the feeling, the answers don't address my question. My question was why the widget only builds once and why setState() does not trigger a second build.

Answers like "use a FutureBuilder" are not helpful since they completely bypass the question about setState(). So no matter how late the async function finishes, triggering a rebuild should update the UI with the new list when setState() is executed.

Also, the async function does not finish too early (before build has finished). I made sure it does not by trying WidgetsBinding.instance.addPostFrameCallback which changed: nothing.

I figured out that the problem was somewhere else. In my main() function the first two lines were:

  SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
  SystemChrome.setPreferredOrientations(
    [DeviceOrientation.portraitUp,DeviceOrientation.portraitDown]
  );

which somehow affected the build order. But only on my Huawei P20 Lite, on no other of my test devices, not in the emulator and not on Dartpad.

So conclusion: Code is fine. My understanding of setState() is also fine. I haven't provided enough context for you to reproduce the error. And my solution was to make the first two lines in the main() function async:

void main() async {
  await SystemChrome.setEnabledSystemUIOverlays([SystemUiOverlay.bottom]);
  await SystemChrome.setPreferredOrientations(
    [DeviceOrientation.portraitUp,DeviceOrientation.portraitDown]
  );
  ...
}
Schnodderbalken
  • 3,257
  • 4
  • 34
  • 60
0

Proof it works

I don't know why you say your code is not working, but here you can see that even the prints perform as they should. Your example might be oversimplified. If you add a delay to that Future (which is a real case scenario, cause fetching data and waiting for it does take a few seconds sometimes), then the code does indeed display 0.

The reason why your code works right now is that the Future returns the list instantly before the build method starts rendering Widgets. That's why the first thing that shows up on the screen is 4.

If you add that .delayed() to the Future, then it does indeed stop working, because the list of numbers is retrieved after some time and the build renders before the numbers are updated.

Problem explanation

SetState in your code is not called properly. You either do it like this (which in this case makes no sense because you use "await", but generally it works too)

    _initializeController() async {
    setState(() {
    List<int> newNumbersList = await _getAsyncNumberList();

    print("Number list was updated to list of length ${newNumbersList.length}");

    
      numbers = newNumbersList;
    });
  }

or like this

    _initializeController() async {
    List<int> newNumbersList = await _getAsyncNumberList();

    print("Number list was updated to list of length ${newNumbersList.length}");

    
    numbers = newNumbersList;
    setState(() {
    /// this thing right here is an entire function. You MUST HAVE THE AWAIT in 
    /// the same function as the update, otherwise, the await is callledn, and on 
    /// another thread, the other functions are executed. In your case, this one 
    /// too. This one finishes early and updates nothing, and the await finishes later.
    });
  }

Suggested solution

This will display 0 while waiting 5 seconds for the Future to return the new list with the data and then it will display 4. If you want to display something else while waiting for the data, please use a FutureBuilder Widget.

FULL CODE WITHOUT FutureBuilder:

class Scan extends StatefulWidget {
  @override
  _ScanState createState() => _ScanState();
}

class _ScanState extends State<Scan> {
  List<int> numbers;

  @override
  void initState() {
    super.initState();
    _initializeController();
  }

  @override
  Widget build(BuildContext context) {
    print('Build was scheduled');

    return Center(
        child: Text(numbers == null ? '0' : numbers.length.toString()));
  }

  Future<List<int>> _getAsyncNumberList() {
    return Future.delayed(Duration(seconds: 5), () => [1, 2, 3, 4]);
  }

  _initializeController() async {
      List<int> newNumbersList = await _getAsyncNumberList();

      print(
          "Number list was updated to list of length ${newNumbersList.length}");
      numbers = newNumbersList;
      setState(() {});
  }
}

I strongly recommend using this version, since it displays something to the user the whole time while waiting for the data and also has a failsafe if an error comes up. Try them out and pick what is best for you, but again, I recommend this one.

FULL CODE WITH FutureBuilder:

class _ScanState extends State<Scan> {
  List<int> numbers;

  @override
  void initState() {
    super.initState();

  }

  @override
  Widget build(BuildContext context) {
    print('Build was scheduled');

    return FutureBuilder(
      future: _getAsyncNumberList(),
      builder: (BuildContext context, AsyncSnapshot<List<int>> snapshot) {
        switch (snapshot.connectionState) {
          case ConnectionState.waiting: return Center(child: Text('Fetching numbers...'));
          default:
            if (snapshot.hasError)
              return Center(child: Text('Error: ${snapshot.error}'));
            else
              /// snapshot.data is the result that the async function returns
              return Center(child: Text('Result: ${snapshot.data.length}'));
        }
      },
    );
  }

  Future<List<int>> _getAsyncNumberList() {
    return Future.delayed(Duration(seconds: 5), () => [1, 2, 3, 4]);
  }
}

Here is a more detailed example with a full explanation of how FutureBuilder works. Take some time and carefully read through it. It's a very powerful thing Flutter offers.

Mad World
  • 129
  • 2
  • 8
  • Thank your for your answer. I understand what you're saying, but I still don't get why it's a problem to have the `_getAsyncNumberList()` completed after `build()`. Calling `setState()`'s purpose is to trigger a build isn't it? So even if the execution of the function takes longer than the build, then calling `setState()` should update the widget and the text anyways, shouldn't it? – Schnodderbalken Sep 29 '20 at 07:22
  • I haven't said that is the problem. The problem is, your build renders before your method finished retrieving the numbers. Please don't just downvote people because you did not read the answer properly. Try out my code and you will see it works as it should. It displays something while waiting a few seconds for the result, and then the result is shown – Mad World Sep 29 '20 at 19:57
  • In conclusion, in your initial code, where there was no delay, the numbers were retrieved before build, therefore the build displayed 4 on the screen straight up. SetState is not used properly in your code. Check my edit – Mad World Sep 29 '20 at 20:01
  • 1
    I wasn't me who downvoted your answer, sorry! Well, I thought the whole purpose of `await` is to wait for the execution of the function, turning an async call into a sync call. How can `setState()` be executed before the execution then? Apart from that: I can not put everything including the `await`ed function call inside `setState()`, even if I make `setState` async itself. See for yourself, the compiler will complain. If I do `_getAsyncNumberList().then(() {setState(...)})`, it still does not work, which is from my understanding the same like my code now. – Schnodderbalken Sep 30 '20 at 06:50
  • Yeah, I forgot to mention the fact that the await makes no sense inside the setState. What was wrong with your code was just not placing all the connected parts outside the setState function. Just keep in mind it's best to leave setState empty and do your thing before and outside of it – Mad World Oct 01 '20 at 03:49
  • Technically it doesn't matter whether to put the variable assignment inside the callback of setState or not. However, it's discouraged to have empty setState() callbacks. https://api.flutter.dev/flutter/widgets/ModalRoute/setState.html https://medium.com/@mehmetf_71205/setting-the-state-2809936fb79d So what's your source of information for "it's best to leave setState empty"? And why do you think it makes a difference? – Schnodderbalken Oct 01 '20 at 07:30
0

This answer may not fit the explicit Problem OP was facing, but it fits the Title of the Question.

So basically i was using a Scaffold with BottomNavigation bar. In the body i put a List<Widget> which i prepared in the initState-Function and then indexed via _bodyWidgets.elementAt(_bottomNavIndex)

Everything worked so fine, until i was changing the state of said Widgets, because it never triggered the build-Function and thus never actually updating any deeper state. I didnt use initState in a async-Function or anything like that.

What finally did it for me was, to not put the List<Widget> in a variable and define it in setState, but rather to write it directly in the body parameter of Scaffold (i guess i could also just redefine the variable in the parent build-function)

dont:

initState(){
    _bodyWidgets = [MyWidget(val: _someState),...]
}

...

build(BuildContext context){
    return Scaffold(
    body: _bodyWidgets.elementAt(_navIndex);
    ...
    );
}

do:

build(BuildContext context){
    return Scaffold(
    body: [MyWidget(val: _someState),...].elementAt(_navIndex);
    ...
    );
}
Raqha
  • 754
  • 4
  • 17