How to define a protocol to include a property with @Published property wrapper
Asked Answered
H

11

37

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.

Hirsutism answered 15/8, 2019 at 23:24 Comment(2)
I'm really hoping this becomes possible as I have the same issue. I ended up using CurrentValueSubject for my properties instead of @Published as that can happily be used in a protocol.Interrogate
https://mcmap.net/q/426275/-propertywrappers-and-protocol-declarationCozart
C
32

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 }
}
Cozart answered 20/10, 2019 at 9:22 Comment(1)
When updating the ViewModel which property do you set? The . person , . personPublished or . personPublisher ?Adamina
S
21

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.

Scansion answered 7/5, 2021 at 9:18 Comment(3)
Pretty much the solution I was looking for, much appreciated! Makes complete sense, Published will force the entire ObservableObject (viewModel) to trigger the refresh.Jacobi
Not a good solution if you have a big view model. We just need to listen to 'name' here. Why listen to the changes of the whole view model?Openwork
Doesn't work if want to use extension functions on the protocol to share functionality across view models.Roadwork
J
9

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?

Joselyn answered 16/8, 2019 at 15:29 Comment(2)
I have run into the same access control problem you've described. Do you have a resolution for this problem? Or, do we expand the protocol to include the wrapped and projected values be conformed manually?Thimbu
Looks like this behaviour is debated hotly in the swift evolution proposal. Sadly, it seems like whatever the access level of the published variable is, the synthesised variables are either private or internal(I'm confused by some terminologies there).Thimbu
H
9

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"
Horseman answered 19/12, 2019 at 19:7 Comment(0)
B
3

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")
    }
}
Byington answered 7/5, 2022 at 14:6 Comment(0)
K
1

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

Kutzenco answered 1/1, 2022 at 0:52 Comment(1)
I think the drawback could be the inability to use the published value as a Binding, or at least I haven't figured out a way to do it.Cindycine
G
1

The following set up should do.

  • Declare a ViewState class with @Published properties
  • Declare a ViewModel protocol with a ViewState property requirement.
  • Create a ViewModel instance and supply its ViewState instance to View, which shall observe the same.

ViewModel.swift

class ViewState {
    @Published fileprivate(set) var text: String = ""
    @Published fileprivate(set) var number: Int = 0
}

protocol ViewModel {
    var viewState: ViewState { get }
}

ViewModelImpl.swift

class ViewModelImpl: ViewModel {
    var viewState = ViewState()
    
    func onSomeChange() {
        viewState.text = "abc"
        viewState.number = 1
    }
}

View.swift

class View: UIView { //It can also be UIViewController. Shown UIView for simplicity.
    private var cancellables: [AnyCancellable] = []
    var viewState: ViewState? = nil {
        didSet {
            cancellables.removeAll()
            guard let viewState else { return }
            var textCancellable = viewState.$text.sink { recievedText in
                print("Do something")
            }
            var numberCancellable = viewState.$number.sink { recievedNumber in
                print("Do something")
            }
            cancellables = [textCancellable, numberCancellable]
        }
    }
}
Gospel answered 2/3 at 1:26 Comment(1)
Not bad, though not designed for SwiftUI.Roadwork
P
0

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

Peplum answered 4/9, 2019 at 16:15 Comment(0)
B
0

To expose a @Published var you can also just expose the var's get and add update functions to set it or it's properties and then make use of SwiftUI's Binding(get:set:) function.

protocol PersonViewModelProtocol {
    var person: Person { get }
    func updateName(_ name: String)
}

which for example could update the name by setting

Binding(
    get: { viewModel.person.name },
    set: { viewModel.updateName($0) }
) 

Playground Example:

import SwiftUI
import Combine

// Define a Person struct
struct Person {
    var name: String
}

// The protocol that the View will be using
protocol PersonUpdatable: ObservableObject {
    var person: Person { get }
    func updateName(_ name: String)
}

// ViewModel class that conforms to PersonUpdatable and uses @Published for the person
class PersonViewModel: PersonUpdatable {
    @Published private(set) var person: Person
    
    init(person: Person) {
        self.person = person
    }
    
    func updateName(_ name: String) {
        person.name = name
    }
}

struct PersonView<T: PersonUpdatable>: View {
    @ObservedObject var viewModel: T
    
    var body: some View {
        VStack {
            Text("Name: \(viewModel.person.name)")
                .font(.largeTitle)
            
            TextField("Enter new name", text: Binding(
                get: { viewModel.person.name },
                set: { viewModel.updateName($0) }
            ))
            .textFieldStyle(RoundedBorderTextFieldStyle())
            .padding()
        }
        .padding()
    }
}

struct ContentView: View {
    var body: some View {
        let person = Person(name: "Aleksandra")
        let viewModel = PersonViewModel(person: person)
        PersonView(viewModel: viewModel)
    }
}

import PlaygroundSupport
PlaygroundPage.current.setLiveView(ContentView())
Bandwagon answered 6/6 at 8:13 Comment(0)
D
-2

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()
            }
        }
    }
}
Disc answered 10/2, 2020 at 20:48 Comment(1)
Although this code may help to solve the problem, it doesn't explain why and/or how it answers the question. Providing this additional context would significantly improve its long-term value. Please edit your answer to add explanation, including what limitations and assumptions apply.Antennule
B
-2

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 }
...
Burt answered 30/3, 2020 at 12:24 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.