'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 inheritsObservableObject
, soView
will subscribe toViewModel
changes- property
name
has getter and setter, so we can use it asBinding
- when
View
changesname
property (via TextField) then View is notified about changes via@Published
property inViewModel
(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