#if canImport(SwiftUI) import ComposableArchitecture2 import SwiftUI @resultBuilder public enum FormViewFieldDefinitionBuilder { public static func buildExpression( _ field: FormViewFieldDefinition ) -> [FormViewFieldDefinition] { [field] } public static func buildExpression( _ fields: [FormViewFieldDefinition] ) -> [FormViewFieldDefinition] { fields } public static func buildBlock( _ fields: [FormViewFieldDefinition]... ) -> [FormViewFieldDefinition] { fields.flatMap { $0 } } } public struct FormViewFieldDefinition { fileprivate init( id: AnyHashable, content: @MainActor @escaping ( Binding, FieldValidationState?, @escaping (AnyHashable) -> Void ) -> AnyView ) { self.content = content self.id = id } public let id: AnyHashable fileprivate let content: @MainActor ( Binding, FieldValidationState?, @escaping (AnyHashable) -> Void ) -> AnyView } public struct ComposableForm: View { @Bindable private var store: StoreOf> private let fields: [FormViewFieldDefinition] public init( _ store: StoreOf>, @FormViewFieldDefinitionBuilder fields: () -> [FormViewFieldDefinition] ) { self.store = store self.fields = fields() } public var body: some View { SwiftUI.Form { ForEach(self.fields.indices, id: \.self) { index in let field = self.fields[index] field.content($store.value, store.validation[field.id]) { field in store.send(.fieldSubmitted(field)) } } } } } @MainActor public func FormField( _ keyPath: WritableKeyPath, id: AnyHashable? = nil, @ViewBuilder content: @MainActor @escaping (Binding) -> Content ) -> FormViewFieldDefinition { let fieldID = id ?? AnyHashable(keyPath) return FormViewFieldDefinition( id: fieldID, content: { value, validation, submit in AnyView( FormFieldContent( fieldID: fieldID, keyPath: keyPath, submit: submit, validation: validation, value: value, content: content ) ) } ) } @MainActor public func FormScope( _ keyPath: WritableKeyPath, id: AnyHashable? = nil, @FormViewFieldDefinitionBuilder fields: () -> [FormViewFieldDefinition] ) -> [FormViewFieldDefinition] { let scope = id ?? AnyHashable(keyPath) return fields().map { field in let scopedID = AnyHashable( ScopedFormFieldID( scope: scope, field: field.id ) ) return FormViewFieldDefinition( id: scopedID, content: { value, validation, submit in field.content(value[dynamicMember: keyPath], validation) { childField in submit( AnyHashable( ScopedFormFieldID( scope: scope, field: childField ) ) ) } } ) } } // MARK: - Preview #if DEBUG && !os(Android) #Preview("Composable form") { ComposableFormPreview( store: Store( initialState: PreviewProfileForm.State( value: PreviewProfileDraft.sample ) ) { PreviewProfileForm() } ) } #Preview("Composable form custom field style") { ComposableFormPreview( store: Store( initialState: PreviewProfileForm.State( value: PreviewProfileDraft.sample ) ) { PreviewProfileForm() } ) .formFieldStyle(PreviewFormFieldStyle()) } private struct ComposableFormPreview: View { @Bindable var store: StoreOf var body: some View { NavigationStack { ComposableForm(store) { FormField(\.name) { $name in TextField("Name", text: $name) .submitLabel(.next) } FormField(\.email) { $email in TextField("Email", text: $email) .submitLabel(.next) } FormField(\.status) { $status in Picker("Status", selection: $status) { ForEach(PreviewStatus.allCases) { status in Text(status.title).tag(status) } } } FormScope(\.account) { FormField(\.username) { $username in TextField("Username", text: $username) .submitLabel(.done) } } } .navigationTitle("Profile Draft") .safeAreaInset(edge: .bottom) { self.footer } } } private var footer: some View { VStack(alignment: .leading, spacing: 12) { HStack { self.submissionStatus Spacer() Button("Submit") { store.send(.submittedForm) } .buttonStyle(.borderedProminent) .disabled(store.isSubmitting) } Text("Try clearing the name, entering an invalid email, or using `taken` as the username.") .font(.footnote) .foregroundStyle(.secondary) } .padding() .background(.bar) } private var submissionStatus: some View { HStack(spacing: 6) { if store.isSubmitting { ProgressView() } Text(self.submissionStatusLabel) .font(.subheadline.weight(.medium)) } } private var submissionStatusLabel: LocalizedStringKey { switch store.submissionStatus { case .idle: store.hasErrors ? "Needs changes" : "Ready" case .submitted: "Submitted" case .submitting: "Checking" } } } private struct PreviewFormFieldStyle: FormFieldStyle { func makeBody(configuration: FormFieldStyleConfiguration) -> some View { VStack(alignment: .leading, spacing: 4) { configuration.content FormFieldValidationStatus(validation: configuration.validation) .padding(.leading, 2) } } } private typealias PreviewProfileForm = Form private struct PreviewAccountDraft: Equatable { var username = "" } @MainActor private enum PreviewProfileFieldID { static var account: AnyHashable { AnyHashable(\PreviewProfileDraft.account) } static var email: AnyHashable { AnyHashable(\PreviewProfileDraft.email) } static var name: AnyHashable { AnyHashable(\PreviewProfileDraft.name) } static var username: AnyHashable { AnyHashable( ScopedFormFieldID( scope: Self.account, field: AnyHashable(\PreviewAccountDraft.username) ) ) } } private struct PreviewProfileDraft: Equatable { var account = PreviewAccountDraft() var email = "" var name = "" var status = PreviewStatus.draft static let sample = Self( account: PreviewAccountDraft(username: "woody"), email: "woody@example.com", name: "Woody", status: .draft ) } private enum PreviewStatus: String, CaseIterable, Identifiable { case archived case draft case published var id: Self { self } var title: String { switch self { case .archived: "Archived" case .draft: "Draft" case .published: "Published" } } } private extension PreviewProfileForm { init() { self = Self { Self.Field(\.name) { Validation(error: .previewNameRequired) { !$0.isEmpty } } Self.Field(\.email) { Validation(error: .previewEmailRequired) { !$0.isEmpty } Validation(error: .previewEmailInvalid) { $0.contains("@") } } Self.Field(\.status) Self.Scope(\.account) { Form.Field(\.username) { Validation(error: .previewUsernameRequired) { !$0.isEmpty } Validation(policy: .formSubmitted) { username async throws -> ValidationResult in try await Task.sleep(for: .milliseconds(600)) return username.lowercased() == "taken" ? .invalid(.previewUsernameTaken) : .valid } } } } .onSubmit { value in print("Submitted profile draft:", value) } } } private extension ErrorMessage { static let previewEmailInvalid = Self(message: "Email must contain @") static let previewEmailRequired = Self(message: "Email is required") static let previewNameRequired = Self(message: "Name is required") static let previewUsernameRequired = Self(message: "Username is required") static let previewUsernameTaken = Self(message: "Username is already taken") } #endif #endif