69

I have a swiftui view that depends on a view model, the view model has some published properties. I want define a protocol and default implementation for the view model hierarchy, and make the view dependent on the protocol not the concrete class?

I want to be able to write the following:

protocol ItemViewModel: ObservableObject {
    @Published var title: String

    func save()
    func delete()
}

extension ItemViewModel {
    @Published var title = "Some default Title"

    func save() {
        // some default behaviour
    }

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


struct ItemView: View {
    @ObservedObject var viewModel: ItemViewModel

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

// What I have now is this:

class AbstractItemViewModel: ObservableObject {
    @Published var title = "Some default Title"

    func save() {
        // some default behaviour
    }

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

class TestItemViewModel: AbstractItemViewModel {
    func delete() {
        // some custom behaviour
    }
}

struct ItemView: View {
    @ObservedObject var viewModel: AbstractItemViewModel

    var body: some View {
        TextField($viewModel.title, text: "Item Title")
        Button("Save") { self.viewModel.save() } 
    }
}
M.Serag
  • 1,381
  • 1
  • 11
  • 15

6 Answers6

91

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

backup

Asperi
  • 228,894
  • 20
  • 464
  • 690
  • Does this mean that I've to repeat all the properties I want to be wrapped with @Published in every view model concrete implementation? no way till now to make that a default protocol implementation? – M.Serag Dec 28 '19 at 09:12
  • 1
    I think never be, because `@Published var some`, generates private stored property `_some`, which is forbidden for protocols. – Asperi Dec 28 '19 at 09:17
  • 1
    Another approach is not to use `@Published` but rather manually publish changes through `objectWillChange` publisher. By doing this way we can get rid of default protocol implementations. More info here: https://www.hackingwithswift.com/books/ios-swiftui/manually-publishing-observableobject-changes – Bartosz Olszanowski Jul 09 '20 at 08:11
  • no way to make this work for environment var right? – Nicolas Degen Sep 24 '20 at 16:53
  • 1
    @NicolasDegen I find this works with `@EnvironmentObject` as well, except it requires manually specifying the concrete class for the Generic. e.g. `NavigationLink(destination: ItemView()) { ... }` – Jeremy Apr 05 '21 at 22:34
  • true, I've figured that out since:) – Nicolas Degen Apr 06 '21 at 13:16
14

This post is similar to some others, but it's just the required template for a published variable without distractions.

protocol MyViewModel: ObservableObject {
    var lastEntry: String { get }
}

class ActualViewModel: MyViewModel {
    @Published private(set) var lastEntry: String = ""
}

struct MyView<ViewModel>: View where ViewModel: MyViewModel {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        Text(viewModel.lastEntry)
    }
}

The View's generic ViewModel: MyViewModel constraint lets the compiler know that it needs to build out logic for any type that use the MyViewModel protocol

dwsolberg
  • 879
  • 9
  • 8
10

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 {
        // ...
    }

}
Paul
  • 101
  • 1
  • 5
9

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.

  • I love your solution Christopher! Can you please explain what is going on when you do objectWillChange.map({ _ in }).eraseToAnyPublisher for self.objectWillChange? And why we need that objectWillChange? Thank you! – bodich Jul 24 '21 at 06:41
  • The ObservableObject protocol requires an objectWillChange publisher, but leaves the details to an associated type. (Meaning implementing concrete types decide the details.) So the map is required because we need to convert whatever (unknown/varied) type of publisher the wrapped type might provide to a specific concrete type that our type erasing implementation promises. – Christopher Thiebaut Jul 25 '21 at 13:21
  • Great solution Christopher. Can you elaborate on "things can get crazy if too many views become generic on the view model" ? – ettore Apr 10 '23 at 23:09
  • 1
    @ettore I meant that your nested types get quite complicated if that generic nesting goes on long enough. It become less ergonomic and more visually noisy if all of your types are forced to hold generics for purely implementation detail reasons. So generally I prefer to use type erasure to limit that. Of course, this answer is somewhat outdated now given the any keyword that Swift now has for type erasure. – Christopher Thiebaut Apr 12 '23 at 19:37
  • 1
    How is it outdated @ChristopherThiebaut? I'm using `any`, but in a concrete case, I still have to erase the type: `@ViewBuilder func f(_ t: any P) { SomeView(observed: t.eraseToAnyP) }`. If I declare `SomeView { @ObservedObject var observed: any P }` I still get `Type 'any P' cannot conform to 'ObservableObject'` – Tae Aug 08 '23 at 08:55
  • @Tae, you're right. I wrote that it was outdated a little too quickly after type erasure was introduced and I wasn't sufficiently aware of the limitations at that point. – Christopher Thiebaut Aug 25 '23 at 22:44
  • Thanks for clarifying, @ChristopherThiebaut! – Tae Aug 26 '23 at 15:22
7

Ok I spent some time figuring these out, but once I got it right, everything makes sense.

At the moment it is not possible to use PropertyWrappers in protocols. But what you can do is use generics in your View and expect your ViewModels to comply to your protocol. This is specially great if you are testing things or you need to setup something lightweight for the Preview.

I have some sample example here so you can get yours right

Protocol:

protocol UploadStoreProtocol:ObservableObject {

    var uploads:[UploadModel] {get set}

}

ViewModel: You want to make sure your view model is ObservableObject and add @Published to variables that can change

// For Preview
class SamplePreviewStore:UploadStoreProtocol {

    @Published  var uploads:[UploadModel] = []

    init() {
        uploads.append( UploadModel(id: "1", fileName: "Image 1", progress: 0, started: true, errorMessage: nil))
        uploads.append( UploadModel(id: "2", fileName: "Image 2", progress: 47, started: true, errorMessage: nil))
        uploads.append( UploadModel(id: "3", fileName: "Image 3", progress: 0, started: false, errorMessage: nil))
    }
}

// Real Storage
class UploadStorage:UploadStoreProtocol {

    @Published var uploads:[UploadModel] = []

    init() {
        uploads.append( UploadModel(id: "1", fileName: "Image 1", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "2", fileName: "Image 2", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "3", fileName: "Image 3", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "4", fileName: "Image 4", progress: 0, started: false, errorMessage: nil))
        uploads.append( UploadModel(id: "5", fileName: "Image 5", progress: 0, started: false, errorMessage: nil))
    }
    func addItem(){
        uploads.append( UploadModel(id: "\(Int.random(in: 100 ... 100000))", fileName: "Image XX", progress: 0, started: false, errorMessage: nil))
    }
    func removeItemAt(index:Int){
        uploads.remove(at: index)
    }
}

For the UI View you can use generics:

struct UploadView<ViewModel>: View where ViewModel:UploadStoreProtocol {

    @ObservedObject var store:ViewModel
    
    var body: some View {
        List(store.uploads.indices){ item in
            ImageRow(item: $store.uploads[item])
        }.padding()
    }
}

struct ImageRow: View {

    @Binding var item:UploadModel

    var body: some View {
        HStack{
            Image(item.id ?? "")
                .resizable()
                .frame(width: 50.0, height: 50.0)
            VStack (alignment: .leading, spacing: nil, content: {
                Text(item.fileName ?? "-")
                Text(item.errorMessage ?? "")
                    .font(.caption)
                    .foregroundColor(.red)
            })
            Spacer()
            VStack {
                if (item.started){
                    Text("\(item.progress)").foregroundColor(.purple)
                }
                UploadButton(is_started: $item.started)
            }
        }
    }
}

Now your view is ready to get the ViewModel, you can have your store setup externally like this:

@main
struct SampleApp: App {

    @StateObject var uploadStore = UploadStorage()

    var body: some Scene {
        WindowGroup {
            UploadView(store: uploadStore)
        }
    }
}

and for Preview you can have:

struct ContentView_Previews: PreviewProvider {

    @StateObject static var uploadStore = SamplePreviewStore()

    static var previews: some View {
        UploadView(store: uploadStore)
        
    }
}
Amir.n3t
  • 2,859
  • 3
  • 21
  • 28
  • what if instead of @ObservedObject you are using @EnvironmentObject? In that case, in the app code you would need to have UploadView< UploadStorage >(store: uploadStore) every time you use the UploadView which is not convenient – onthemoon Aug 20 '23 at 13:03
0

I am not sure how to use @property wrapper in a protocol. Except that, normal swift rule applies.

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

            func save()
            func delete()
        }

        extension ItemViewModel {
            //var title = "Some default Title"

            func save() {
                // some default behaviour
                title = "save in protocol"
                print("save in protocol")
            }

            func delete() {
                // some default behaviour
                 print("delete in protocol")
            }
        }

        // What I have now is this:

        class AbstractItemViewModel:  ItemViewModel{
            @Published var title = "Some default Title"

        //    func save() {
        //          print("save in class")
        //        // some default behaviour
        //    }
        //
        //    func delete() {
        //         print("delete in class")
        //        // some default behaviour
        //    }
        }

        class TestItemViewModel: AbstractItemViewModel {
             func delete() {
                // some custom behaviour
                title = "delete in"
                  print("delete in ")
            }
        }

        struct ItemView: View {
            @ObservedObject var viewModel: TestItemViewModel

            var body: some View {
                VStack{
                Button(action: { self.viewModel.save()}){
                    Text("protocol save")
                }
                    Button(action: { self.viewModel.delete()}){
                               Text("class delete")
                           }
                    TextField.init ("Item Title", text:  $viewModel.title)}
            }
        }
E.Coms
  • 11,065
  • 2
  • 23
  • 35
  • Is there a way not to make the view dependent on a concrete type, to make it possible to pass different concrete classes having same interface but different implementation? – M.Serag Dec 27 '19 at 17:15
  • sure. `@ObservedObject var viewModel: AbstractItemViewModel` also accept if called with `ItemView(viewModel: TestItemViewModel())` – E.Coms Dec 27 '19 at 17:30