json_form_engine
JSON-driven dynamic form builder for Flutter. Generate complete, validated,
conditional, multi-step forms from JSON — with a powerful controller API,
50+ field types, built-in image/camera/file pickers, theming, localization
(en/hi/ar/es/fr/de + custom), and no third-party dependencies (only
Flutter's official image_picker / file_selector plugins).
Why
- Server-driven UI — change forms without shipping an app update.
- One controller for everything — values, validation, focus, visibility, runtime add/remove of fields.
- Performance-first — every field listens to its own
ValueNotifiers; typing in one field never rebuilds another. Fields render lazily viaListView.builder, so 1000+ field forms stay smooth. - Extensible by design — every renderer, validator and field type can be overridden or added at runtime (Factory + Strategy patterns).
Quick start
import 'package:json_form_engine/json_form_engine.dart';
final controller = DynamicFormController();
DynamicForm(
controller: controller,
json: const {
'id': 'registration',
'fields': [
{'type': 'text', 'id': 'name', 'label': 'Full Name',
'validators': ['required']},
{'type': 'email', 'id': 'email', 'label': 'Email',
'validators': ['required', 'email']},
{'type': 'dropdown', 'id': 'country', 'label': 'Country',
'items': [
{'label': 'India', 'value': 'IN'},
{'label': 'USA', 'value': 'US'},
]},
],
},
showSubmitButton: true,
onSubmit: (data) => print(data),
onChanged: (data) => print(data),
)
json accepts a JSON string or a decoded map — local asset, remote
API response, or built at runtime. Passing a new value re-parses the form and
preserves values of surviving field ids (runtime JSON changes).
Field types
| Category | Types |
|---|---|
| Text | text, textarea, password, email, number, decimal, phone, url, search, otp, pin, readOnly |
| Date & time | date, time, datetime (stored as ISO-8601 strings) |
| Selection | dropdown, multiselect, checkbox, checkboxGroup, radio, radioGroup, switch, chips, toggleButtons, segmented, country, state, city, autocomplete, typeahead |
| Numeric | slider, rangeSlider, rating, stepper |
| Misc | colorPicker, hidden, label, divider, spacer, sectionHeader, expansion, group, custom |
| Media (built-in) | image, camera, file |
| Pluggable | signature, qrScanner, barcodeScanner, richText, markdown, htmlEditor |
Pluggable types keep the core dependency-free: the JSON schema, controller and validation all work out of the box, and you register the widget backed by the plugin of your choice once at startup:
FieldFactory.register(FieldType.signature, (context, field, controller) {
return MySignaturePad(
onDone: (bytes) => controller.setValue(field.id, bytes),
);
});
Custom JSON types work the same way:
FieldFactory.registerCustom('map_picker', (context, field, controller) => ...);
// JSON: {"type": "custom", "id": "loc", "customType": "map_picker"}
Media fields: image, camera & file (built-in)
Gallery/camera image picking (with thumbnail previews) and document picking work straight from JSON — no adapter registration needed:
{"fields": [
{"type": "image", "id": "profilePhoto", "label": "Profile Photo",
"source": "both", "imageQuality": 80},
{"type": "image", "id": "gallery", "label": "Photos",
"source": "gallery", "multiple": true, "maxImages": 4},
{"type": "camera", "id": "selfie", "label": "Selfie",
"preferredCamera": "front"},
{"type": "file", "id": "resume", "label": "Resume",
"extensions": ["pdf", "docx"], "validators": ["required"]}
]}
image—source:"gallery","camera"or"both"(default; shows a Gallery/Camera bottom sheet). Extras:multiple,maxImages,imageQuality(0–100),maxWidth/maxHeight,preferredCamera("front"/"rear"),video: true(pick/record a video,maxDurationSeconds),previewSize.camera— camera-only shorthand forimage.file— extras:multiple,maxFiles,extensions(e.g.["pdf", "docx"]),mimeTypes.
Stored value: the picked file path as a String (a List<String>
with "multiple": true) — read it with controller.getValue(id) and upload
however you like. required validation, conditions, theming and edit mode
all work like any other field.
Powered by Flutter's official image_picker and file_selector plugins.
Platform setup (only what those plugins need):
- iOS — add to
ios/Runner/Info.plist:NSPhotoLibraryUsageDescription,NSCameraUsageDescriptionand (for video)NSMicrophoneUsageDescription. - Android — nothing; the photo picker and camera intents need no manifest permissions.
Want a different picking flow (cropper, custom permission UX)? Swap the service — the field UI stays:
MediaPickerAdapter.instance = MyCroppingPickerAdapter();
…or replace the whole widget via FieldFactory.register(FieldType.image, ...).
Common field properties
id, key, name, label, hint, helperText, initialValue,
defaultValue, required, enabled, readOnly, visible, validators,
maxLength, minLength, regex, prefixIcon, suffixIcon,
keyboardType, textInputAction, autofocus, obscureText, padding,
margin, width, height — plus type-specific extras (min, max,
divisions, rows, length, multiple, colors, expanded, …) available
via field.ex<T>('name').
Controller
final controller = DynamicFormController(locale: 'hi');
controller.getValue('name');
controller.setValue('name', 'John');
controller.clearField('name');
controller.reset();
controller.validate(); // bool
controller.submit(); // Map? (null when invalid)
controller.getFormData();
controller.setFormData({'name': 'A'});
controller.addField(FieldConfig.fromJson({...}), index: 2);
controller.removeField('name');
controller.hideField('state'); controller.showField('state');
controller.enableField('x'); controller.disableField('x');
controller.focusField('email'); controller.unfocus();
controller.getErrors(); controller.clearErrors();
final cancel = controller.listen('name', (value) => ...); cancel();
controller.dispose();
Typed helpers: getString, getInt, getDouble, getBool, getList,
hasErrors. Dirty state: isDirty, dirty (ValueListenable),
markClean().
Validation (JSON-configurable)
{
"type": "password", "id": "password",
"validators": [
"required",
{"type": "passwordStrength"},
{"type": "minLength", "value": 8, "message": "Too short"}
]
}
Built-in: required, email, phone, url, number, decimal, min,
max, minLength, maxLength, regex, matchField, passwordStrength.
Custom validators, usable from JSON by name:
ValidatorRegistry.register('gstin', (cfg) => CustomValidator(cfg,
(value, formData) => isGstin('$value') ? null : 'Invalid GSTIN'));
Conditional logic
{"type": "dropdown", "id": "state",
"visibleWhen": {"field": "country", "operator": "equals", "value": "IN"}}
{"type": "text", "id": "license",
"enabledWhen": {"field": "age", "operator": "greaterThanOrEqual", "value": 18}}
{"type": "text", "id": "gst",
"requiredWhen": {"and": [
{"field": "country", "operator": "equals", "value": "IN"},
{"field": "business", "operator": "equals", "value": true}
]}}
Operators: equals, notEquals, greaterThan, greaterThanOrEqual,
lessThan, lessThanOrEqual, contains, startsWith, endsWith,
isEmpty, isNotEmpty, in — composable with and, or, not.
Dynamic / async options
DynamicFormController(
optionsLoader: (fieldId, formData) async {
final res = await api.get('/options/$fieldId?country=${formData['country']}');
return [for (final o in res) OptionItem(label: o['name'], value: o['id'])];
},
)
Fields opt in with "optionsUrl": "..." (or empty options on selection
types). Add "dependsOn": ["country"] to clear + reload when a parent field
changes — the classic country → state → city chain.
Multi-step / wizard forms
Use steps instead of fields; each step validates before advancing:
{"steps": [
{"title": "Account", "fields": [ ... ]},
{"title": "Profile", "fields": [ ... ]}
]}
Nested sections: {"type": "expansion", "fields": [...]} and
{"type": "group", "fields": [...]} — children keep flat ids, so the whole
controller API works on them.
Field UI styles (JSON-driven)
Five built-in input looks, switchable from JSON at three levels — app theme default, form root, and per field (most specific wins):
{
"style": {"variant": "rounded", "borderColor": "#3F51B5"},
"fields": [
{"type": "text", "id": "name", "label": "Name"},
{"type": "email", "id": "email",
"style": {"variant": "filled", "fillColor": "#EEF0FB",
"textStyle": {"fontSize": 16, "fontWeight": "w600"}}}
]
}
Variants: outlined, rounded (pill), filled, underline, none.
Per-style knobs: borderRadius, fillColor, borderColor,
focusedBorderColor, borderWidth, dense, contentPadding,
labelBehavior (auto/always/never), textStyle / labelStyle /
hintStyle (fontSize, color, fontWeight, italic, letterSpacing).
Colors are #RRGGBB / #AARRGGBB. The field-level key can be style or
decoration — both are accepted. App-wide default:
DynamicFormThemeData(defaultFieldStyle: ...). Because style lives in the
JSON, changing it at runtime restyles the form while preserving values.
Edit mode (prefilled forms)
Open the same JSON form prefilled with an existing record — three ways:
// 1. From code — wins over everything:
DynamicForm(controller: c, json: formJson,
initialData: {'name': 'Rupesh', 'country': 'IN'})
// 2. From the JSON itself — server ships definition + record together:
// {"fields": [...], "data": {"name": "Rupesh", "country": "IN"}}
// 3. Programmatically, after an async fetch:
controller.setFormData(record, asInitial: true);
Edit-mode semantics are handled correctly everywhere:
- The prefilled form starts clean — the discard guard only triggers after the user actually edits something.
- Typing a field back to its record value makes the form clean again (deep-compared baseline).
controller.reset()restores the record, not the field defaults.- Runtime JSON changes preserve both the values and the dirty baseline.
- Passing a new
initialDatamap instance re-prefills at runtime (switching the edit target).
Dirty tracking & discard guard
The controller tracks unsaved changes (deep-compared against the last clean
baseline): controller.isDirty, the listenable controller.dirty, and
controller.markClean() (called automatically on attach, reset() and a
successful submit()).
Enable the back-navigation guard from JSON — when the user edited something and navigates back, a confirmation dialog asks before discarding:
{
"confirmDiscard": true,
"discardTitle": "Discard changes?",
"discardMessage": "You have unsaved changes.",
"fields": [ ... ]
}
"confirmDiscard": false(or omitted) disables it — fully dynamic.- Code override wins:
DynamicForm(confirmDiscard: true/false, ...). - Title/message fall back to the localized defaults (all 6 locales).
- Fully custom dialog:
DynamicFormThemeData(discardDialogBuilder: (context) async => ...)— returntrueto leave,false/nullto stay.
Theming
DynamicFormTheme(
data: DynamicFormThemeData(
fieldSpacing: 20,
decorationBuilder: (context, field, decoration) =>
decoration.copyWith(border: const OutlineInputBorder()),
errorBuilder: (context, message) => MyErrorBanner(message),
loadingBuilder: (context) => const MySpinner(),
),
child: DynamicForm(...),
)
Material 3 by default; the form inherits your app ThemeData
(light/dark/high-contrast follow automatically). Every renderer can be
replaced wholesale via FieldFactory.register.
Localization
Built-in message locales: en, hi, ar (RTL), es, fr, de.
DynamicFormController(locale: 'ar');
FormLocalizations.addTranslations('ta', {'required': 'இது தேவை'});
State management
The package is self-contained (plain ChangeNotifier + ValueNotifier), so
it plugs into anything:
// Provider
ChangeNotifierProvider(create: (_) => DynamicFormController());
// Riverpod
final formControllerProvider =
Provider.autoDispose((ref) {
final c = DynamicFormController();
ref.onDispose(c.dispose);
return c;
});
// Bloc — forward changes into your bloc
controller.onChanged = (id, value, data) =>
context.read<FormBloc>().add(FormFieldChanged(id, value));
// GetX
class FormCtrl extends GetxController {
final form = DynamicFormController();
@override void onClose() { form.dispose(); super.onClose(); }
}
Accessibility
Semantic labels from JSON labels, full keyboard navigation
(textInputAction: next + focus API), screen-reader friendly Material
widgets, and high-contrast support via your app theme.
Example
See example/lib/main.dart for a complete
registration demo: conditional state dropdown fed by a simulated API,
age-gated license field, chips, sliders, rating, color picker, image /
camera / file pickers and multi-rule validation.
License
MIT © Rupesh Rajak
Libraries
- json_form_engine
- JSON-driven dynamic form builder for Flutter.