1

I've got an @Published protocol array that I am looping through with a ForEach to present the elements in some view. I'd like to be able to use SwiftUI bindable syntax with the ForEach to generate a binding for me so I can mutate each element and have it reflected in the original array.

This seems to work for the properties that are implemented in the protocol, but I am unsure how I would go about accessing properties that are unique to the protocol's conforming type. In the example code below, that would be the Animal's owner property or the Human's age property. I figured some sort of type casting might be necessary, but can't figure out how to retain the reference to the underlying array via the binding.

Let me know if you need more detail.

import SwiftUI

protocol Testable {
    var id: UUID { get }
    var name: String { get set }
}

struct Human: Testable {
    let id: UUID
    var name: String
    var age: Int
}

struct Animal: Testable {
    let id: UUID
    var name: String
    var owner: String
}

class ContentViewModel: ObservableObject {
    @Published var animalsAndHumans: [Testable] = []
}

struct ContentView: View {
    @StateObject var vm: ContentViewModel = ContentViewModel()
    var body: some View {
        VStack {
            ForEach($vm.animalsAndHumans, id: \AnyTestable.id) { $object in
                TextField("textfield", text: $object.name)
                // if the object is an Animal, how can I get it's owner?
            }
            Button("Add animal") {
                vm.animalsAndHumans.append(Animal(id: UUID(), name: "Mick", owner: "harry"))
            }
            Button("Add Human") {
                vm.animalsAndHumans.append(Human(id: UUID(), name: "Ash", age: 26))
            }
        }
    }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

2 Answers 2

2

This is a thorny problem with your data types.

If you can change your data types, you can make this easier to solve.

For example, maybe you can model your data like this instead, using an enum instead of a protocol to represent the variants:

struct Testable {
    let id: UUID
    var name: String
    var variant: Variant

    enum Variant {
        case animal(Animal)
        case human(Human)
    }

    struct Animal {
        var owner: String
    }

    struct Human {
        var age: Int
    }
}

It will also help to add accessors for the two variants' associated data:

extension Testable {
    var animal: Animal? {
        get {
            guard case .animal(let animal) = variant else { return nil }
            return animal
        }
        set {
            guard let newValue = newValue, case .animal(_) = variant else { return }
            variant = .animal(newValue)
        }
    }

    var human: Human? {
        get {
            guard case .human(let human) = variant else { return nil }
            return human
        }
        set {
            guard let newValue = newValue, case .human(_) = variant else { return }
            variant = .human(newValue)
        }
    }
}

Then you can write your view like this:

class ContentViewModel: ObservableObject {
    @Published var testables: [Testable] = []
}

struct ContentView: View {
    @StateObject var vm: ContentViewModel = ContentViewModel()
    var body: some View {
        VStack {
            List {
                ForEach($vm.testables, id: \.id) { $testable in
                    VStack {
                        TextField("Name", text: $testable.name)

                        if let human = Binding($testable.human) {
                            Stepper("Age: \(human.wrappedValue.age)", value: human.age)
                        }

                        else if let animal = Binding($testable.animal) {
                            HStack {
                                Text("Owner: ")
                                TextField("Owner", text: animal.owner)
                            }
                        }
                    }
                }
            }

            HStack {
                Button("Add animal") {
                    vm.testables.append(Testable(
                        id: UUID(),
                        name: "Mick",
                        variant: .animal(.init(owner: "harry"))
                    ))
                }
                Button("Add Human") {
                    vm.testables.append(Testable(
                        id: UUID(),
                        name: "Ash",
                        variant: .human(.init(age: 26))
                    ))
                }
            }
        }
    }
}
Sign up to request clarification or add additional context in comments.

1 Comment

Hey Rob, this is great, thanks so much. Regarding the code if let human = Binding($testable.human) I had no idea Binding had such an initializer. I'm going to have to read into how that works. Apple Docs look a little sparse. If you have any links to resources I'd love to look at them. Thanks again.
1

Simple way to solve is extending your Testable protocol. Something likes

protocol Testable {
    var id: UUID { get }
    var name: String { get set }
    var extra: String? { get }
}

struct Human: Testable {
    let id: UUID
    var name: String
    var age: Int
    var extra: String? { nil }
}

struct Animal: Testable {
    let id: UUID
    var name: String
    var owner: String
    var extra: String? { return owner }
}

Your ForEach block doesn't need to know the concrete type: Animal or Human, just check the extra of Testable to decide to add new element or not.

1 Comment

Thanks a lot for this Quang, I selected Rob's answer as correct as I would like to be able to access the unique properties for all conforming types and I feel like this might get a little unwieldy if I've got several unique properties and/or several conforming types. Nonetheless, this totally does answer my question as well. Gave it an upvote.

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge you have read our privacy policy.

Start asking to get answers

Find the answer to your question by asking.

Ask question

Explore related questions

See similar questions with these tags.