Welcome to another article for my Custom Widgets Series. In this series I share some tips and code about creating reusable and advanced flutter widgets. These widgets are used by me and many professionals out there in production level flutter apps. Now they are available to you!
Table of Contents
Introduction
Are you tired of dealing with the complexities of form fields in Flutter? Do you wish there was a simpler way to handle form validation, error styling, and value changes without writing tons of boilerplate code or messing with the Form API? I designed my own custom widget to make form handling in Flutter a breeze.
I recently shared some screenshots of forms from my Famony app on X(twitter).
These forms contained validations for different field types like dropdowns, textfields, date picker, gender cards etc. You can see in the image below:
Many people inboxed me asking how I was able to do that since not every widget has validations and it gets very messy to sync them all.
CustomFormField ✨
Introducing the CustomFormField widget, wrapper around FormField
widget that takes in validation
, onSaved
, onChanged
callbacks etc.
With CustomFormField
, you can easily manage form validation, error display, and value changes with minimal effort. No more struggling with the default FormField API to achieve basic functionality. It provides a clean and intuitive way to handle form fields, allowing you to focus on building great user experiences.
It has built-in support for validation errors which you can style as you want. Just wrap any child to add validation without worrying about form reactive logic. You can see the it in the example below.
CustomValueListener
The CustomFormField
needs a way to keep the form in sync and allow updating it from anywhere using a controller. Since the field can be of dynamic nature, the most suitable option for a controller was a ValueNotifier<T>
, where <T>
is the type you pass to your CustomFormField<T>
. In order to listen to this custom controller we needed a custom listener widget as well.
The CustomValueListener
widget is essential for efficiently managing state changes in the CustomFormField
. It listens to a ValueNotifier
and triggers updates whenever the value changes, ensuring that the form field's state is kept in sync. This separation of concerns simplifies the code, making it cleaner and more maintainable. Additionally, it handles the lifecycle of the listener, adding it during initialization and removing it upon disposal, which prevents memory leaks and ensures optimal performance. This approach allows for a more responsive and dynamic form handling experience in Flutter.
Code
Here is the crux of it all, the code for this amazing widget. I decided to place it at the end to avoid confusion, because it was important to see the usage and benefits before the implementation. I have included it as gist so I can update it in the future, if needed.
import 'package:flutter/material.dart'; | |
// Helpers | |
import '../../helpers/constants/constants.dart'; | |
// Widgets | |
import 'custom_value_listener.dart'; | |
typedef OnChangedCallback<T> = void Function(T?); | |
typedef OnValidateCallback<T> = String? Function(T?)?; | |
typedef OnSavedCallback<T> = void Function(T?)?; | |
class CustomFormErrorStyle { | |
/// The alignment of the error text. | |
final Alignment errorAlign; | |
/// The flag used to enable/disable error border. | |
final bool showErrorBorder; | |
/// The flag used to enable/disable focused border. | |
final bool showErrorMessage; | |
/// The style used for displaying error text. | |
final TextStyle? textStyle; | |
const CustomFormErrorStyle({ | |
this.errorAlign = Alignment.centerRight, | |
this.showErrorBorder = false, | |
this.showErrorMessage = true, | |
this.textStyle = const TextStyle( | |
fontSize: 14, | |
color: AppColors.redColor, | |
), | |
}); | |
} | |
/// [T] is any type of value that is passed to the [onSaved], [onChanged], | |
/// and [validator] callback. | |
class CustomFormField<T> extends StatelessWidget { | |
/// An optional method to call with the final value when the | |
/// form is saved via [FormState.save]. | |
final OnSavedCallback<T> onSaved; | |
/// An optional method that validates an input. Returns an error | |
/// string to display if the input is invalid, or null otherwise. | |
final OnValidateCallback<T> validator; | |
/// An optional method to call with the value when the | |
/// field is changed | |
final OnChangedCallback<T>? onChanged; | |
final ValueNotifier<T> controller; | |
/// The style used for displaying error. | |
final CustomFormErrorStyle errorStyle; | |
/// The width of the error text. | |
final double? width; | |
/// The function used to build the widget | |
final FormFieldBuilder<T> builder; | |
const CustomFormField({ | |
required this.controller, | |
required this.builder, | |
super.key, | |
this.onSaved, | |
this.width, | |
this.onChanged, | |
this.validator, | |
this.errorStyle = const CustomFormErrorStyle(), | |
}); | |
@override | |
Widget build(BuildContext context) { | |
return FormField<T>( | |
initialValue: controller.value, | |
validator: validator, | |
onSaved: onSaved, | |
builder: (state) { | |
return Column( | |
children: [ | |
// Display field | |
CustomValueListener( | |
controller: controller, | |
onChanged: () { | |
state | |
..didChange(controller.value) | |
..validate(); | |
onChanged?.call(controller.value); | |
}, | |
child: builder(state), | |
), | |
// Error text | |
if (state.hasError && errorStyle.showErrorMessage) ...[ | |
Insets.gapH3, | |
SizedBox( | |
width: width, | |
child: Align( | |
alignment: errorStyle.errorAlign, | |
child: Text( | |
state.errorText!, | |
style: errorStyle.textStyle, | |
), | |
), | |
), | |
], | |
], | |
); | |
}, | |
); | |
} | |
} |
class CustomValueListener<T> extends StatefulWidget { | |
final Widget child; | |
final ValueNotifier<T> controller; | |
final VoidCallback onChanged; | |
const CustomValueListener({ | |
required this.child, | |
required this.controller, | |
required this.onChanged, | |
super.key, | |
}); | |
@override | |
_CustomValueListenerState createState() => _CustomValueListenerState(); | |
} | |
class _CustomValueListenerState extends State<CustomValueListener> { | |
@override | |
void initState() { | |
super.initState(); | |
widget.controller.addListener(widget.onChanged); | |
} | |
@override | |
void didUpdateWidget(covariant CustomValueListener oldWidget) { | |
super.didUpdateWidget(oldWidget); | |
if (widget.controller != oldWidget.controller) { | |
oldWidget.controller.removeListener(widget.onChanged); | |
widget.controller.addListener(widget.onChanged); | |
} | |
} | |
@override | |
void dispose() { | |
widget.controller.removeListener(widget.onChanged); | |
super.dispose(); | |
} | |
@override | |
Widget build(BuildContext context) { | |
return widget.child; | |
} | |
} |
// 1. wrap any child | |
CustomFormField<T?>( | |
controller: _controller!, | |
width: width, | |
validator: validator, | |
onSaved: onSaved, | |
onChanged: onChanged, | |
errorStyle: errorStyle, | |
builder: _builder, | |
) | |
// 2. Wrap multiple formfields in a form widget | |
Form( | |
key: formKey, | |
child: Column( | |
children: [ | |
CustomFormField<int>(...), | |
CustomFormField<DateTime>(...), | |
CustomFormField<String>(...), | |
] | |
), | |
) | |
// 3. Run all form fields' validators | |
if (!formKey.currentState!.validate()) return; | |
... |
Credits
If you like it, please keep following me as I have around 40 such widgets and I will post many more of these.
Top comments (0)