SwiftUI components
Register your own SwiftUI views per field type with .formhausComponents. Types you leave out keep the built-in views of the set you start from.
iOS and macOS guide · Kiln example set
Component map
Each .field call maps one field type to a view. Start from .standard (the Formhaus set), .system (native iOS) or .headless (no built-in views):
import FormhausCore
import FormhausUI
import SwiftUI
extension FormComponents {
static let acme = FormComponents.standard
.field("text") { AcmeTextField(context: $0) }
.field("email") { AcmeTextField(context: $0) }
.field("select") { AcmeSelect(context: $0) }
.field("checkbox") { AcmeCheckbox(context: $0) }
}
struct SignupView: View {
let definition: FormDefinition
var body: some View {
FormhausForm(definition: definition) { values, _ in .ok }
.formhausComponents(.acme)
}
}The submit handler here returns .ok without sending anything. Use your own handler, as in Render a form. Switching the components of a running form keeps its values, step and errors.
Fields
Each view registered with .field receives a FieldContext:
| Member | What it is |
|---|---|
label, placeholder, helperText | Translated texts of the field |
value, text | Current value as JSONValue?, and as text |
textBinding, boolBinding | Bindings for TextField and Toggle |
setValue(_:), commit(_:) | Write a value; commit also moves to the next step when the field sets autoAdvance |
options | Options of choice fields, each with value, label and image |
error, isInvalid | Error message to show under the field, and whether there is one |
isRequired, isLocked | Required flag, and whether the form is busy or the field is disabled |
accessibilityName | Label with the required marker for VoiceOver |
field | The full field definition |
A text field:
struct AcmeTextField: View {
@FocusState private var focused: Bool
let context: FieldContext
var body: some View {
VStack(alignment: .leading, spacing: 6) {
Text(verbatim: context.label).font(.subheadline.weight(.semibold))
TextField(context.placeholder ?? "", text: context.textBinding)
.textFieldStyle(.roundedBorder)
.disabled(context.isLocked)
.formhausFocus(context, $focused)
.accessibilityLabel(context.accessibilityName)
if let error = context.error {
Text(verbatim: error).font(.footnote).foregroundStyle(.red)
}
}
.formhausScrollAnchor(context)
}
}.formhausFocus moves focus to the first invalid field after a failed submit. It keeps the keyboard up while the form is busy. .formhausScrollAnchor lets the form scroll to the field from the error summary.
A select with the native Picker:
struct AcmeSelect: View {
let context: FieldContext
var body: some View {
Picker(context.label, selection: selection) {
Text(verbatim: context.placeholder ?? "Choose").tag("")
ForEach(context.options, id: \.self) { option in
Text(verbatim: option.label ?? "").tag(option.value ?? "")
}
}
.pickerStyle(.menu)
.disabled(context.isLocked)
.formhausScrollAnchor(context)
}
private var selection: Binding<String> {
Binding(
get: { context.value?.stringValue ?? "" },
set: { context.setValue($0.isEmpty ? nil : .string($0)) }
)
}
}A checkbox with Toggle:
struct AcmeCheckbox: View {
let context: FieldContext
var body: some View {
Toggle(context.label, isOn: context.boolBinding)
.disabled(context.isLocked)
.formhausScrollAnchor(context)
}
}Actions and containers
.actions replaces the Continue, Back and Submit row. ActionsContext holds the labels and flags of the current step:
struct AcmeActions: View {
let context: ActionsContext
var body: some View {
HStack {
if context.state.showBack {
Button(context.state.backLabel) { context.back() }
}
Spacer()
Button(context.state.primaryLabel) { context.performPrimaryAction() }
.buttonStyle(.borderedProminent)
.disabled(context.state.primaryDisabled || context.busy)
}
}
}
FormhausForm(definition: definition) { values, _ in .ok }
.formhausComponents(.acme.actions { AcmeActions(context: $0) }).progress, .section, .group, .groupItem and .confirmDialog replace the other containers the same way.
Test ids
FormRendererOptions(testIds: .automatic) sets accessibility identifiers for UI tests. It is .off by default.
FormhausForm(definition: definition, renderer: FormRendererOptions(testIds: .automatic)) { values, _ in .ok }Ids start with the definition id: signup.form, signup.email, signup.plan.pro, signup.people.0.name, signup.submit, signup.next, signup.back, signup.cancel and signup.summary. Field rows of the .system set are accessibility containers, so query inputs with a descendant query:
let app = XCUIApplication()
app.descendants(matching: .any)["signup.email"].tap()
app.descendants(matching: .any)["signup.plan.pro"].tap()
app.buttons["signup.next"].tap()Name the ids yourself with .custom. It gets a TestIdTarget and returns the id, or nil for none:
let testIds = TestIds.custom { target in
switch target.kind {
case .field: "field-\(target.key ?? "")"
case .option: "field-\(target.key ?? "")-\(target.value ?? "")"
default: "form-\(target.kind.rawValue)"
}
}
FormhausForm(definition: definition, renderer: FormRendererOptions(testIds: testIds)) { values, _ in .ok }A custom field view reads context.testId and context.optionTestId(_:). A custom actions view reads context.testIds. See Testing for the web ids.
Keep hidden values
By default a field hidden by a condition loses its value. hiddenValues: .keep in FormEngineOptions keeps it in engine.values and in the submitted values:
FormhausForm(definition: definition, initialValues: record, options: FormEngineOptions(hiddenValues: .keep)) { values, _ in .ok }Hidden fields still count as unanswered for conditions and validation, so they never block Continue or Submit. Pass SubmissionOptions(hiddenValues: .keep) to validateSubmission on the server.
Navigation title
FormBehavior.titleDisplayMode sets how the .system set shows the definition title. .automatic leaves the mode to your screen. .inline and .large set it, and .off sets no title:
FormhausForm(definition: definition) { values, _ in .ok }
.formhausComponents(.system)
.formhausBehavior(FormBehavior(titleDisplayMode: .large))Tint
The .system set uses the tint of your app for Continue and Back, checkmarks, scale dots, stars, picture selection and the date row. Set .tint(...) around the form:
FormhausForm(definition: definition) { values, _ in .ok }
.formhausComponents(.system)
.tint(.indigo)The Formhaus set still uses the theme primary.
Missing translations
missingKeys(messages:labels:) lists the built-in message and label keys that a catalog lacks. Use it in a unit test:
let missing = missingKeys(messages: .catalog(messages), labels: .catalog(labels))
#expect(missing.messages.isEmpty)
#expect(missing.labels.isEmpty)In debug builds, a form with a messages or labels catalog reports missing-message-key or missing-label-key through onWarning when a shown text is in neither the catalog nor the definition. Each key is reported once per process:
let options = FormEngineOptions(onWarning: { issue in print(issue.code, issue.message) }, messages: .catalog(messages))Theme only
To keep the built-in views and change colors, fonts and radii, set a FormhausTheme with .formhausTheme instead. Custom views read the resolved tokens from @Environment(\.formhausTokens). See Theming in the README.