SwiftUI Simplify .onChange Modifier for Many TextFields

Viewed 1554

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

4 Answers

Any time you are duplicating code you want to move it one level down so the same code can be reused.

Here is a solution, the parent view will hold a variable that will know if the "name" as a whole has changes.

import SwiftUI
class PatientDetailViewModel: ObservableObject{
    @Published var pubFirstName: String = "John"
    @Published var pubLastName: String = "Smith"
}
struct TrackingChangesView: View {
    @StateObject var vm: PatientDetailViewModel = PatientDetailViewModel()
    ///Variable to know if there is a change
    @State var nameHasChanges: Bool = false
    var body: some View {
        NavigationView{
            NavigationLink("EditView", destination: {
                VStack{
                    TrackingChangesTextFieldView(hasChanges: $nameHasChanges, text: $vm.pubFirstName, titleKey: "first name")
                    TrackingChangesTextFieldView(hasChanges: $nameHasChanges, text: $vm.pubLastName, titleKey: "last name")
                    Button("save", action: {
                        //Once you verify saving the object reset the variable
                        nameHasChanges = false
                    })//Enable button when there are changes
                        .disabled(!nameHasChanges)
                }
                //Or track the single variable here
                .onChange(of: nameHasChanges, perform: {val in
                    //Your method here
                })
                //trigger back button with variable
                .navigationBarBackButtonHidden(nameHasChanges)
            })
            
        }
    }
}
struct TrackingChangesTextFieldView: View {
    //Lets the parent view know that there has been a change
    @Binding var hasChanges: Bool
    @Binding var text: String
    let titleKey: String
    var body: some View {
        TextField(titleKey, text: $text)
            .onChange(of: text, perform: { _ in
                //To keep it from reloading view if already true
                if !hasChanges{
                    hasChanges = true
                }
            })
    }
}
struct TrackingChangesView_Previews: PreviewProvider {
    static var previews: some View {
        TrackingChangesView()
    }
}

Another way you can do this is to make a combined publisher for both pubFirstName and pubLastName. Add following function to your viewModel

var nameChanged: AnyPublisher<Bool, Never> {
        $patientDetailVM.pubFirstName
            .combineLatest($patientDetailVM.pubLastName)
            .map { firstName, lastName in
                if firstName != patientDetailVM.pubFirstName ||
                    lastName != patientDetailVM.pubLastName
                {
                    return true
                } else {
                    return false
                }
            }
            .eraseToAnyPublisher()
    }

and listen to nameChanged publisher on onReceive of your view

.onReceive(of: patientDetailVM.nameChanged) { hasNameChanged in
    changeBackButton()
}

so you can listen to either first or last name change. Didn't test the code but just as an idea.

Here's a fairly DRY approach I came up with. Obviously, once you've written the code that defines the NameKeyPathPairs struct, and the extension to Array, etc., it's very simple to use.

Example Usage

import SwiftUI

struct EmployeeForm: View {
    @ObservedObject var vm: VM

    private let textFieldProps: NameKeyPathPairs<String, ReferenceWritableKeyPath<VM, String>> = [
        "First Name": \.firstName,
        "Last Name": \.lastName,
        "Occupation": \.occupation
    ]

    private func changeBackButton() {
        print("changeBackButton method was called.")
    }

    var body: some View {
        Form {
            ForEach(textFieldProps, id: \.name) { (name, keyPath) in
                TextField(name, text: $vm[dynamicMember: keyPath])
            }
        }
        .onChange(of: textFieldProps.keyPaths.applied(to: vm)) { _ in
            changeBackButton()
        }
    }
}

.onChange Helper Code

public struct NameKeyPathPairs<Name, KP>: ExpressibleByDictionaryLiteral where Name : ExpressibleByStringLiteral, KP : AnyKeyPath {
    private let data: [Element]
    public init(dictionaryLiteral elements: (Name, KP)...) {
        self.data = elements
    }
    public var names: [Name] {
        map(\.name)
    }
    public var keyPaths: [KP] {
        map(\.keyPath)
    }
}

extension NameKeyPathPairs : Sequence, Collection, RandomAccessCollection {
    public typealias Element = (name: Name, keyPath: KP)
    public typealias Index = Array<Element>.Index
    public var startIndex: Index { data.startIndex }
    public var endIndex: Index { data.endIndex }
    public subscript(position: Index) -> Element { data[position] }
}

extension RandomAccessCollection {
    public func applied<Root, Value>(to root: Root) -> [Value] where Element : KeyPath<Root, Value> {
        map { root[keyPath: $0] }
    }
}

Remaining Code of Example

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()
    }
}

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...
Related