Skip to content
Open Cloud

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):

swift
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:

MemberWhat it is
label, placeholder, helperTextTranslated texts of the field
value, textCurrent value as JSONValue?, and as text
textBinding, boolBindingBindings for TextField and Toggle
setValue(_:), commit(_:)Write a value; commit also moves to the next step when the field sets autoAdvance
optionsOptions of choice fields, each with value, label and image
error, isInvalidError message to show under the field, and whether there is one
isRequired, isLockedRequired flag, and whether the form is busy or the field is disabled
accessibilityNameLabel with the required marker for VoiceOver
fieldThe full field definition

A text field:

swift
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:

swift
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:

swift
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:

swift
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.

swift
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:

swift
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:

swift
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:

swift
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.

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:

swift
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:

swift
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:

swift
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:

swift
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.