Search code examples
swiftuicombine

How to re-render UI in response to computed property buried in a nested class?


It's not clear to me how you would combine the output of the following computed property, to the UI.

var isComplete: Bool {
    Set([.givenName, .familyName]).isSubset(of: elements)
}

I essentially want the user interface to update if the above changes. How would I do this using Combine?

Reactive programming demands that I now think backwards and I'm having trouble thinking about model <<< UI rather than model >>> UI.

Here is the code in context.

struct EditPersonView: View {
    
    let model: ViewModel
            
    private var captionView: some View {
        HStack {
        /*
          stuff
        */
            if submitted && model.name.isComplete {
                Spacer()
                Text("select".localizedCapitalized) + Text(" ") + Text("save") + Text(" ") + Text("👆")
            }
        }
    }

    var body: some View {
    /*
    stuff - including captionView
    */
    }
}

extension EditPersonView {
    
    final class ViewModel {
        
        let name: PersonName
        
        init(person: Person) {
            self.name = PersonName(for: person)
        }
    }
}

extension EditPersonView.ViewModel {
    
    final class PersonName {
        
        let person: Person
        
        private let formatter = PersonNameComponentsFormatter()
        
        init(for person: Person) {
            self.person = person
        }
        
        var text: String {
            get { person.name ?? "" }
            set { person.name = newValue }
        }
        
        private var components: PersonNameComponents? {
            formatter.personNameComponents(from: text)
        }
        
        var givenName: String? {
            components?.givenName
        }
        
        var familyName: String? {
            components?.familyName
        }
        
        private func isValid(component: String?) -> Bool {
            if let name = component, name.count > 1 {
                return true
            }
            return false
        }
        
        var elements: Set<Elements> {
            var collection = Set<Elements>()
            if isValid(component: givenName) { collection.insert(.givenName) }
            if isValid(component: familyName) { collection.insert(.familyName) }
            return collection
        }
        
        var isComplete: Bool {
            Set([.givenName, .familyName]).isSubset(of: elements)
        }
    }
}

extension EditPersonView.ViewModel.PersonName {
    
    enum Elements {
        case givenName, familyName
    }
}

Solution

  • Below is what I came up with.

    The root of the sequence is the textPublisher. This begins the sequence with the values sent to text.

    didSet sends the text to the sequence and saves it in the person's name just as the original code does.

    isComplete becomes a publisher that sends true or false depending on whether the components are valid. The chain of map operators each take the value through one step of the computations in your original code. You could easily reduce this to a single map I would think. (or filter the computations out into functions with meaningful names and substitute the functions for the closures)

    An external Subscriber could subscribe to isComplete and respond when it emits a true value.

    final class PersonName {
        var person: Person
    
        private let formatter = PersonNameComponentsFormatter()
    
        let textPublisher = PassthroughSubject<String, Never>()
        var text: String {
            get { person.name ?? "" }
            set { textPublisher.send(newValue); person.name = newValue }
        }
    
        var isComplete : AnyPublisher<Bool, Never>!
    
        init(for person: Person) {
            self.person = person
    
            isComplete = textPublisher
                .map{ self.formatter.personNameComponents(from: $0) }
                .map{ (components: PersonNameComponents?) -> Set<Elements> in
                    var collection = Set<Elements>()
    
                    if let components = components {
                        if self.isValid(component: components.givenName) { collection.insert(.givenName) }
                        if self.isValid(component: components.familyName) { collection.insert(.familyName) }
                    }
    
                    return collection
                }
                .map { Set([Elements.givenName, Elements.familyName]).isSubset(of: $0) }
                .eraseToAnyPublisher()
        }
    
        private func isValid(component: String?) -> Bool {
            if let name = component, name.count > 1 {
                return true
            }
            return false
        }
    }