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:
- the associated type requirement in
ObservableObject
- 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:
- Create view model protocol
protocol ItemViewModel: AnyObservableObject {
var title: String { get set }
func save()
func delete()
}
- Create view model implementation
class MyItemViewModel: ItemViewModel, ObservableObject {
@Published var title = ""
func save() {}
func delete() {}
}
- 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.