0

How does setState actually work?

It seems to not do what I expect it to do when the Widget which should have been rebuilt is built in a builder function. The current issue I have is with a ListView.builder and buttons inside an AlertDialog.

One of the buttons here is an "AutoClean" which will automatically remove certain items from the list show in the dialog.

Note: The objective here is to show a confirmation with a list of "Jobs" which will be submitted. The jobs are marked to show which ones appear to be invalid. The user can go Back to update the parameters, or press "Auto Clean" to remove the ones that are invalid.

The button onTap looks like this:

    GeneralButton(
      color: Colors.yellow,
      label: 'Clear Overdue',
      onTap: () {
        print('Nr of jobs BEFORE: ${jobQueue.length}');

        for (int i = jobQueue.length - 1; i >= 0; i--) {
          print('Checking item at $i');
          Map task = jobQueue[i];
          if (cuttoffTime.isAfter(task['dt'])) {
            print('Removing item $i');
            setState(() {                             // NOT WORKING
              jobQueue = List<Map<String, dynamic>>.from(jobQueue)
                ..removeAt(i);                        // THIS WORKS
            });

          }
        }

        print('Nr of jobs AFTER: ${jobQueue.length}');
        updateTaskListState();                        // NOT WORKING 
        print('New Task-list state: $taskListState');
      },
    ),

Where jobQueue is used as the source for building the ListView.

updateTaskListState looks like this:

  void updateTaskListState() {
    DateTime cuttoffTime = DateTime.now().add(Duration(minutes: 10));
    if (jobQueue.length == 0) {
      setState(() {
        taskListState = TaskListState.empty;
      });
      return;
    }
    bool allDone = true;
    bool foundOverdue = false;
    for (Map task in jobQueue) {
      if (task['result'] == null) allDone = false;
      if (cuttoffTime.isAfter(task['dt'])) foundOverdue = true;
    }
    if (allDone) {
      setState(() {
        taskListState = TaskListState.done;
      });
      return;
    }
    if (foundOverdue) {
      setState(() {
        taskListState = TaskListState.needsCleaning;
      });
      return;
    }
    setState(() {
      taskListState = TaskListState.ready;
    });
  }

TaskListState is simply an enum used to decide whether the job queue is ready to be submitted.

The "Submit" button should become active once the taskListState is set to TaskListState.ready. The AlertDialog button row uses the taskListState for that like this:

  Row(
    mainAxisAlignment: MainAxisAlignment.spaceBetween,
    mainAxisSize: MainAxisSize.max,
    children: <Widget>[
      if (taskListState == TaskListState.ready)
        ConfirmButton(
            onTap: (isValid && isOnlineNow)
                ? () {
                  postAllInstructions().then((_) {
                    updateTaskListState();
                    // navigateBack();
                  });
                : null),

From the console output I can see that that is happening but it isn't working. It would appear to be related to the same issue.

I don't seem to have this kind of problem when I have all the widgets built using a simple widget tree inside of build. But in this case I'm not able to update the display of the dialog to show the new list without the removed items.

This post is getting long but the ListView builder, inside the AleryDialog, looks like this:

  Flexible(
    child: ListView.builder(
      itemBuilder: (BuildContext context, int itemIndex) {
        DateTime itemTime = jobQueue[itemIndex]['dt'];
        bool isPastCutoff = itemTime.isBefore(cuttoffTime);
        return Row(
          mainAxisAlignment: MainAxisAlignment.spaceBetween,
          mainAxisSize: MainAxisSize.max,
          children: <Widget>[
            Text(
              userDateFormat.format(itemTime),
              style: TextStyle(
                color:
                    isPastCutoff ? Colors.deepOrangeAccent : Colors.blue,
              ),
            ),
            Icon(
              isPastCutoff ? Icons.warning : Icons.cached,
              color: isPastCutoff ? Colors.red : Colors.green,
            )
          ],
        );
      },
      itemCount: jobQueue.length,
    ),
  ),

But since the Row() with buttons also doesn't react to setState I doubt that the problem lies within the builder function itself.

FWIW all the code, except for a few items like "GeneralButton" which is just a boilerplate widget, resides in the State class for the Screen.

My gut-feeling is that this is related to the fact that jobQueue is not passed to any of the widgets. The builder function refers to jobQueue[itemIndex], where it accesses the jobQueue attribute directly.

I might try to extract the AlertDialog into an external Widget. Doing so will mean that it can only access jobQueue if it is passed to the Widget's constructor....

The Tahaan
  • 6,915
  • 4
  • 34
  • 54
  • After extracting the `content` of the AlertDialog into a Widget, which gets the `jobQueue` and ``taskListState` and all the callbacks as arguments, I still get exactly the same behaviour. – The Tahaan Aug 02 '19 at 07:57

1 Answers1

2

Since you are writing that this is happening while using a dialog, this might be the cause of your problem:

Snippet from Flutter API

https://api.flutter.dev/flutter/material/showDialog.html

The setState call inside your dialog therefore won't trigger the desired UI rebuild of the dialog content. As stated in the API a short and easy way to achieve a rebuild in another context would be to use the StatefulBuilder widget:

showDialog(
    context: context,
    builder: (dialogContext) {
      return StatefulBuilder(
        builder: (stateContext, setInnerState) {
          // return your dialog widget - Rows in ListView in Container
          ...
          // call it directly as part of onTap of a widget of yours or
          // pass the setInnerState down to another widgets
          setInnerState((){
            ...
          })

    }
);

EDIT

There are, as in almost every case in the programming world, various approaches to handle the setInnerState call to update the dialog UI. It highly depends on the general way of how you decided to manage data flow / management and logic separation. As an example I use your GeneralButton widget (assuming it is a StatefulWidget):

class GeneralButton extends StatefulWidget {
  // all your parameters
  ...
  // your custom onTap you provide as instantiated
  final VoidCallback onTap;

  GeneralButton({..., this.onTap});

  @override
  State<StatefulWidget> createState() => _GeneralButtonState();
}

class _GeneralButtonState extends State<GeneralButton> {
  ...
  @override
  Widget build(BuildContext context) {
    // can be any widget acting as a button - Container, GestureRecognizer...
    return MaterialButton(
      ...
      onTap: {
        // your button logic which has either been provided fully
        // by the onTap parameter or has some fixed code which is
        // being called every time
        ...
        // finally calling the provided onTap function which has the
        // setInnerState call!
        widget.onTap();
      },
    );
  }

If you have no fixed logic in your GeneralButton widget, you can write: onTap: widget.onTap

This would result in using your GeneralButton as follows:

...
GeneralButton(
  ...
  onTap: {
    // the desired actions like provided in your first post
    ...
    // calling setInnerState to trigger the dialog UI rebuild
    setInnerState((){});
  },
)
kounex
  • 1,555
  • 4
  • 12
  • OK, I guess I should have RTFM. I will try this and I assume this is exactly the problem I'm having. – The Tahaan Aug 02 '19 at 09:33
  • Can you please update your answer to show how a button in the dialog can be given a callBack (anonymous or as a method on the State) which would after doing it's work update the Dialog. – The Tahaan Aug 02 '19 at 09:46
  • I am guessing if I provide a callback to the Dialog to a fucntion running in the pageState that won't work. Also your setInnerState calls the setState of the outer State. How does it know to update the state in the dialog Statefull widget? I'm more and more inclined to think I should just a) Create a Stateful Widget "MyDialog" to hold the Dialog content. b) Create a "controller" MyDialogController" for it with methods to update the State c) Pass this controller from the caller to the new MyDialog widget. d) use the controller in the parent to update the dialog. – The Tahaan Aug 02 '19 at 12:01
  • Also your setInnerState -> setState doesn't have any content inside the inner function, it is just an anonymous default do-nothing function. I've often seen people do that even with setState, but the Flutter docs says to update the State attributes inside of this function. Why/How/When does this matter? – The Tahaan Aug 02 '19 at 12:02
  • Option a is very viable and would also extract code (but obviously only needed if the dialog content is actually big) - setInnerState does not call setState of the outter context. StatefulBuilder provides us with its own setState via the builder function (I just named it setInnerState to be able to distinguish). And yes, you actually don't need to layout the setState call and just call it as i did at the very end since it only serves as triggering a rebuild – kounex Aug 02 '19 at 14:05
  • So please can you explain what is the purpose of the function passed into setState? – The Tahaan Aug 02 '19 at 21:11
  • By the way the "GeneralButton" is just boilerplate around a button, basically a stateless widget. – The Tahaan Aug 02 '19 at 21:13
  • Here is an explanation on why the callback can be used: https://stackoverflow.com/questions/50551334/what-is-the-point-of-adding-a-callback-to-setstate-and-not-just-calling-setstate – kounex Aug 02 '19 at 23:56