'How to define a protocol to include a property with @Published property wrapper

When using @Published property wrapper following current SwiftUI syntax, it seems very hard to define a protocol that includes a property with @Published, or I definitely need help :)

As I'm implementing dependency injection between a View and it's ViewModel, I need to define a ViewModelProtocol so to inject mock data to preview easily.

This is what I first tried,

protocol PersonViewModelProtocol {
    @Published var person: Person
}

I get "Property 'person' declared inside a protocol cannot have a wrapper".

Then I tried this,

protocol PersonViewModelProtocol {
    var $person: Published
}

Obviously didn't work because '$' is reserved.

I'm hoping a way to put a protocol between View and it's ViewModel and also leveraging the elegant @Published syntax. Thanks a lot.



Solution 1:[1]

You have to be explicit and describe all synthetized properties:

protocol WelcomeViewModel {
    var person: Person { get }
    var personPublished: Published<Person> { get }
    var personPublisher: Published<Person>.Publisher { get }
}

class ViewModel: ObservableObject {
    @Published var person: Person = Person()
    var personPublished: Published<Person> { _person }
    var personPublisher: Published<Person>.Publisher { $person }
}

Solution 2:[2]

A workaround my coworker came up with is to use a base class that declares the property wrappers, then inherit it in the protocol. It still requires inheriting it in your class that conforms to the protocol as well, but looks clean and works nicely.

class MyPublishedProperties {
    @Published var publishedProperty = "Hello"
}

protocol MyProtocol: MyPublishedProperties {
    func changePublishedPropertyValue(newValue: String)
}

class MyClass: MyPublishedProperties, MyProtocol {
    changePublishedPropertyValue(newValue: String) {
        publishedProperty = newValue
    }
}

Then in implementation:

class MyViewModel {
    let myClass = MyClass()

    myClass.$publishedProperty.sink { string in
        print(string)
    }

    myClass.changePublishedPropertyValue("World")
}

// prints:
//    "Hello"
//    "World"

Solution 3:[3]

My MVVM approach:

// MARK: View

struct ContentView<ViewModel: ContentViewModel>: View {
    @ObservedObject var viewModel: ViewModel

    var body: some View {
        VStack {
            Text(viewModel.name)
            TextField("", text: $viewModel.name)
                .border(Color.black)
        }
    }
}

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

// MARK: View model

protocol ContentViewModel: ObservableObject {
    var name: String { get set }
}

final class ContentViewModelImpl: ContentViewModel {
    @Published var name = ""
}

final class ContentViewModelMock: ContentViewModel {
    var name: String = "Test"
}

How it works:

  • ViewModel protocol inherits ObservableObject, so View will subscribe to ViewModel changes
  • property name has getter and setter, so we can use it as Binding
  • when View changes name property (via TextField) then View is notified about changes via @Published property in ViewModel (and UI is updated)
  • create View with either real implementation or mock depending on your needs

Possible downside: View has to be generic.

Solution 4:[4]

This is how I suppose it should be done:

public protocol MyProtocol {
    var _person: Published<Person> { get set }
}

class MyClass: MyProtocol, ObservableObject {
    @Published var person: Person

    public init(person: Published<Person>) {
        self._person = person
    }
}

Although the compiler seems to sort of like it (the "type" part at least), there is a mismatch in the property's access control between the class and the protocol (https://docs.swift.org/swift-book/LanguageGuide/AccessControl.html). I tried different combinations: private, public, internal, fileprivate. But none worked. Might be a bug? Or missing functionality?

Solution 5:[5]

We've encountered this as well. As of Catalina beta7, there doesn't seem to be any workaround, so our solution is to add in a conformance via an extension like so:


struct IntView : View {
    @Binding var intValue: Int

    var body: some View {
        Stepper("My Int!", value: $intValue)
    }
}

protocol IntBindingContainer {
    var intValue$: Binding<Int> { get }
}

extension IntView : IntBindingContainer {
    var intValue$: Binding<Int> { $intValue }
}

While this is a bit of extra ceremony, we can then add in functionality to all the IntBindingContainer implementations like so:

extension IntBindingContainer {
    /// Reset the contained integer to zero
    func resetToZero() {
        intValue$.wrappedValue = 0
    }
}

Solution 6:[6]

I came up with a fairly clean workaround by creating a generic ObservableValue class that you can include in your protocols.

I am unsure if there are any major drawbacks to this, but it allows me to easily create mock/injectable implementations of my protocol while still allowing use of published properties.

import Combine

class ObservableValue<T> {
    @Published var value: T
    
    init(_ value: T) {
        self.value = value
    }
}

protocol MyProtocol {
    var name: ObservableValue<String> { get }
    var age: ObservableValue<Int> { get }
}

class MyImplementation: MyProtocol {
    var name: ObservableValue<String> = .init("bob")
    var age: ObservableValue<Int> = .init(29)
}

class MyViewModel {
    let myThing: MyProtocol = MyImplementation()
    
    func doSomething() {
        let myCancellable = myThing.age.$value
            .receive(on: DispatchQueue.main)
            .sink { val in
                print(val)
            }
    }
}

Solution 7:[7]

Until 5.2 we don't have support for property wrapper. So it's necessary expose manually the publisher property.

protocol PersonViewModelProtocol {
    var personPublisher: Published<Person>.Publisher { get }
}

class ConcretePersonViewModelProtocol: PersonViewModelProtocol {
    @Published private var person: Person

    // Exposing manually the person publisher 
    var personPublisher: Published<Person>.Publisher { $person }
    
    init(person: Person) {
        self.person = person
    }

    func changePersonName(name: String) {
        person.name = name
    }
}

final class PersonDetailViewController: UIViewController {
    
    private let viewModel = ConcretePersonViewModelProtocol(person: Person(name: "Joao da Silva", age: 60))

    private var cancellables: Set<AnyCancellable> = []

    func bind() {
        viewModel.personPublisher
            .receive(on: DispatchQueue.main)
            .sink { person in
                print(person.name)
            }
            .store(in: &cancellables)
        viewModel.changePersonName(name: "Joao dos Santos")
    }
}

Solution 8:[8]

Try this

import Combine
import SwiftUI

// MARK: - View Model

final class MyViewModel: ObservableObject {

    @Published private(set) var value: Int = 0

    func increment() {
        value += 1
    }
}

extension MyViewModel: MyViewViewModel { }

// MARK: - View

protocol MyViewViewModel: ObservableObject {

    var value: Int { get }

    func increment()
}

struct MyView<ViewModel: MyViewViewModel>: View {

    @ObservedObject var viewModel: ViewModel

    var body: some View {

        VStack {
            Text("\(viewModel.value)")

            Button("Increment") {
                self.viewModel.increment()
            }
        }
    }
}

Solution 9:[9]

I succeeded in just requiring the plain variable, and by adding the @Published in the fulfilling class:

final class CustomListModel: IsSelectionListModel, ObservableObject {



    @Published var list: [IsSelectionListEntry]


    init() {

        self.list = []
    }
...
protocol IsSelectionListModel {


    var list: [IsSelectionListEntry] { get }
...

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 Mycroft Canner
Solution 2 LazyTriceratops
Solution 3 Murlakatam
Solution 4 kontiki
Solution 5 marcprux
Solution 6 gh123man
Solution 7 Andre Lara
Solution 8 Emil Landron
Solution 9 Hardy