Creating a Tap Publisher for SwiftUI Buttons
A key tenet of MVVM is creating View layers that have no knowledge of the Model or View Model’s implementation. This approach is relatively simplistic when working with UIKit.
Views and View Models
In essence, we want a way to notify our view model that some action has occurred on the view. Using a framework like RXSwift and RXCocoa makes it incredibly easy to achieve this goal because RXCocoa exposes an observable that your view model can subscribe to and observe. It’s as easy as button.rx.tap
.
But what about Combine and SwiftUI?
For starters, Combine does not provide any straightforward way to observe events created by views. For our use case, we want the view model to be notified when a button in the view is tapped. After spending countless hours researching and tinkering, I came up with a method of observing SwiftUI buttons taps.
Plan of Action
Our first step is to create a custom button like so:
struct CustomButton: View {
let title: String
var body: some View {
Button(action: {}) {
Text(title)
.bold()
.font(.system(size: 18))
}
.foregroundColor(.white)
.padding()
.background(Color(.blue))
.cornerRadius(2)
}
}
The code segment above simply creates a custom button embedded in it. It is initialized as such: CustomButton(title: Dummy Button")
.
Our next task is to somehow notify our view model that the button has been tapped. Well, we should create a publisher that essentially tells its subscribers that a new tap event has occurred. As you probably guessed, here’s where Combine comes into action. We will utilize combine’s passthroughsubject
publisher. We need to plug this into our custom button class. passthroughsubject
simply passes new values to its observers as it receives them.
struct CustomButton: View {
let title: String
private let tapSubject = PassthroughSubject<Void, Never>()
var tap: AnyPublisher<Void,Never> {
tapSubject.eraseToAnyPublisher()
}var body: some View {
Button(action: tapAction) {
Text(title)
.bold()
.font(.system(size: 18))
}
.foregroundColor(.white)
.padding()
.background(Color(.blue))
.cornerRadius(2)
}
func tapAction() {
tapSubject.send()
}}
Okay! a lot has happened since we last saw our custom class. Let’s break it down:
1, We set our publisher (tapSubject) to private as we only want the custom button struct to send or emit a value when the button is tapped.
2, We provide a tap variable that exposes our passthroughsubject
as a type-erased publisher that other entities can interact with.
— — quick break here to refresh and absorb that info — -
3, We create a new function called tapAction that simply emits a void event when called.
4, We then set our custom button’s action parameter to tapAction
. This ensures that whenever our button is clicked, a new void event is published, signifying that our button was clicked.
So far we have successfully created a publisher for our button. But how do we actually observe the events that our publisher is emitting?
Observing our changes
class GenericSubscriber<T>: Subscriber {
typealias Input = T
typealias Failure = Never
public private(set) var map: ((Input)->Void)?public init(map: ((Input)->Void)? = nil) {
self.map = map
}func receive(subscription: Subscription) {
subscription.request(.unlimited)
}func receive(subscription: Subscription) {
self.map?(input)
return .none
}func receive(subscription: Subscription) {
self.map = nil
}}
In the segment above, we create a custom subscriber that conforms to the Combine Subscriber protocol. We also include a completion that enables us to perform tasks when we receive new events from our publisher.
Everything we have done to this point only sets us from a Combine standpoint, we still need to hook up this logic to our button. To do that, I created a custom View modifier that enables a subscriber to subscribe to our button’s publisher.
extension CustomButton {
func bindTap(to: GenericSubscriber<Void>) -> Self {
let copy = self
copy.tap.subscribe(to)
return copy
}
}
Finally, we can set everything up!
class CustomViewModel: ObservableObject
{
var didTap : GenericSubscriber<Void>!
init() {
didTap = GenericSubscriber<Void>()
{ [unowned self] (value) in
print(“The button was tapped”) // no to retain cycles!
}
}
}struct ContentView: View {
@ObservedObject var vm: CustomViewModel
var body: some View {
CustomButton(title: “Dummy Button”)
.bindTap(to: vm.didTap)
}
}
And there we are! Clicking our button reveals our “The button was tapped” text. Successfully creating a publisher for our button’s tap event.
DISCLAIMER
This solution is more of a proof of concept, further testing should be undertaken to ensure that it works for your use case.
There’s a lot of finer details I would have loved to discuss but I hope this is enough to get you going with Combine!