flutterwidgetprogress-baryield

How to force a stateful widget to redraw when setting state from within a "yielding stream" listener?


Nothing crazy what I am trying to achieve but I pull my hair on it for days.

I basically want to achieve the following: a long running process does internet accesses and processing of data with many states to pass thru. I want the user to be kept posted of each completing state hence my stateful widget shows a progress bar and a text widget that shows the name of the state.

I can sum up this with this dramatically stripped down version that perfectly runs as wanted in a dart pad:

import 'package:flutter/material.dart';

void main() {
  runApp(const MyApp());
}

class MyApp extends StatefulWidget {
  const MyApp({super.key});

  State<MyApp> createState() => _MyAppState();
}

class _MyAppState extends State<MyApp> {
  String? _subState;

  Stream<String?> longRunningProcess() async* {
    // ... process logic ...

    for (int i = 0; i < 10; i++) {
      yield i.toString();

      int a = 0;
      for (int j = 0; j < 10000000; j++) {
        a = a + 1;
      }
      await Future.delayed(Duration(seconds: 1)); // <<<=== Removing this breaks the realtime update of the widget
    }
    // Close the stream to signal completion
  }

  @override
  void initState() {
    longRunningProcess()
        .listen((subState) => setState(() => _subState = subState));
    super.initState();
  }

  @override
  Widget build(BuildContext context) {
    return MaterialApp(
      debugShowCheckedModeBanner: false,
      home: Scaffold(
        body: Center(
          child: Text('state = $_subState!'),
        ),
      ),
    );
  }
}

As you can see, like in the latest version of my actual code, I came up with a version that takes advantage of yielding: Each state yields a string that says what the sate is doing and the listener simply setState() it.

If you run in a Dart pad, you see all the states perfectly showing in realtime.

BUT THIS IS NOT THE CASE in my code. And I'd like you advise me and give suggestions on the solution I can look into.

One thing though: if you remove the delay, you face the same situation I have as the widget only shows the very last step, as if the widget never get a chance to trigger a build.

IN MY ACTUAL code, I see with the debugger, that my setState() is ran as expected, and even though I delay to give a change to the rendering system to trigger a build, I don't get it.

Here is my listener code:

 _subscription = future.listen(
        (subState) async {
          debugPrint("GasStationDownloaderView got sub state: $subState");
          setState(() => _subState = subState); // <<<== the debugger stops on this line with no problem and I can see the debuPrint prints the various states.
          await Future.delayed(Duration(seconds: 1));
        },
        onDone: () => Navigator.pushReplacement(
          context,
          MaterialPageRoute(
            builder: (context) => AppBottomNavigationBarController(),
          ),
        ),
      );

Solution

  • As suggested by @pskink, I replaced the setState() by a StreamBuilder()and it works now.

    Here is the final code:

    
    import 'package:flutter/material.dart';
    
    void main() {
      runApp(const MyApp());
    }
    
    class MyApp extends StatefulWidget {
      const MyApp({super.key});
    
      State<MyApp> createState() => _MyAppState();
    }
    
    class _MyAppState extends State<MyApp> {
      late final Sream<String?> _stream;
    
      StreamSubscription<String?>? _subscription;
    
      Stream<String?> longRunningProcess() async* {
        // ... process logic ...
    
        for (int i = 0; i < 10; i++) {
          yield i.toString();
    
          int a = 0;
          for (int j = 0; j < 10000000; j++) {
            a = a + 1;
          }
          await Future.delayed(Duration(seconds: 1)); // <<<=== Removing this breaks the realtime update of the widget
        }
        // Close the stream to signal completion
      }
    
      @override
      void initState() {
        // Make our stream a broadcast stream so that I can have more than one listener.
        _stream = longRunningProcess().asBroadcastStream();
    
        // Keep posted for the stream to complete
        _subscription = _stream
            .listen( 
              null,
              onDone: () {
                // It's all good, I can now push-replace the next screen to go ahead 
              },
        );
    
        super.initState();
      }
    
    
      @override
      void dispose() {
        _subscription?.cancel();
        super.dispose();
      }
    
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          debugShowCheckedModeBanner: false,
          home: Scaffold(
            body: Center(
              child: StreamBuilder(
                stream: _stream,
                builder: (context, snapshot) {
    
                  final subState = snapshot.data;
    
                  return Text('state = $subState');
                },
              ),
            ),
          ),
        );
      }
    }