How to define a protocol as a type for a @ObservedObject property?

We have found a solution in our small library by writing a custom property wrapper. You can have a look at XUI.

There are essentially two issues at hand:

  1. the associated type requirement in ObservableObject
  2. the generic constraint on ObservedObject

By creating a similar protocol to ObservableObject (without associated type) and a protocol wrapper similar to ObservedObject (without the generic constraint), we can make this work!

Let me show you the protocol first:

protocol AnyObservableObject: AnyObject {
    var objectWillChange: ObservableObjectPublisher { get }
}

That is essentially the default form of ObservableObject, which makes it quite easy for new and existing components to conform to that protocol.

Secondly, the property wrapper - it is a bit more complex, which is why I will simply add a link. It has a generic attribute without a constraint, which means that we can use it with protocols as well (simply a language restriction as of now). However, you will need to make sure to only use this type with objects conforming to AnyObservableObject. We call that property wrapper @Store.

Okay, now let's go through the process of creating and using a view model protocol:

  1. Create view model protocol
protocol ItemViewModel: AnyObservableObject {
    var title: String { get set }

    func save()
    func delete()
}
  1. Create view model implementation
class MyItemViewModel: ItemViewModel, ObservableObject {

    @Published var title = ""

    func save() {}
    func delete() {}

}
  1. Use the @Store property wrapper in your view:
struct ListItemView: View {
    @Store var viewModel: ListItemViewModel

    var body: some View {
        // ...
    }

}

Wrappers and stored properties are not allowed in swift protocols and extensions, at least for now. So I would go with the following approach mixing protocols, generics and classes... (all compilable and tested with Xcode 11.2 / iOS 13.2)

// base model protocol
protocol ItemViewModel: ObservableObject {
    var title: String { get set }

    func save()
    func delete()
}

// generic view based on protocol
struct ItemView<Model>: View where Model: ItemViewModel {
    @ObservedObject var viewModel: Model

    var body: some View {
        VStack {
            TextField("Item Title", text: $viewModel.title)
            Button("Save") { self.viewModel.save() }
        }
    }
}

// extension with default implementations
extension ItemViewModel {
    
    var title: String {
        get { "Some default Title" }
        set { }
    }
    
    func save() {
        // some default behaviour
    }

    func delete() {
        // some default behaviour
    }
}

// concrete implementor
class SomeItemModel: ItemViewModel {
    @Published var title: String
    
    init(_ title: String) {
        self.title = title
    }
}

// testing view
struct TestItemView: View {
    var body: some View {
        ItemView(viewModel: SomeItemModel("test"))
    }
}

I think type erasure is the best answer to this.

So, your protocol remains unchanged. You have:

protocol ItemViewModel: ObservableObject {
    var title: String { get set }

    func save()
    func delete()
}

So we need a concrete type the view can always depend on (things can get crazy if too many views become generic on the view model). So we'll create a type erasing implementation.

class AnyItemViewModel: ItemViewModel {
    var title: title: String { titleGetter() }
    private let titleGetter: () -> String

    private let saver: () -> Void
    private let deleter: () -> Void

    let objectWillChange: AnyPublisher<Void, Never>

    init<ViewModel: ItemViewModel>(wrapping viewModel: ViewModel) {
        self.objectWillChange = viewModel
            .objectWillChange
            .map { _ in () }
            .eraseToAnyPublisher()
        self.titleGetter = { viewModel.title }
        self.saver = viewModel.save
        self.deleter = viewModel.delete
    }

    func save() { saver() }
    func delete() { deleter() }
}

For convenience, we can also add an extension to erase ItemViewModel with a nice trailing syntax:

extension ItemViewModel {
   func eraseToAnyItemViewModel() -> AnyItemViewModel {
        AnyItemViewModel(wrapping: self)
   }
}

At this point your view can be:

struct ItemView: View {
    @ObservedObject var viewModel: AnyItemViewModel

    var body: some View {
        TextField($viewModel.title, text: "Item Title")
        Button("Save") { self.viewModel.save() }  
    }
}

You can create it like this (Great for previews):

ItemView(viewModel: DummyItemViewModel().eraseToAnyItemViewModel())

Technically, you can do the type erasing in the view initializer, but then you actually would have to write that initializer and it feels a little off to do that.