Flutter infinite loop when StreamBuilder inside LayoutBuilder

2,436

This happens because of a misuse of streams.

The culprit is this line:

Observable<String> get emailStream => _emailController.stream.transform(...);

The issue with this line is that it creates a new stream every time.

This means that bloc.emailStream == bloc.emailStream is actually false.

When combined with StreamBuilder, it means that every time something asks StreamBuilder to rebuild, the latter will restart the listening process from scratch.


Instead of a getter, you should create the stream once inside the constructor body of your BLoC:

class MyBloc {
  StreamController _someController;
  Stream foo;

  MyBloc() {
    foo = _someController.stream.transform(...);
  }
}
Share:
2,436
Manos Serifios
Author by

Manos Serifios

Updated on December 11, 2022

Comments

  • Manos Serifios
    Manos Serifios over 1 year

    So i am making a page with a LayoutBuilder as described here

    Inside the LayoutBuilder i put a StreamBuilder with a TextField powered by the bloc class SignupFormBloc. The stream is a BehaviorSubject

    When someone put something in the input it trigger the onChanged function which is the sink for my stream. So i add the value in the stream then i pass the value in a StreamTransformer to validate the value and then i let the StreamBuilder to build the TextField again with an error message(if value not valid).

    This is were the problem starts.

    When i click on the TextField and enter something it starts an infinite loop like this:

    • The StreamBuilder sees the new value in the stream
    • The StreamBuilder try to rebuild TextField
    • Some how this triggers the LayoutBuilder builder function
    • The LayoutBuilder builder function builds again the StreamBuilder
    • StreamBuilder find a value in stream(because of the BehaviorSubject)
    • and all start again from the first bulled in an endless loop

    Hint: If i change the BehaviorSubject to a PublishSubject everything is ok

    Hint 2: If i remove the StreamBuilder completely and just let a blank TextField, you can see that in every entry the LayoutBuilder builder function run. Is that a normal behavior?

    import 'dart:async';
    
    import 'package:flutter/material.dart';
    import 'package:rxdart/rxdart.dart';
    
    void main() => runApp(MyApp());
    
    class MyApp extends StatelessWidget {
      // This widget is the root of your application.
      @override
      Widget build(BuildContext context) {
        return MaterialApp(
          title: 'Flutter Demo',
          theme: ThemeData(
            primarySwatch: Colors.blue,
          ),
          home: MyHomePage(title: 'Flutter Demo Home Page'),
        );
      }
    }
    
    class MyHomePage extends StatefulWidget {
      MyHomePage({Key key, this.title}) : super(key: key);
    
      final String title;
    
      @override
      _MyHomePageState createState() => _MyHomePageState();
    }
    
    class _MyHomePageState extends State<MyHomePage> {
    
    
      SignupFormBloc _signupFormBloc;
    
      @override
      void initState() {
        super.initState();
        _signupFormBloc = SignupFormBloc();
      }
    
      @override
      Widget build(BuildContext context) {
        print('Build Run!!!!!');
        return Scaffold(
          appBar: AppBar(
    
            title: Text(widget.title),
          ),
          body: LayoutBuilder(
            builder: (BuildContext context, BoxConstraints viewportConstraints) {
              print('Layout Builder!!!');
              return SingleChildScrollView(
                child: ConstrainedBox(
                  constraints: BoxConstraints(
                    minHeight: viewportConstraints.maxHeight,
                  ),
                  child: IntrinsicHeight(
                    child:         StreamBuilder<String>(
                      stream: _signupFormBloc.emailStream,
                      builder: (context, AsyncSnapshot<String> snapshot) {
    
                        return TextField(
                          onChanged: _signupFormBloc.onEmailChange,
                          keyboardType: TextInputType.emailAddress,
                          decoration: InputDecoration(
                            hintText: 'Email',
                            contentPadding: const EdgeInsets.symmetric(horizontal: 15, vertical: 18),
                            filled: true,
                            fillColor: Colors.white,
                            errorText: snapshot.error,
                            border: new OutlineInputBorder(
                              borderSide: BorderSide.none
                            ),
                          ),
    
                        );
                      }
                    ),
                  ),
                ),
              );
            },
          )
        );
      }
    
      @override
      void dispose() {
        _signupFormBloc?.dispose();
        super.dispose();
      }
    
    }
    
    
    class SignupFormBloc  {
    
      ///
      /// StreamControllers
      ///
      BehaviorSubject<String> _emailController = BehaviorSubject<String>();
    
    
      ///
      /// Stream with Validators
      ///
      Observable<String> get emailStream => _emailController.stream.transform(StreamTransformer<String,String>.fromHandlers(handleData: (email, sink){
    
        final RegExp emailExp = new RegExp(r"^[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+$");
    
        if (!emailExp.hasMatch(email) || email.isEmpty){
          print('has error');
          sink.addError('Email format is invalid');
        } else {
          sink.add(email);
        }
      }));
    
    
      ///
      /// Sinks
      ///
      Function(String) get onEmailChange => _emailController.sink.add;
    
    
      void dispose() {
        _emailController.close();
      }
    
    
    
    }