Annotate a data class, get a type-safe controller. Sync & async validation, cross-field rules, conditional visibility — no runtime reflection.
Zero runtime reflection. A typed controller is generated at compile time from your annotated data class.
Sync and async validators, cross-field rules, conditional visibility — all checked at compile time.
No UI opinions. You own every pixel. Wire any composable to a FieldScope and you're done.
Android, iOS, and WASM via Compose Multiplatform. One schema, every target.
Add the dependencies and the KSP plugin to your module's build.gradle.kts.
plugins {
id("com.google.devtools.ksp")
}
dependencies {
implementation("io.github.wassimbeltaief:formidable-core:2.0.0")
implementation("io.github.wassimbeltaief:formidable-compose:2.0.0")
ksp("io.github.wassimbeltaief:formidable-ksp:2.0.0")
}
Two steps: annotate a data class, wire the fields in Compose.
Annotate a plain data class with @FormSchema. KSP generates a
LoginFormController for you at build time.
@FormSchema
data class LoginForm(
@Field(label = "Email", hint = "you@example.com")
@Email("Please enter a valid email")
val email: String = "",
@Field(label = "Password", hint = "At least 8 characters")
@MinLength(8, "Password must be at least 8 characters")
val password: String = "",
)
Call StringField, BooleanField,
EnumField etc. inside Formidable {}.
Auto-rendering handles the UI — override with config = { } when you need it,
or pass a trailing lambda for full control.
@Composable
fun LoginScreen() {
val controller = remember { LoginFormController() }
val emailState by controller.email.collectAsState()
val passwordState by controller.password.collectAsState()
val isValid by controller.isValid.collectAsState()
Formidable {
StringField(
state = emailState,
onValueChange = { controller.updateEmail(it) },
onFocusLost = { controller.touchEmail() },
)
StringField(
state = passwordState,
onValueChange = { controller.updatePassword(it) },
onFocusLost = { controller.touchPassword() },
config = {
keyboardType = KeyboardType.Password
visualTransformation = PasswordVisualTransformation()
},
)
Button(onClick = { /* submit */ }, enabled = isValid) {
Text("Login")
}
}
}
Every field supports three call forms. Start with auto-render, reach for config overrides when needed, drop to headless for full control.
Renders a Material 3 OutlinedTextField (or the appropriate widget for the field type).
Wires label, hint, error message, and loading spinner — all from the field state. Zero boilerplate.
StringField(
state = emailState,
onValueChange = { controller.updateEmail(it) },
onFocusLost = { controller.touchEmail() },
)
BooleanField(
state = acceptTermsState,
onCheckedChange = { controller.updateAcceptTerms(it) },
onFocusLost = { controller.touchAcceptTerms() },
)
Still auto-rendered, but you override specific slots via a named
config = { } argument. The config must be a named argument —
trailing-lambda syntax is reserved for the headless API. Available slots:
style,
keyboardType,
visualTransformation,
supportingText,
trailingIcon,
leadingIcon,
singleLine.
// Password field — override keyboard type and visual transformation
StringField(
state = passwordState,
onValueChange = { controller.updatePassword(it) },
onFocusLost = { controller.touchPassword() },
config = {
visualTransformation = PasswordVisualTransformation()
keyboardType = KeyboardType.Password
},
)
// Enum field rendered as radio group instead of dropdown
EnumField(
state = contactMethodState,
options = ContactMethod.entries,
onSelect = { controller.updateContactMethod(it) },
config = { style = FieldStyle.Picker.RadioGroup },
)
// Switch row instead of checkbox row
BooleanField(
state = notificationsState,
onCheckedChange = { controller.updateNotifications(it) },
onFocusLost = { controller.touchNotifications() },
config = { style = FieldStyle.Toggle.SwitchRow },
)
The trailing lambda receives a typed FieldScope.
You own the UI entirely — no Material 3 dependency used. The scope provides
value, modifier,
keyboardOptions, keyboardActions,
showError, errorMessage,
and more — see the FieldScope table below.
StringField(
state = emailState,
onValueChange = { controller.updateEmail(it) },
onFocusLost = { controller.touchEmail() },
) {
OutlinedTextField(
value = value,
onValueChange = onValueChange,
modifier = modifier.fillMaxWidth(),
label = { Text(label) },
isError = showError,
supportingText = if (showError) { { Text(errorMessage ?: "") } } else null,
keyboardOptions = keyboardOptions,
keyboardActions = keyboardActions,
)
}
Pass style = FieldStyle.* inside a config = { } block to change the rendered widget.
| Field type | Default style | Other styles |
|---|---|---|
| StringField NullableStringField IntField |
FieldStyle.Text.Outlined | FieldStyle.Text.Filled |
| BooleanField | FieldStyle.Toggle.CheckboxRow | Toggle.Checkbox Toggle.Switch Toggle.SwitchRow |
| EnumField | FieldStyle.Picker.Dropdown | Picker.RadioGroup Picker.SegmentedButton |
When using the headless trailing-lambda API, every field block receives a typed scope with these properties.
| Property | Type | Description |
|---|---|---|
| value | T | Current field value |
| onValueChange | (T) → Unit | Called on every keystroke |
| showError | Boolean | True when the field is touched and has errors |
| errorMessage | String? | First validation error, or null |
| isValidating | Boolean | True while an async validator is running |
| label | String | Label from @Field |
| hint | String | Placeholder hint from @Field |
| modifier | Modifier | Pre-wired with focus requester and scroll anchor |
| keyboardOptions | KeyboardOptions | Auto-configured ImeAction (Next / Done) |
| keyboardActions | KeyboardActions | Auto-focuses next field on Next action |
All annotations live in formidable-core.
| Annotation | Target | Description |
|---|---|---|
| @FormSchema | class | Triggers controller code generation |
| @Field(label, hint) | property | Attaches label and placeholder hint to a field |
| @NotBlank | String | Must not be blank |
| @MinLength(n) | String | Minimum character count |
| @MaxLength(n) | String | Maximum character count |
| String | Valid email format | |
| @Pattern(regex) | String | Matches a regular expression |
| @IntRange(min, max) | Int | Value within the given range |
| @MustBeTrue | Boolean | Must be true — for checkbox consent |
| @MatchField(field) | String | Must equal the value of another field |
| @RequiredIf(field, value) | String? | Required only when another field equals a value |
| @AsyncValidation(KClass) | any | Runs a suspending AsyncFieldValidator |
| @VisibleWhen(field, KClass) | any | Conditionally shows or hides a field |
Implement AsyncFieldValidator<T> and attach it with
@AsyncValidation. The controller debounces automatically and
exposes isValidating for a loading indicator.
class UniqueUsernameValidator : AsyncFieldValidator<String> {
override suspend fun validate(value: String): ValidationResult {
val taken = api.checkUsername(value)
return if (taken)
ValidationResult.Invalid(listOf("Username '$value' is already taken"))
else
ValidationResult.Valid
}
}
@FormSchema
data class SignUpForm(
@Field(label = "Username")
@NotBlank("Username is required")
@AsyncValidation(UniqueUsernameValidator::class)
val username: String = "",
)
Use config = { } to override the supporting text and trailing icon
while keeping auto-rendering. usernameState is read from the outer
composable's collectAsState() — the config lambda stores composable
values but is not itself composable.
val usernameState by controller.username.collectAsState()
StringField(
state = usernameState,
onValueChange = { controller.updateUsername(it) },
onFocusLost = { controller.touchUsername() },
config = {
supportingText = when {
usernameState.isValidating -> { { Text("Checking availability…") } }
usernameState.showError -> { { Text(usernameState.errorMessage ?: "") } }
else -> null
}
trailingIcon = if (usernameState.isValidating) {
{ CircularProgressIndicator(Modifier.size(20.dp)) }
} else null
},
)
Use @MatchField to require a field to match another —
the classic password confirmation pattern. The generated controller re-validates the dependent
field automatically whenever the source changes.
@FormSchema
data class SignUpForm(
@Field(label = "Password")
@MinLength(8)
val password: String = "",
@Field(label = "Confirm password")
@MatchField("password", "Passwords do not match")
val confirmPassword: String = "",
)
A full sign-up form running live in your browser via Compose Multiplatform WASM. Async validation, cross-field rules, conditional visibility — all in one demo.
Open live demo →