SwiftUI 简化许多 TextFields 的 .onChange 修饰符

SwiftUI Simplify .onChange Modifier for Many TextFields

我正在寻找一种方法 simplify/refactor 在 SwiftUI 中添加 .onChange(of:) 具有许多 TextField 的视图。如果解决方案简洁,我也会移动修饰符 更接近适当的字段,而不是在 ScrollView 的末尾。在这个 在这种情况下,所有 .onChange 修饰符都调用相同的函数。

示例:

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

我试过“oring”字段。这不起作用:

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

这是我要调用的简单函数:

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

如有任何指导,我们将不胜感激。 Xcode 13.2.1 iOS 15

任何时候复制代码时,您都希望将其向下移动一层,以便重复使用相同的代码。

这是一个解决方案,父视图将保存一个变量,该变量将知道“名称”作为一个整体是否发生了变化。

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

另一种方法是为 pubFirstNamepubLastName 创建一个组合发布者。 将以下功能添加到您的 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()
    }

并听取 nameChanged 发布者关于 onReceive 您的看法

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

因此您可以收听名字或姓氏的更改。 没有测试代码,只是作为一个想法。

这是我想出的一个相当枯燥的方法。显然,一旦您编写了定义 NameKeyPathPairs 结构的代码,以及对 Array 的扩展等,使用起来就非常简单。

用法示例

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 辅助代码

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: [=11=]] }
    }
}

示例的剩余代码

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

解决方案概述

我们扩展了 Binding 类型,创建了两个新方法,它们都被称为 onChange.

这两种 onChange 方法都旨在用于需要在 Binding 实例的 wrappedValue 属性 为 [=59= 时执行某些工作的情况]更改(不仅仅是设置)通过它的set方法。

第一个 onChange 方法 不会 Binding 实例的 wrappedValue 属性 的新值传递给提供了 on-change 回调方法,而第二个 onChange 方法 确实 为它提供了新值。

第一个 onChange 方法允许我们重构它:

bindingToProperty.onChange { _ in
    changeBackButton()
}

对此:

bindingToProperty.onChange(perform: changeBackButton)

解决方案

助手代码

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

用法

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

解决方案的好处

  1. 帮助程序代码和使用代码都很简单,而且保持非常小。
  2. 它使 onChange-callback 非常 靠近 Binding 实例提供给 TextField/TextEditor/other 类型的地方。
  3. 它是通用的,并且非常通用,因为它可以用于任何具有wrappedValue 属性 的 符合 Equatable 协议的任何 类型。
  4. Binding 个具有更改回调的实例,看起来就像 Binding 个没有更改回调的实例。因此,没有提供这些 Binding 具有更改回调的实例的类型,需要特殊修改才能知道如何处理它们。
  5. 帮助程序代码不涉及创建任何新的 View@State 属性、ObservableObjectEnvironmentKeyPreferenceKey's,或任何其他类型。它只是向名为 Binding 的现有类型添加了几个方法 - 这显然是一种已经在代码中使用的类型...

查看此问题的 twoStraws 解决方案 - 添加绑定协议的扩展 - 允许您向任何 state/binding 变量添加修饰符并在使用该变量时调用函数。很干净。