skip to Main Content

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

5

Answers


  1. 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()
        }
    }
    
    Login or Signup to reply.
  2. 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.

    Login or Signup to reply.
  3. 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()
        }
    }
    
    Login or Signup to reply.
  4. 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…
    Login or Signup to reply.
  5. Why not just use a computed var?

    @State private var something: Int = 1
    @State private var another: Bool = true
    @State private var yetAnother: String = "whatever"
    
    var anyOfMultiple: [String] {[
        something.description,
        another.description,
        yetAnother
    ]}
    
    var body: some View {
        VStack {
            //
        }
        .onChange(of: anyOfMultiple) { _ in
            //
        }
    }
    
    Login or Signup to reply.
Please signup or login to give your own answer.
Back To Top
Search