A library for helping build high quality forms in SwiftUI and TCA
0

Configure Feed

Select the types of activity you want to include in your feed.

34 4 0

Clone this repository

https://tangled.org/woody.fm/swift-composable-forms https://tangled.org/did:plc:bvcfah3arrveu2eskpjvb7gh
git@tangled.org:woody.fm/swift-composable-forms git@tangled.org:did:plc:bvcfah3arrveu2eskpjvb7gh

For self-hosted knots, clone URLs may differ based on your setup.



README.md

Composable Forms#

Composable form state, validation, and SwiftUI bindings for ComposableArchitecture2 features.

ComposableForms gives a form a small reducer that owns the current value, focused field, validation state, and submission status. It supports synchronous and asynchronous validation, scoped child fields, debounced validation after observed changes, and SwiftUI helpers for rendering fields from a StoreOf<Form<Value>>.

import ComposableArchitecture2
import ComposableForms

struct ProfileDraft: Equatable {
  var name = ""
  var email = ""
}

enum ProfileField {
  static let name = AnyHashable(\ProfileDraft.name)
  static let email = AnyHashable(\ProfileDraft.email)
}

typealias ProfileForm = Form<ProfileDraft>

Installation#

Add the package to your Swift package dependencies:

.package(url: "https://tangled.org/woody.fm/swift-composable-forms", branch: "main")

Then add ComposableForms to the targets that need it:

.target(
  name: "YourFeature",
  dependencies: [
    "ComposableForms",
  ]
)

Defining Fields#

Define the fields a form should observe and validate with Form.Field.

let form = ProfileForm {
  Form.Field(\.name) {
    Validation(error: "Name is required") { !$0.isEmpty }
  }

  Form.Field(\.email) {
    Validation(error: "Email is required") { !$0.isEmpty }
    Validation(error: "Email is invalid") { $0.contains("@") }
  }
}

Each field is identified by its key path by default. Pass an explicit id when you want stable domain identifiers or need to coordinate with view code.

Form.Field(id: ProfileField.name, value: \.name) {
  Validation(error: "Name is required") { !$0.isEmpty }
}

Validation#

Validations return ValidationResult.valid or one or more ErrorMessage values. Boolean convenience initializers cover common required-field checks:

Validation(error: "Username is required") { !$0.isEmpty }

Use an async validation when the field needs to ask another dependency:

Validation(policy: .formSubmitted) { username async throws in
  try await usernameIsAvailable(username)
    ? .valid
    : .invalid("Username is unavailable")
}

Validation policies decide which user actions run a validation:

ValidationPolicy.fieldSubmitted
ValidationPolicy.focusLoss
ValidationPolicy.formSubmitted
ValidationPolicy.observedChange
ValidationPolicy.userActions
ValidationPolicy.all

Observed value changes are debounced through the formObservedChangeValidationDelay dependency, which tests can override with a TestClock.

State#

Form.State stores the draft value and derived form state:

var state = ProfileForm.State(value: ProfileDraft())

state.value.name = "Blob"
state.focusedField = ProfileField.email
state.validation[ProfileField.name] = .valid

The state exposes submission and validation helpers that views can use directly:

state.canSubmit
state.hasErrors
state.isSubmitting
state.isSubmitted
state.isValidating
state.validationErrors

When Value is Equatable, Form.State is also Equatable.

Submission#

Send .submittedForm to validate all fields and submit the current value when the form is valid:

let feature = ProfileForm {
  Form.Field(\.name) {
    Validation(error: "Name is required") { !$0.isEmpty }
  }
}
.onSubmit { value in
  try save(value)
}

If any submit-time async validation is still running, the form remains in the .submitting state until those validations settle.

SwiftUI#

Render a form with ComposableForm and field bindings:

ComposableForm(store) {
  FormField(\.name) { $name in
    TextField("Name", text: $name)
      .submitLabel(.next)
  }

  FormField(\.email) { $email in
    TextField("Email", text: $email)
      .submitLabel(.done)
  }
}

FormField submits its field when the SwiftUI field submits, which lets the form validate the field and advance focus to the next field.

Use FormScope for nested values. Scoped view field IDs match scoped reducer field IDs.

FormScope(\.profile) {
  FormField(\.displayName) { $displayName in
    TextField("Display name", text: $displayName)
  }
}

Field Styles#

Customize field rendering with FormFieldStyle:

struct InlineValidationStyle: FormFieldStyle {
  func makeBody(configuration: FormFieldStyleConfiguration) -> some View {
    VStack(alignment: .leading, spacing: 4) {
      configuration.content
      FormFieldValidationStatus(validation: configuration.validation)
    }
  }
}

ComposableForm(store) {
  FormField(\.name) { $name in
    TextField("Name", text: $name)
  }
}
.formFieldStyle(InlineValidationStyle())

The default style renders the field content followed by validating and error messages.

Requirements#

This package currently targets Swift 6.0 or later, iOS 17 or later, and macOS 14 or later. The core form reducer is separate from the SwiftUI helpers; view APIs are compiled only when SwiftUI is available.