Publishing Updates Using Protocols with Combine on iOS

If you've tried using POP (Protocol-Oriented programming) with SwiftUI and MVVM, you may have run into the problem of being able to observe published changes from your protocol. Unfortunately, protocols don't support property wrappers, which makes it hard to figure out how one would use the @Published property wrapper and still observe updates. Let's take a look at a work around.

Let's say there is a protocol responsible for managing the signed in state of the user:

protocol SessionService {
    var isSignedIn: Bool { get set }
}

If the SessionService is passed to a ViewModel, logic can be written against whether a user is signed in or not.

Ideally, the concrete class would look something like this:

class MyAppSessionService: SessionService {
    @Published var isSignedIn: Bool = false
}

And the ViewModel for the View would look something like the following:

class AuthViewViewModel: ObservableObject {
    
    var isSignedIn: Bool {
        sessionService.isSignedIn
    }
    
    private var sessionService: SessionService = MyAppSessionService()
    
    func signIn() {
        sessionService.isSignedIn = true
    }
    
    func signOut() {
        sessionService.isSignedIn = false
    }
}

The sessionService is initialized with the concrete class MyAppSessionService but has an explicit type SessionService.

The View can then access the different members of the ViewModel to show relevant info to the user:

struct AuthView: View {
    
    @ObservedObject var viewModel: AuthViewViewModel = .init()
    
    var body: some View {
        VStack(spacing: 40) {
            Text(viewModel.isSignedIn ? "Signed In" : "Signed Out")
            
            Button("Sign In", action: viewModel.signIn)
            Button("Sign Out", action: viewModel.signOut)
        }
    }
}

At first glance, it seems like pressing the buttons should update the Text that is being rendered to the screen. However, whenever the user taps either button, nothing seems to happen. This is because the ViewModel doesn't know that the value inside of the SessionService has changed.

To fix this problem, start by updating the SessionService protocol to the following:

protocol SessionService {
    var isSignedIn: Bool { get set }
    var isSignedInPublisher: Published<Bool>.Publisher { get }
}

The added isSignedInPublisher is a publisher that can notify the ViewModel that changes have been made.

The concrete needs to be updated too:

class MyAppSessionService: SessionService {
    @Published var isSignedIn: Bool = false
    var isSignedInPublisher: Published<Bool>.Publisher { $isSignedIn }
}

The value of isSignedInPublisher is simply the published binding of isSignedIn.

Lastly, the ViewModel needs to simply observe the changes to the isSignedIn value:

     init() {
        observeStatus()
    }
    
    var token: AnyCancellable?
    func observeStatus() {
        token = sessionService.isSignedInPublisher.sink { [weak self] _ in
            self?.objectWillChange.send()
        }
    }

When the ViewModel is initialized, it begins observing isSignedInPublisher and objectWillChange.send() is called since the ViewModel conforms to ObservableObject.

The UI now updates successfully! 🎉

Share on Twitter