iosxcodeswiftui

SwiftUI Simplify .onChange Modifier for Many TextFields


I'm searching for a way to simplify/refactor the addition of .onChange(of:) in a SwiftUI view that has MANY TextFields. If a solution were concise, I would also move the modifier closer to the appropriate field rather than at the end of, say, a ScrollView. In this case, all of the .onChange modifiers call the same function.

Example:

.onChange(of: patientDetailVM.pubFirstName) { x in
    changeBackButton()
}
.onChange(of: patientDetailVM.pubLastName) { x in
    changeBackButton()
}
// ten+ more times for other fields

I tried "oring" the fields. This does not work:

.onChange(of:
            patientDetailVM.pubFirstName ||
            patientDetailVM.pubLastName
) { x in
    changeBackButton()
}

This is the simple function that I want to call:

func changeBackButton() {
    withAnimation {
        showBackButton = false
        isEditing = true
    }
}

Any guidance would be appreciated. Xcode 13.2.1 iOS 15


Solution

  • Overview of Solution

    We extend the Binding type, to create two new methods, both of which are called onChange.

    Both onChange methods are intended to be used in situations in which you need to perform some work whenever the Binding instance's wrappedValue property is changed (not just set) via its set method.

    The first onChange method doesn't pass the new value of the Binding instance's wrappedValue property to the provided on-change callback method, whereas the second onChange method does provide it with the new value.

    The first onChange method allows us to refactor this:

    bindingToProperty.onChange { _ in
        changeBackButton()
    }
    

    to this:

    bindingToProperty.onChange(perform: changeBackButton)
    

    Solution

    Helper-Code

    import SwiftUI
    
    extension Binding {
        public func onChange(perform action: @escaping () -> Void) -> Self where Value : Equatable {
            .init(
                get: {
                    self.wrappedValue
                },
                set: { newValue in
                    guard self.wrappedValue != newValue else { return }
                    
                    self.wrappedValue = newValue
                    action()
                }
            )
        }
        
        public func onChange(perform action: @escaping (_ newValue: Value) -> Void) -> Self where Value : Equatable {
            .init(
                get: {
                    self.wrappedValue
                },
                set: { newValue in
                    guard self.wrappedValue != newValue else { return }
                    
                    self.wrappedValue = newValue
                    action(newValue)
                }
            )
        }
    }
    

    Usage

    struct EmployeeForm: View {
        @ObservedObject var vm: VM
        
        private func changeBackButton() {
            print("changeBackButton method was called.")
        }
        
        private func occupationWasChanged() {
            print("occupationWasChanged method was called.")
        }
        
        var body: some View {
            Form {
                TextField("First Name", text: $vm.firstName.onChange(perform: changeBackButton))
                TextField("Last Name", text: $vm.lastName.onChange(perform: changeBackButton))
                TextField("Occupation", text: $vm.occupation.onChange(perform: occupationWasChanged))
            }
        }
    }
    
    struct Person {
        var firstName: String
        var surname: String
        var jobTitle: String
    }
    
    extension EmployeeForm {
        class VM: ObservableObject {
            @Published var firstName = ""
            @Published var lastName = ""
            @Published var occupation = ""
            
            func load(from person: Person) {
                firstName = person.firstName
                lastName = person.surname
                occupation = person.jobTitle
            }
        }
    }
    
    struct EditEmployee: View {
        @StateObject private var employeeForm = EmployeeForm.VM()
        @State private var isLoading = true
        
        func fetchPerson() -> Person {
            return Person(
                firstName: "John",
                surname: "Smith",
                jobTitle: "Market Analyst"
            )
        }
        
        var body: some View {
            Group {
                if isLoading {
                    Text("Loading...")
                } else {
                    EmployeeForm(vm: employeeForm)
                }
            }
            .onAppear {
                employeeForm.load(from: fetchPerson())
                isLoading = false
            }
        }
    }
    
    struct EditEmployee_Previews: PreviewProvider {
        static var previews: some View {
            EditEmployee()
        }
    }
    

    Benefits of Solution

    1. Both the helper-code and usage-code are simple and kept very minimal.
    2. It keeps the onChange-callback very close to the place where the Binding instance is provided to the TextField/TextEditor/other type.
    3. It's generic, and is very versatile, as it can be used for any Binding instance that has a wrappedValue property of any type that conforms to the Equatable protocol.
    4. The Binding instances that have on-change callbacks, look just like Binding instances that don't have on-change callbacks. Consequently, no types to which these Binding instances with on-change callbacks are provided, need special modifications to know how to deal with them.
    5. The helper-code doesn't involve the creation of any new View's, @State properties, ObservableObject's, EnvironmentKey's, PreferenceKey's, or any other types. It simply adds a couple of methods to the existing type called Binding - which obviously is a type that would have already been being used in the code...