Bez kategorii

TIL #2: Dynamic TextField validation in Flutter

Disclaimer: I’ve started working on very simple Flutter project to learn the basics of the framework and generally check it out. The goal here is to create production ready app that will be published to Google Play and Appstore.

BLoC Pattern

Before I started working on the app, I was aware of some popular libraries and approaches in the Flutter framework. One of them was BLoC library (https://bloclibrary.dev/) which I introduced to the project. The first thing I tried was very simple:

  • Add TextField with very simple validation (String is not empty)
  • Move validation to other class for easy unit testing (preferably BLoC)
  • Enable/disable button based on the validation status

Very easy task, yet when I started looking into the documentation and checking some blogposts on the web, they all cover the same scenario:

  1. We have one abstract State class and abstract Event class
  2. In Bloc class, the mapEventToState method is overrode, where most of blogposts have if-else conditions to yield different States
  3. In Flutter Widgets there are another ifs which are rendering different things based on the State

To be fair, I don’t like it. Probably there is another approach I can follow with BLoC library that I am not aware yet, or maybe it’s common approach widely used in Flutter community. But what worked well for me in that case was Stream from dart:async library.

Stream library

Let’s look at this class:

class AddGoalsBloc {
  final _goalStream = StreamController<String>.broadcast();

  final ProjectRepository _projectRepository;

  AddGoalsBloc(this._projectRepository);

  Function(String) get changeGoal => _goalStream.sink.add;

  Stream<String> get getGoal => _goalStream.stream.transform(_goalValidator);

  final _goalValidator = StreamTransformer<String,String>.fromHandlers(
      handleData: (goal,sink) {
        if(goal != null && goal.isNotEmpty){
          sink.add(goal);
        }
        else{
          sink.addError('Goal is empty!');
        }
      }
  );

  dispose() {
    _goalStream.close();
  }
}

It’s very simple, yet it gives everything we need:

  • StreamController.broadcast() allows us to subscribe to this stream with multiple subscribers (TextField for setting error and Button for enabling/disabling clicks)
  • _goalValidator validates if ‘goal’ is not null and not empty and adds proper value to sink
  • ‘Getter’ and ‘Setter’ (Stream and Function) are exposed so we can easily access them
  • There is ‘dispose()’ method which closes the stream to avoid Memory Leaks
  • The class is easily testable

Let’s look at the usage:

Widget buildGoalEditText(AddGoalsBloc bloc) {
    return StreamBuilder<Object>(
        stream: bloc.getGoal,
        builder: (context, snapshot) {
          return Container(
            width: 200,
            child: TextFormField(
              keyboardType: TextInputType.text,
              decoration: InputDecoration(
                labelText: 'Goal',
                errorText: snapshot.error,
              ),
              onChanged: bloc.changeGoal,
            ),
          );
        });
  }

  Widget buildButton(AddGoalsBloc bloc) {
    return StreamBuilder<String>(
        stream: bloc.getGoal,
        builder: (context, snapshot) {
          return RaisedButton(
              child: Text('Add goal'),
              onPressed: (snapshot.hasData) ? () => {_addGoal(snapshot.data)} : null
          );
        });
  }

  _addGoal(String text) {
    this._bloc.changeGoal(text);
  }

Because we have StreamController.broadcast() we can easily subscribe with multiple widgets. We can also utilize the same value for different widgets. It covers everything I needed

In the end, the test class:

void main() {
  test('Stream should pass error when goal is null', () {
    final bloc = AddGoalsBloc(null);

    bloc.getGoal.listen((event) {},
    onError: (error) {
      expect(error, isNotNull);
    });

    bloc.changeGoal(null);
  });

  test('Stream should pass goal when is not null and not empty', () {
    final bloc = AddGoalsBloc(null);
    final dummyGoal = 'dummyGoal';

    bloc.getGoal.listen((event) {
     expect(event, dummyGoal);
    });

    bloc.changeGoal(dummyGoal);
  });
}

Everything easy and straightforward.

Conclusion

Still, I have many doubts related to this code:

  • It can be probably better designed (but for learning purpose it’s enough)
  • The BLoC can probably be used easily, but if there are more TextFormFields I think there is an advantage of Stream (direct coupling of streams and widgets instead of one BLoC for all of the fields)
  • I have to checkout Cubits, it’s on my list. Maybe it will solve the problem
  • It was my first unit test in Flutter (and Dart) so I hope it’s not a ‘false-positive’

Thanks!

Mariusz Brona

Dodaj komentarz

Twój adres email nie zostanie opublikowany. Pola, których wypełnienie jest wymagane, są oznaczone symbolem *