
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:
- We have one abstract State class and abstract Event class
- In Bloc class, the mapEventToState method is overrode, where most of blogposts have if-else conditions to yield different States
- 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!