Overview
Composure is a form validation engine you define once, in Kotlin, and drive from both Compose Multiplatform and SwiftUI: same fields, same validators, same async-debounce behavior, no parallel validation logic to keep in sync on the Swift side.
- composure-core is the engine: typed fields, sync/async validators,
StateFlow-based observable state. Pure Kotlin, no UI framework dependency. - composure-compose provides
rememberFormState, wiring aFormScopeinto Compose’s lifecycle. - composure-ios is a Swift-friendly bridge (
ComposureFormScope,FormField) that exposes the same engine to SwiftUI without leaking coroutines or generics across the interop boundary.
Platforms: Android, iOS, Desktop (JVM) via composure-core/composure-compose; native SwiftUI via composure-ios.
Installation
Compose Multiplatform
commonMain.dependencies {
implementation("io.github.kmpbits:composure-core:<version>")
implementation("io.github.kmpbits:composure-compose:<version>")
}
Replace <version> with the latest release on GitHub or Maven Central.
SwiftUI (Swift Package Manager)
- In Xcode, go to File → Add Package Dependencies…
- Paste the repository URL:
https://github.com/kmpbits/Composure.git - Pick a version rule, click Add Package, then select the
Composureproduct for your target.
Or add it to your own Package.swift:
.package(url: "https://github.com/kmpbits/Composure.git", from: "<version>")
Then import composure_ios in Swift.
Usage
Compose Multiplatform: inline
val form = rememberFormState {
field("email", Email)
field("password", Password) { minLength(8) }
}
val email by form.get<Email>("email").value.collectAsState()
val isValid by form.isValid.collectAsState()
OutlinedTextField(
value = email,
onValueChange = { form.get<Email>("email").onChange(it) },
label = { Text("Email") },
)
Button(onClick = form.handleSubmit { values -> /* submit */ }, enabled = isValid) {
Text("Sign in")
}
Compose Multiplatform: typed form class
class RegistrationForm(scope: FormScope) : FormController by scope {
val email = scope.field(Email) { async(checkEmailAvailability) }
val password = scope.field(Password) { minLength(8); hasUppercase(); hasDigit() }
val confirm = scope.field(Password) { mustMatch(password) }
}
val form = rememberFormState { scope -> RegistrationForm(scope) }
Delegating FormController to scope exposes isValid, isDirty, isSubmitting, and handleSubmit()/reset() directly on the form class, alongside its own typed field properties.
Surviving configuration changes
rememberFormState behaves like plain remember: the form is lost whenever composition is discarded, including an Android configuration change. There’s intentionally no rememberSaveable variant — FormScope holds a CoroutineScope, StateFlows, and Jobs, none of which are Bundle-safe, and “configuration change” isn’t a concept on iOS/Desktop/Wasm to begin with.
For forms that need to survive configuration changes, hoist FormScope into a ViewModel instead. It’s plain Kotlin with no Compose dependency:
class LoginViewModel : ViewModel() {
private val scope = FormScope(viewModelScope)
val form = LoginForm(scope)
}
For process-death survival on top of that, round-trip field values through a SavedStateHandle with FormScope.saveFieldData() / restoreFieldData():
class LoginViewModel(private val savedStateHandle: SavedStateHandle) : ViewModel() {
private val scope = FormScope(viewModelScope)
val form = LoginForm(scope).also {
savedStateHandle.get<Map<String, List<String?>>>("form")?.let(scope::restoreFieldData)
}
fun persist() {
savedStateHandle["form"] = scope.saveFieldData()
}
}
Call persist() whenever you want the current values committed — FormScope doesn’t expose a single “any field changed” stream, so continuous autosave on every keystroke is on you to wire up if you need it.
SwiftUI
class RegistrationForm: ComposureForm {
lazy var email = makeEmailField()
lazy var password = makePasswordField()
lazy var confirm = makeConfirmField(matching: password)
}
ComposureForm/ComposureField wrap ComposureFormScope/FormField into ObservableObject/SwiftUI View types, so field updates drive @Published properties with no manual coroutine bridging. These wrapper types aren’t part of the published composure-ios package: it ships the Kotlin bridge only. Copy ComposureFormKit.swift from the sample app into your project as a starting point.
API
FormScope
The form-building scope and runtime controller, passed into your form class (or inline block) by rememberFormState.
class FormScope(
coroutineScope: CoroutineScope,
asyncDebounceMs: Long = 300L,
) : FormController
Declare fields with field(type) { ... } (typed property access) or field(name, type) { ... } (string-keyed, retrieved later with form.get<Type>(name)). Implements FormController: isValid, isDirty, isSubmitting, submitError as StateFlow, plus handleSubmit(onValid) and reset().
Field types
Built-in FieldTypes pre-bake sensible default validators:
| Type | Keyboard hint | Default validators |
|---|---|---|
Email | required, email format | |
Password | Password (masked) | required |
Name | Text | required |
Phone | Phone | required |
Text | Text | none |
Implement FieldType yourself for custom types, with your own default validators and DSL extensions.
Validators
required(), email(), minLength(), maxLength(), matches(), hasUppercase(), hasDigit(), hasSpecialChar(), plus type-specific extensions like Password.mustMatch(other) for cross-field validation. The dependent field re-validates automatically whenever the field it depends on changes, no manual wiring required.
Async validation (server-side uniqueness checks, etc.) attaches per field via async(validator) and is debounced by asyncDebounceMs (default 300ms) so it doesn’t fire on every keystroke.
Every built-in validator takes a message parameter, and messages() overrides a type’s default required/format messages:
val email = scope.field(Email) {
messages(required = "We need your email", format = "That doesn't look like an email")
}
val password = scope.field(Password) {
minLength(8, "Needs to be at least 8 characters")
}
For fully custom validation logic, use addValidator inside the field’s builder block:
val username = scope.field(Text) {
addValidator { value ->
if (value.contains(" ")) ValidationResult.Invalid("No spaces allowed")
else ValidationResult.Valid
}
}
iOS bridge
ComposureFormScope owns its own FormScope and coroutine scope, and exposes field factories (emailField(), passwordField(), confirmField()) returning FormField: a type-erased wrapper Swift can observe (watchValue, watchError, watchIsTouched, watchIsDirty, watchIsValidating) without touching Kotlin generics or StateFlow directly. asyncValidator { value, callback in ... } bridges a Swift async check into the same AsyncFieldValidator interface the Compose side uses.
Known Limitations
- No bundled SwiftUI view layer:
composure-iosships the Kotlin bridge only. TheComposureForm/ComposureFieldSwiftUI types are a reference implementation in the sample app, meant to be copied and adapted, not a transitive dependency. - No built-in input masking: validators reject or accept a value; formatting input as the user types (e.g. phone number masks) stays the UI layer’s responsibility.
rememberFormStatedoesn’t survive configuration changes: it’sremember-scoped, notrememberSaveable. HoistFormScopeinto aViewModelfor that — see Surviving configuration changes above.