Something went wrong. Try again.
A library for helping build high quality forms in SwiftUI and TCA
Something went wrong. Try again.
9.9 kB · 346 lines
Swift
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347#if canImport(SwiftUI)import ComposableArchitecture2import SwiftUI
@resultBuilderpublic enum FormViewFieldDefinitionBuilder<Value> { public static func buildExpression( _ field: FormViewFieldDefinition<Value> ) -> [FormViewFieldDefinition<Value>] { [field] }
public static func buildExpression( _ fields: [FormViewFieldDefinition<Value>] ) -> [FormViewFieldDefinition<Value>] { fields }
public static func buildBlock( _ fields: [FormViewFieldDefinition<Value>]... ) -> [FormViewFieldDefinition<Value>] { fields.flatMap { $0 } }}
public struct FormViewFieldDefinition<Value> { fileprivate init( id: AnyHashable, content: @MainActor @escaping ( Binding<Value>, FieldValidationState?, @escaping (AnyHashable) -> Void ) -> AnyView ) { self.content = content self.id = id }
public let id: AnyHashable fileprivate let content: @MainActor ( Binding<Value>, FieldValidationState?, @escaping (AnyHashable) -> Void ) -> AnyView}
public struct ComposableForm<Value>: View { @Bindable private var store: StoreOf<Form<Value>>
private let fields: [FormViewFieldDefinition<Value>]
public init( _ store: StoreOf<Form<Value>>, @FormViewFieldDefinitionBuilder<Value> fields: () -> [FormViewFieldDefinition<Value>] ) { 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)) } } } }}
@MainActorpublic func FormField<Root, FieldValue, Content: View>( _ keyPath: WritableKeyPath<Root, FieldValue>, id: AnyHashable? = nil, @ViewBuilder content: @MainActor @escaping (Binding<FieldValue>) -> Content) -> FormViewFieldDefinition<Root> { 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 ) ) } )}
@MainActorpublic func FormScope<Root, Child>( _ keyPath: WritableKeyPath<Root, Child>, id: AnyHashable? = nil, @FormViewFieldDefinitionBuilder<Child> fields: () -> [FormViewFieldDefinition<Child>]) -> [FormViewFieldDefinition<Root>] { 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<PreviewProfileForm>
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<PreviewProfileDraft>
private struct PreviewAccountDraft: Equatable { var username = ""}
@MainActorprivate 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<PreviewAccountDraft>.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