Building a Form in SwiftUI – Part 2 – External Submission

header image

Welcome to the second part of our form context series, where we delve into implementing an external trigger for submitting form content. This tutorial is particularly handy if you need to submit form data stored in a form context using an external component like a button, which isn’t aware of the form itself. If you haven’t already, I recommend reading Building a Form with Context in SwiftUI – Part 1, as this tutorial builds upon it.

To kick things off, we need to establish a method for publishing a submission request that all forms within the view can subscribe to. Next, we’ll need a way to differentiate between forms so that the submission request can be directed to a specific form. Let’s dive in!

In SwiftUI, we leverage EnvironmentObject to propagate changes down a view hierarchy, allowing forms to track these changes. Additionally, we can assign each form a unique ID to differentiate it from others. With these concepts in mind, we can create a FormSubmitTrigger along with a view modifier that incorporates it as an environment object. Let’s proceed with this approach!

/// An instance of FormSubmitTrigger should be added to the top-level view in a hierarchy using the `formSubmitTrigger(_ trigger:)` modifier,
/// and provides a `submit(_ formId:)` function which takes a form id and publishes it to all forms in the hierachy so that the expected form can handle it.
public final class FormSubmitTrigger: ObservableObject {
    @Published private(set) var formId: String?

    public init() {}

    /// Triggers the submission action of a form with the given `formId`.
    /// - Parameter formId: The id of the form that is expected to be submitted.
    public func submit(_ formId: String?) {
        self.formId = formId
    }
}

public extension View {

    /// A view modifier that adds a form submit trigger to the current view and all its children via an environment object.
    @ViewBuilder
    func formSubmitTrigger(_ trigger: FormSubmitTrigger) -> some View {
        modifier(FormSubmitTriggerViewModifier(formSubmitTrigger: trigger))
    }
}

private struct FormSubmitTriggerViewModifier: ViewModifier {
    @ObservedObject private var formSubmitTrigger: FormSubmitTrigger

    init(formSubmitTrigger: FormSubmitTrigger) {
        self._formSubmitTrigger = .init(wrappedValue: formSubmitTrigger)
    }

    func body(content: Content) -> some View {
        content
            .environment(\.hasFormSubmitTrigger, true)
            .environmentObject(formSubmitTrigger)
    }
}

private struct HasFormSubmitTriggerKey: EnvironmentKey {
    static let defaultValue = false
}

public extension EnvironmentValues {
    var hasFormSubmitTrigger: Bool {
        get { self[HasFormSubmitTriggerKey.self] }
        set { self[HasFormSubmitTriggerKey.self] = newValue }
    }
}

Following this, we’ll incorporate a listener into the form view to monitor changes to this environment object. If the submitted form ID matches the ID associated with the current form, a closure will be triggered accordingly. Let’s proceed by implementing this listener functionality.

public extension View {
    /// A view modifier that triggers the given closure when a form submit is reqeusted and passes the form id for which the submit is requested.
    @ViewBuilder
    func onFormSubmitTriggered(perform action: @escaping (String?) -> Void) -> some View {
        modifier(OnFormSubmitTriggeredViewModifier(action: action))
    }
}

private struct OnFormSubmitTriggeredViewModifier: ViewModifier {

    @Environment(\.hasFormSubmitTrigger)
    private var hasFormSubmitTrigger: Bool

    @EnvironmentObject private var formSubmitTrigger: FormSubmitTrigger

    let action: (String?) -> Void

    func body(content: Content) -> some View {
        if hasFormSubmitTrigger {
            content
                .onReceive(formSubmitTrigger.$formId, perform: action)
        } else {
            content
        }

    }
}

After that, we’ll be able to utilise the onFormSubmitTriggered view modifier within the form view. This modifier will execute a closure each time a new form ID is submitted to the FormSubmitTrigger:

struct FormView<Content: View>: View {
    private let formId: String
    private let formContextObject: FormContextObject
    private let content: Content

    init(formId: String, formContext: FormContext = .init(), @ViewBuilder content: () -> Content) {
        self.formId = formId
        self.formContextObject = .init(context: formContext)
        self.content = content()
    }

    var body: some View {
        content
            .formContextObject(formContextObject)
            .onFormSubmitTriggered { formId in
                guard let formId, formId == self.formId else { return }
                
                if let dataFields = formContextObject.context?.dataFields {
                    dataFields.keys.sorted().forEach {
                        if let value = dataFields[$0]??.description {
                            print("\($0): \(value)")
                        }
                    }
                    print("==========")
                }
            }
    }
}

To wrap up, we’ll create a view containing two forms, each equipped with a submit button. Tapping the submit button will grant access to the current form context. Additionally, we’ll include two buttons outside of the forms, capable of triggering form submissions externally. Let’s craft this view now.

struct ContentView: View {
    let formSubmitTrigger = FormSubmitTrigger()

    var body: some View {
        VStack(spacing: 24) {
            FormView(formId: "form-1") {
                VStack {
                    Text("Form 1")
                        .font(.headline)
                        .frame(maxWidth: .infinity, alignment: .leading)
                    TextFieldView(id: "firstname", title: "First Name")
                    TextFieldView(id: "lastname", title: "Last Name")
                    SubmitButtonView()
                }
            }
            
            FormView(formId: "form-2") {
                VStack {
                    Text("Form 2")
                        .font(.headline)
                        .frame(maxWidth: .infinity, alignment: .leading)
                    TextFieldView(id: "firstname", title: "First Name")
                    TextFieldView(id: "lastname", title: "Last Name")
                    SubmitButtonView()
                }
            }
            
            VStack {
                Button("External Submission Form 1", action: {
                    formSubmitTrigger.submit("form-1")
                })
                
                Button("External Submission Form 2", action: {
                    formSubmitTrigger.submit("form-2")
                })
            }
            .padding()
        }
        .formSubmitTrigger(formSubmitTrigger)
        .padding()
    }
}
sample image of 2 forms

In the example provided, the “External Submission Form 1” button will submit “form-1” to the FormSubmitTrigger, while “External Submission Form 2” will submit “form-2”. This action triggers the submission closure on each respective form. Each form checks if the submitted form ID via the FormSubmitTrigger matches its own ID and handles the submission accordingly.

In conclusion, we’ve explored the method of externally submitting a form along with its context using a button located outside the form itself. This button solely relies on the form ID for submission, without direct access to form data or context. In our next tutorial, we’ll delve into implementing form validation, enabling the validation of content within the form context upon submission, and providing user feedback accordingly.

Kamyab R. Bozorg

Software developer specializing in iOS development using Swift and SwiftUI, with experience in web development as well as in backend languages like C# and Java. My passion lies in crafting elegant solutions and pushing the boundaries of innovation in the ever-evolving world of technology.

One thought on “Building a Form in SwiftUI – Part 2 – External Submission

Leave a Reply

Your email address will not be published. Required fields are marked *