Changing focus does not validate the control
Hi in the FormGroup, I have two control when the user change the value, it does not validate until moving focus multiple time to other widget, then the error will be shown.
'email': FormControl<String>(
value: '',
validators: [
Validators.email,
],
),
'password': FormControl<String>(
value: '',
validators: [
Validators.required,
],
),
here in the video I tabbed multiple time to move focus around, as you see this cause the login button to be disabled because there is error but in the text field does not show.
https://user-images.githubusercontent.com/11982812/180612904-08bc65de-aab4-41da-a1f8-3623c655639d.mov
is there is anything I can do validate automatically when focus changes. in the docs said changing focus or completing the text will trigger validation.
I tried to use FocusNode for each text field and markAsTouched and that's work but I think the library it should do that ?
Thanks
hi @rebaz94
Check login form sample in example
when you run the example - it works perfectly without any delays in validation
https://github.com/joanpablo/reactive_forms/blob/master/example/lib/samples/login_sample.dart
If you still have issues provide a repository with reproduction code
@vasilich6107 I tried multiple time and the code is same and error not shown, it work when you change focus multiple times. In my case I tested on mac, maybe that's the problem?
Hi @rebaz94,
Thank you for using Reactive Forms and for the issue.
BTW your UI in the sample video is really nice ;)
In order to be able to help you, would you mind sharing with us a portion of your code, or any other code that allows us to understand: 1-How are you creating the FormGroup? 2-Are you using any State Management library?
Hi @joanpablo Thank you :).
1-How are you creating the FormGroup?
Normally I just create from StateNotifier
2-Are you using any State Management library?
I use Riverpod but does not do any special things, just get FormGroup
I will create the FormGroup like this
FormGroup(
{
'name': FormControl<String>(
value: '',
validators: [
Validators.required,
],
),
'email': FormControl<String>(
value: '',
validators: [
Validators.required,
Validators.email,
],
),
'password': FormControl<String>(
value: '',
validators: [
Validators.required,
],
),
},
);
and custom text field widget
class LoginField extends StatelessWidget {
const LoginField({
Key? key,
required this.controllerName,
this.validationMessages,
this.onTap,
this.focusNode,
this.hint,
this.padding = const EdgeInsets.only(top: 10.0),
required this.prefixIcon,
}) : super(key: key);
final String controllerName;
final Map<String, String>? validationMessages;
final VoidCallback? onTap;
final FocusNode? focusNode;
final String? hint;
final EdgeInsetsGeometry padding;
final Widget prefixIcon;
@override
Widget build(BuildContext context) {
return Padding(
padding: padding,
child: ReactiveTextField(
formControlName: controllerName,
validationMessages: (_) => validationMessages ?? {},
focusNode: focusNode,
onTap: onTap,
style: styles.loginFieldStyle,
maxLines: 1,
textInputAction: TextInputAction.next,
decoration: InputDecoration(
isCollapsed: true,
contentPadding: const EdgeInsetsDirectional.only(start: 12.0, end: 12.0, top: 16.0, bottom: 16.0),
errorStyle: const TextStyle(height: 1.3),
errorMaxLines: 2,
),
),
);
}
}
and usage for the LoginField
LoginField(
controllerName: 'email',
hint: '[email protected]',
prefixIcon: Icon(
FontAwesomeIcons.at,
color: Colors.grey.withOpacity(0.45),
size: 16.0,
),
),
and SwiftyFormBuilder. you can ignore this, it just helper widget, basically return ReactiveForm
class SwiftyFormBuilder<N extends BaseStateNotifier<M, A>, A extends Object, M> extends ConsumerWidget {
const SwiftyFormBuilder({
Key? key,
required this.provider,
this.onSetupArgs = defaultOnSetupArgs,
required this.formBuilder,
required this.errorBuilder,
this.loadingBuilder = defaultLoadingBuilder,
this.formSelector = defaultFormSelector,
this.onChange,
this.ignoreDataAndBuildByState = false,
}) : super(key: key);
static Widget defaultLoadingBuilder(BuildContext context, WidgetRef ref, _) {
return const Center(child: CircularLoadingIndicator());
}
static FormGroup defaultFormSelector<M>(/*M*/ dynamic result) {
return result as FormGroup;
}
static void defaultOnSetupArgs(BaseStateNotifier notifier, WidgetRef ref) {}
final StateNotifierProviderOverrideMixin<StateNotifier<Result<M>>, Result<M>> provider;
final void Function(N notifier, WidgetRef ref) onSetupArgs;
/// This is indicate that the state of form build by state
/// for example if [ignoreDataAndBuildByState] is true and form has previous state, it will ignore it
final bool ignoreDataAndBuildByState;
final Widget Function(
BuildContext context,
WidgetRef ref,
N notifier,
M formInfo,
) formBuilder;
final Widget Function(
BuildContext context,
WidgetRef ref,
N notifier,
) loadingBuilder;
final Widget Function(
BuildContext context,
WidgetRef ref,
N notifier,
String message,
) errorBuilder;
final void Function(BuildContext context, Result<M> value)? onChange;
final FormGroup Function(M data) formSelector;
BaseStateNotifier<M, Object> notifier(WidgetRef ref) {
return ref.read(provider.notifier) as BaseStateNotifier<M, Object>;
}
SwiftyForm<M, Object> swiftyForm(WidgetRef ref) {
return ref.read(provider.notifier) as SwiftyForm<M, Object>;
}
@override
Widget build(BuildContext context, WidgetRef ref) {
final notifier = (ref.watch(provider.notifier) as N);
onSetupArgs(notifier, ref);
final state = ref.watch(provider);
final formKey = ValueKey(notifier.formKey);
final content = state.maybeWhen(
(data, _) {
return ReactiveForm(
key: formKey,
formGroup: formSelector(data),
child: formBuilder(context, ref, notifier, data),
);
},
loading: (dataOrNull) {
if (dataOrNull == null || ignoreDataAndBuildByState) {
return loadingBuilder(context, ref, notifier);
}
return ReactiveForm(
key: formKey,
formGroup: formSelector(dataOrNull),
child: formBuilder(context, ref, notifier, dataOrNull),
);
},
orElse: () {
final dataOrNull = state.dataOrNull;
if (dataOrNull == null || ignoreDataAndBuildByState) {
return errorBuilder(context, ref, notifier, errorMessage(state));
}
return ReactiveForm(
key: formKey,
formGroup: formSelector(dataOrNull),
child: formBuilder(context, ref, notifier, dataOrNull),
);
},
);
if (onChange != null) {
ref.listen<Result<M>>(provider, (_, value) {
onChange!.call(context, value);
});
}
return content;
}
String errorMessage(Result<M> state) {
return state.maybeWhen(
(data, _) => 'Failed',
noInternet: () => 'No Internet',
orElse: () => 'Failed to load data, please try again',
);
}
}
This is why I'm always asking to reproduction repo) The local code utilization could have many things that we can't imagine. So instead of trying to guess I prefer to save time for both of us)
@rebaz94 could you try to run example project with login form. It should work fine despite of OS
This is why I'm always asking to reproduction repo) The local code utilization could have many things that we can't imagine. So instead of trying to guess I prefer to save time for both of us)
@vasilich6107 I put all the code here that I used, there is nothing else to customize or anything I will test the example as you said and let you know.
@vasilich6107 @joanpablo
founded that if I use IndexedStack and maintainState is true and share a form like what I did then the form focus will not work properly, so quick fix is to maintainState: false.
return IndexedStack(
index: currentTab,
children: [
Visibility(
visible: currentTab == 0,
maintainState: false,
child: LoginTab(),
),
Visibility(
visible: currentTab == 1,
maintainState: false,
child: RegisterTab(),
),
],
);
is there is any workaround to use one FormGroup in multi places? if not, its time to close the issue :D
Thanks
Hi @rebaz94,
Yes, you can use FormGroup in multiple places, that is not the issue.
If you ask me, the code you are sharing is unnecessarily complex.
You are creating several ReactiveForm based on conditions, instead of creating just one ReactiveForm.
You are not giving us context about how you are creating the FormGroup. You just copy/paste the definition but not the context of that definition: is it inside a StatefulWidget or StatelessWidget? Is it inside a Controller? Are you creating several FormGroup based on conditions?
Definitely, the complexity of your implementation is giving you some issues. We would like to help you to make your code works, but you will need to bring more context.
Thanks in advance.
@rebaz94 take notice that you must have only one instance of FormGroup, the FormGroup is your model, it does not matter how many times you rebuild the UI, but you must not create/destroy repeatedly the FormGroup. If you do that then you are destroying the data of your model, resetting the data, and all the status of the FormGroup.
That's why we always recommend using a State Management Library and declaring the FormGroup inside the Controller/Bloc/ViewModel or if you are declaring the FormGroup inside a Widget it should be a StatefulWidget or use the ReactiveFormBuilder.
Are you sure you are not creating/destroying the FormGroup repeatedly?
@joanpablo I'm using Riverpod to manage state and only create one instance of FormGroup and multiple ReactiveForm. the problem happen when sharing a FormGroup in the widget tree and if you have two active ReactiveForm
here reproduction code
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:reactive_forms/reactive_forms.dart';
void main() {
runApp(
MaterialApp(
theme: ThemeData.dark(),
themeMode: ThemeMode.dark,
home: ProviderScope(
child: LoginScreenTest(),
),
),
);
}
class FormNotifier extends StateNotifier<FormGroup> {
FormNotifier()
: super(
FormGroup(
{
'name': FormControl(
value: '',
validators: [
Validators.required,
],
),
'email': FormControl(
value: '',
validators: [
Validators.required,
Validators.email,
],
),
'password': FormControl(
value: '',
validators: [
Validators.required,
Validators.maxLength(8),
],
),
},
),
);
static final provider = StateNotifierProvider.autoDispose<FormNotifier, FormGroup>((ref) {
return FormNotifier();
});
}
class LoginScreenTest extends StatelessWidget {
const LoginScreenTest({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: Center(
child: SizedBox(
width: 400,
child: Column(
children: [
TabBar(
tabs: [
Tab(text: 'Tab1'),
Tab(text: 'Tab2'),
],
),
const _ContentView(),
],
),
),
),
),
);
}
}
class _ContentView extends StatelessWidget {
const _ContentView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: DefaultTabController.of(context)!,
builder: (context, child) {
final index = DefaultTabController.of(context)!.index;
return IndexedStack(
index: index,
children: [
Visibility(
visible: index == 0,
maintainState: true,
child: FirstTab(),
),
Visibility(
visible: index == 1,
maintainState: true,
child: SecondTab(),
),
],
);
},
);
}
}
class FirstTab extends ConsumerWidget {
const FirstTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(FormNotifier.provider);
return ReactiveForm(
formGroup: form,
child: Column(
children: [
ReactiveTextField(
formControlName: 'email',
decoration: InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
ReactiveTextField(
formControlName: 'password',
decoration: InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
],
),
);
}
}
class SecondTab extends ConsumerWidget {
const SecondTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(FormNotifier.provider);
return ReactiveForm(
formGroup: form,
child: Column(
children: [
ReactiveTextField(
formControlName: 'name',
decoration: InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 10),
ReactiveTextField(
formControlName: 'email',
decoration: InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
ReactiveTextField(
formControlName: 'password',
decoration: InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
],
),
);
}
}
https://user-images.githubusercontent.com/11982812/180764472-23fd9dda-55d0-49fe-be7b-7d9d4ee6b337.mov
Hi @rebaz94,
Thanks for giving us all these details they are really useful. I will take a look at the code.
There is just one thing I can tell and this is that a control does not handle the focus on multiple widgets bound to it. In the same way only one control can focus at a time, a control can manages the focus of a control at a time, you can bind a control with multiple widgets but it will handle focus of the last registered recative widget.
I will take a look and see what is the real issue in the above sample code.
@rebaz94 In your use case (the first video SignIn and Signup) I advise you to have 2 different FormGroups, one for SignIn and another for SignUp. Anyway, I will try to figure out what is really happening to give you a better explanation.
The problem is the focus that does not trigger when widget invisible but exist in the widget tree. I don't think making two FormGroup solve the problem as the focus does not react to changes until full widget rebuilt.
Hi @rebaz94,
Yes, creating 2 separated FormGroup definitely solves the issue.
The problem is that a FormControl can only handle one FocusNode at a time (the last registered ReactiveTextField).
Your first ReactiveTextField (the sign-in) will show the error only when email control.invalid && control.touched but the email control will never be touched unless you touch your second ReactiveTextField (the sign-up). Or the screen is completely rebuilt and forced to register again a new FocusNode with the email control. In that case the control will start handling the sign-in text field.
I will try to find a solution in which a control can handle multiple FocusNodes at the same time but meanwhile use 2 different FormGroups, it doesn't matter if they are nested FormGroups but they must be 2.
Thank you for the help. I will try that
Another temporary solution, in case you still want to use the same FormGroup for both views, is to override the default showErrors() for the widgets and use for example control.invalid && control.dirty
That will show the error as soon as you interact with the ReactiveTextFeld (as soon as you start typing).
You can also (optionaly) combine this with another flag. For example, the first time the user enters the email and password you are not going to show errors, but when a user clicks on the button Sign-In then you set the flag to true and rebuild view:
So you override the the showErrors() for something like control.invalid && control.dirty && _submitAttempted
Please let me know which of the last 2 options I gave you was good to you @rebaz94
Hi @joanpablo I tried the same example above but creating 2 FormGroup. it will show the error as soon as focus change but at the same time if you change to second tab and email is invalid error will show immediately, it should not happen because its from other form group, also its not a good UX and really I don't want to show error as soon as focus change, only show error when form submitted and if form has error show error for invalid field and when became focus again or changed clear the error for that field (I don't know if it possible)
here the video using 2 form group
https://user-images.githubusercontent.com/11982812/183486972-5f3e10e6-78b0-4ec8-9b26-adc48f9fb5aa.mov
Hi @rebaz94 I will use your code and will reproduce your use case using 2 diferent form groups. The second tab should not show any error until you interact with it.
Remember also that you can use control.invalid && control.dirty && _submitAttempted
And also can use
control.invalid && control.dirty && control.touched
If the second view (sign-up) is showing the errors without a direct interaction of the user then it is because you are still using the same FormGroup for both views
If the second view (sign-up) is showing the errors without a direct interaction of the user then it is because you are still using the same FormGroup for both views
No, I tested with different FormGroup. Just use the code above and provide a list of form and in each tab use the form you want.
Providing showErrors for every field is not great as you need to maintain form submission state in order to show error or not..
Have you assigned a different Key() for each ReactiveTextField?
Have you assigned a different Key() for each ReactiveTextField?
It's same as before after providing Key for both ReactiveTextField and ReactiveForm,
also pressing tab does not change focus to password field!
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:reactive_forms/reactive_forms.dart';
void main() {
runApp(
MaterialApp(
theme: ThemeData.dark(),
themeMode: ThemeMode.dark,
home: const ProviderScope(
child: LoginScreenTest(),
),
),
);
}
class FormFields {
static String signIn = 'signIn';
static String signUp = 'signUp';
}
class FormNotifier extends StateNotifier<FormGroup> {
FormNotifier()
: super(
FormGroup(
{
FormFields.signUp: FormGroup({
'name': FormControl(
value: '',
validators: [
Validators.required,
],
),
'email': FormControl(
value: '',
validators: [
Validators.required,
Validators.email,
],
),
'password': FormControl(
value: '',
validators: [
Validators.required,
Validators.maxLength(8),
],
),
}),
FormFields.signIn: FormGroup({
'email': FormControl(
value: '',
validators: [
Validators.required,
Validators.email,
],
),
'password': FormControl(
value: '',
validators: [
Validators.required,
Validators.maxLength(8),
],
),
})
},
),
);
FormGroup get signInForm => state.control(FormFields.signIn) as FormGroup;
FormGroup get signUnForm => state.control(FormFields.signUp) as FormGroup;
static final provider =
StateNotifierProvider.autoDispose<FormNotifier, FormGroup>((ref) {
return FormNotifier();
});
}
class LoginScreenTest extends StatelessWidget {
const LoginScreenTest({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: Center(
child: SizedBox(
width: 400,
child: Column(
children: const [
TabBar(
tabs: [
Tab(text: 'Tab1'),
Tab(text: 'Tab2'),
],
),
_ContentView(),
],
),
),
),
),
);
}
}
class _ContentView extends StatelessWidget {
const _ContentView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: DefaultTabController.of(context)!,
builder: (context, child) {
final index = DefaultTabController.of(context)!.index;
return IndexedStack(
index: index,
children: [
Visibility(
visible: index == 0,
maintainState: true,
child: const FirstTab(),
),
Visibility(
visible: index == 1,
maintainState: true,
child: const SecondTab(),
),
],
);
},
);
}
}
class FirstTab extends ConsumerWidget {
const FirstTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(FormNotifier.provider);
return ReactiveForm(
formGroup: form.control(FormFields.signIn) as FormGroup,
child: Column(
children: [
ReactiveTextField(
key: const Key('sign-in-email'),
formControlName: 'email',
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
ReactiveTextField(
key: const Key('sign-in-password'),
formControlName: 'password',
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
],
),
);
}
}
class SecondTab extends ConsumerWidget {
const SecondTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(FormNotifier.provider);
return ReactiveForm(
formGroup: form.control(FormFields.signUp) as FormGroup,
child: Column(
children: [
ReactiveTextField(
key: const Key('sign-up-name'),
formControlName: 'name',
decoration: const InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 10),
ReactiveTextField(
key: const Key('sign-up-email'),
formControlName: 'email',
decoration: const InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
ReactiveTextField(
key: const Key('sign-up-password'),
formControlName: 'password',
decoration: const InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
],
),
);
}
}

Copied you code & test it on Mac still shows same problem
https://user-images.githubusercontent.com/11982812/183510952-6b72e05e-8a66-47bd-93db-40147bde3f6e.mov
This previous code I test it with Key
import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:reactive_forms/reactive_forms.dart';
class FormNotifier extends StateNotifier<List<FormGroup>> {
FormNotifier()
: super(
[
FormGroup(
{
'email': FormControl(
value: '',
validators: [
Validators.required,
Validators.email,
],
),
'password': FormControl(
value: '',
validators: [
Validators.required,
Validators.maxLength(8),
],
),
},
),
FormGroup(
{
'name': FormControl(
value: '',
validators: [
Validators.required,
],
),
'email': FormControl(
value: '',
validators: [
Validators.required,
Validators.email,
],
),
'password': FormControl(
value: '',
validators: [
Validators.required,
Validators.maxLength(8),
],
),
},
)
],
);
static final provider = StateNotifierProvider.autoDispose<FormNotifier, List<FormGroup>>((ref) {
return FormNotifier();
});
}
class LoginScreenTest extends StatelessWidget {
const LoginScreenTest({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return DefaultTabController(
length: 2,
child: Scaffold(
body: Center(
child: SizedBox(
width: 400,
child: Column(
children: [
TabBar(
tabs: [
Tab(text: 'Tab1'),
Tab(text: 'Tab2'),
],
),
const _ContentView(),
],
),
),
),
),
);
}
}
class _ContentView extends StatelessWidget {
const _ContentView({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return AnimatedBuilder(
animation: DefaultTabController.of(context)!,
builder: (context, child) {
final index = DefaultTabController.of(context)!.index;
return IndexedStack(
index: index,
children: [
Visibility(
visible: index == 0,
maintainState: true,
child: FirstTab(),
),
Visibility(
visible: index == 1,
maintainState: true,
child: SecondTab(),
),
],
);
},
);
}
}
class FirstTab extends ConsumerWidget {
const FirstTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(FormNotifier.provider).first;
return ReactiveForm(
formGroup: form,
child: Column(
children: [
ReactiveTextField(
key: ValueKey('t1email'),
formControlName: 'email',
decoration: InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
ReactiveTextField(
key: ValueKey('t1password'),
formControlName: 'password',
decoration: InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
],
),
);
}
}
class SecondTab extends ConsumerWidget {
const SecondTab({Key? key}) : super(key: key);
@override
Widget build(BuildContext context, WidgetRef ref) {
final form = ref.watch(FormNotifier.provider).last;
return ReactiveForm(
formGroup: form,
child: Column(
children: [
ReactiveTextField(
formControlName: 'name',
decoration: InputDecoration(labelText: 'Name'),
),
const SizedBox(height: 10),
ReactiveTextField(
key: ValueKey('t2email'),
formControlName: 'email',
decoration: InputDecoration(labelText: 'Email'),
),
const SizedBox(height: 10),
ReactiveTextField(
key: ValueKey('t2password'),
formControlName: 'password',
decoration: InputDecoration(labelText: 'Password'),
),
const SizedBox(height: 10),
],
),
);
}
}
I have tested on Linux and Web, and I don't believe it is a Platform issue. Can you copy/paste again your code here or share a GitHub project to download?
Ok let me check again your code
Your code is not the same that mine, please copy/paste mine and test it. anyway, I will copy/paste yours again and make the adjustments.
Your code is not the same that mine, please copy/paste mine and test it. anyway, I will copy/paste yours again and make the adjustments.
I said before, I am copied your code and test it on Desktop, the problem is same. the difference is not matter about creating FormGroup.
I tested on web and it has same problem