Your own validation mode
When none of the four modes says what your form needs, write the rule as a class. What the rule sees, what it answers, what it never sees, and what still holds.
The four modes cover most forms: validate on submit, on every keystroke, when a field is left, or live once it was
first left. When a form needs a rule none of them states, extend CustomValidationMode, override decide, and the
package treats the result exactly like a built-in mode: set it on the form, it reaches every field and subform; set it
on one field, that field keeps it.
// The e-mail syntax on every keystroke, the server only when the field is left.
final class LiveSyntaxServerOnLeave extends CustomValidationMode {
const LiveSyntaxServerOnLeave();
@override
ValidationAction decide(ValidationContext c) =>
c.event == ValidationEvent.unfocus ? ValidationAction.full : ValidationAction.sync;
}
final email = AdvancedTextFieldController(
validator: emailSyntax('Not an e-mail address'), // sync: runs on every keystroke
asyncValidation: AsyncValidation(validator: isTaken), // async: runs only when the field is left
)..setValidationMode(const LiveSyntaxServerOnLeave());An e-mail field with onUserInteraction would ask the server after every pause in typing; with onUnfocus the user
would not see "not an e-mail address" until they left the field. This rule gives each check its own moment.
Reach for a built-in mode when one fits. A custom mode covers everything else, from a one-line tweak of a built-in to a rule that reads the field's past.
What the rule sees
decide gets a ValidationContext: what is happening now, and three facts about what happened before.
| Field | Meaning |
|---|---|
event | What is happening: valueChanged (the user edited the value), unfocus (focus left the field) or dependencyChanged (a field this one depends on changed) |
everUnfocused | The user has left this field after editing it, at least once since reset() |
everValidated | validate() ran on this field at least once since reset(). A form's validate() counts for each of its fields |
lastStatus | How the last finished check ended: valid, invalid or failedValidation. Null until a check finishes; clearErrors(), prefill() and reset() drop it |
The context never carries the live status or the errors. Every edit clears the errors and a debounced async check sets
the status to pending, so a rule that asked "does this field have an error right now" would stop validating at the
exact moment the user started fixing the value. lastStatus does not have that problem: an edit keeps it, a round in
flight does not touch it, and only a check that finishes replaces it. Do not capture the field in the class to read
its state from there: that is the same trap with extra steps.
The four built-in modes are decide methods like yours. afterFirstUnfocus, for example, delegates to
onUserInteraction once c.everUnfocused is true and to onUnfocus before.
What the rule answers
decide returns a ValidationAction, which is what the field runs:
| Action | What runs |
|---|---|
none | Nothing. An edit still clears the errors, because they described the old value |
sync | The sync validator only |
full | The sync validator, then the async one if sync passed. After an edit the async check waits out its debounce; after an unfocus it runs at once |
The built-in modes answer sync to a dependency change, because the field's own value did not change and its async
verdict still stands. Your rule may answer full there if it needs to.
What still holds
A custom mode changes which events validate a field and how much runs. It changes nothing else:
- An untouched field validates nothing on its own. The field checks that before it asks any mode, so a rule that
always answers
fullstill cannot reach a field the user has not edited.validate()is what checks those. validate()ignores the mode and never changes it, custom or not. It does seteverValidated.- Sync runs before async, and async only if sync passed. There is no action that skips the sync validator.
- The mode reaches the whole tree. Set on the form, it is broadcast to every field and subform, including ones registered or attached later. A field or subform with its own mode keeps it, as with the built-in modes (see Configuring the mode).
- Switching validation off still beats every mode:
setValidationEnabled(false)makes the tree behave asmanual.
Identity
Modes compare by type: two OnOwnEdits() are the same mode, so one created in build() is not a mode change and
leaves a round in flight alone. A mode with settings lists them in props, so two instances with different settings
are different modes:
final class LiveOn extends CustomValidationMode {
const LiveOn(this.events);
final Set<ValidationEvent> events;
@override
ValidationAction decide(ValidationContext c) =>
events.contains(c.event) ? ValidationAction.full : ValidationAction.none;
@override
List<Object?> get props => [events];
}One instance of a mode is shared by every field it reaches, so a mode keeps no state of its own. Everything it knows about a field is in the context.
Examples
Validate only when the field is left, and ignore changes in the fields it depends on until then. A "to" date that must come after "from": while the user is still picking "from", every change would flash "must be after the start date" on a "to" they have not come back to yet:
final class OnUnfocusOnly extends CustomValidationMode {
const OnUnfocusOnly();
@override
ValidationAction decide(ValidationContext c) =>
c.event == ValidationEvent.unfocus ? ValidationAction.full : ValidationAction.none;
}
to
..setValidationMode(const OnUnfocusOnly())
..subscribeToFields([from]);Quiet while typing until a check finds an error, live while the user fixes it, quiet again once a check passes:
final class LiveWhileInvalid extends CustomValidationMode {
const LiveWhileInvalid();
@override
ValidationAction decide(ValidationContext c) => switch (c.event) {
ValidationEvent.valueChanged => c.lastStatus == FieldStatus.invalid
? ValidationAction.full
: ValidationAction.none,
ValidationEvent.unfocus => ValidationAction.full,
ValidationEvent.dependencyChanged => ValidationAction.sync,
};
}Quiet until the first submit, live after it:
final class LiveAfterSubmit extends CustomValidationMode {
const LiveAfterSubmit();
@override
ValidationAction decide(ValidationContext c) =>
c.everValidated ? ValidationMode.onUserInteraction.decide(c) : ValidationAction.none;
}Try it
One e-mail field. The sync validator checks the syntax, the async one asks a (fake) server whether the address is
taken: anything starting with taken is. Switch between the two built-in modes and the rule above, type, and watch
the status pill: with onUserInteraction the server runs on every keystroke, with onUnfocus nothing runs until you
leave, with the rule the syntax runs live and the server only when you leave.
Source2 files
class EmailFormController extends AdvancedFormController {
EmailFormController() {
registerFields([email]);
}
final email = AdvancedTextFieldController(
validator: (value) =>
_syntax.hasMatch(value) ? null : 'Not an e-mail address',
asyncValidation: const AsyncValidation(validator: _isTaken),
);
static final _syntax = RegExp(r'^[^@\s]+@[^@\s]+\.[^@\s]+$');
// Stands in for the server: anything starting with "taken" is taken.
static Future<String?> _isTaken(String value) async {
await Future<void>.delayed(const Duration(milliseconds: 600));
return value.startsWith('taken') ? 'Already taken' : null;
}
}DocsActions, DocsFieldStatus, DocsHint, DocsModePicker, DocsSubmitButton and DocsTextField are shorthands these docs define, not part of the package. See Rendering fields for the widget code an app writes.Testing a rule
decide is a plain method, so test it without a form:
test('LiveSyntaxServerOnLeave keeps the server for the unfocus', () {
const typing = ValidationContext(event: ValidationEvent.valueChanged);
const leaving = ValidationContext(event: ValidationEvent.unfocus);
expect(const LiveSyntaxServerOnLeave().decide(typing), ValidationAction.sync);
expect(const LiveSyntaxServerOnLeave().decide(leaving), ValidationAction.full);
});To test it through a field, edit the field first (setValue), then drive the event: setValue again for
valueChanged, handleUnfocus() for unfocus, revalidateSync() for dependencyChanged. See
Testing.