Combine — Handling UIKit’s gestures

While Combine doesn’t provide a built-in API to handle UIKit’s gestures yet, the Publisher and Subscription protocols give us the ability to create our own solution.

— Creating a custom publisher

First, let’s create a custom publisher by conforming to the Publisher protocol which is composed of two associated types namely Output and Failure. Output represents the type of value the publisher will produce and Failure is the type of error it can throw.

struct GesturePublisher: Publisher {
    typealias Output = GestureType
    typealias Failure = Never
    private let view: UIView
    private let gestureType: GestureType
    
    init(view: UIView, gestureType: GestureType) {
        self.view = view
        self.gestureType = gestureType
    }
    
    func receive<S>(subscriber: S) where S : Subscriber, GesturePublisher.Failure == S.Failure, GesturePublisher.Output == S.Input {
        let subscription = GestureSubscription(
            subscriber: subscriber, 
            view: view, 
            gestureType: gestureType
        )
        subscriber.receive(subscription: subscription)
    }
}

enum GestureType {
    case tap(UITapGestureRecognizer = .init())
    case swipe(UISwipeGestureRecognizer = .init())
    case longPress(UILongPressGestureRecognizer = .init())
    case pan(UIPanGestureRecognizer = .init())
    case pinch(UIPinchGestureRecognizer = .init())
    case edge(UIScreenEdgePanGestureRecognizer = .init())
    
    func get() -> UIGestureRecognizer {
        switch self {
        case let .tap(tapGesture):
            return tapGesture
        case let .swipe(swipeGesture):
            return swipeGesture
        case let .longPress(longPressGesture):
            return longPressGesture
        case let .pan(panGesture):
            return panGesture
        case let .pinch(pinchGesture):
            return pinchGesture
        case let .edge(edgePanGesture):
            return edgePanGesture
       }
    }
}

Our custom GesturePublisher provides with a GestureType — an enum with default gesture recognizer values — and must never error out. The Publisher protocol has one required method receive<S>(subscriber: S) which attaches the specified subscriber to this publisher. This method will be called once every time the publisher receives a new subscriber. The sink method will be used later on to create our subscriber.

A quick reminder on how Publisher and Subscriber works together.

  1. The Subscriber subscribes to a Publisher
  2. The Publisher gives Subscription to the Subscriber
  3. The Subscriber request values
  4. The Publisher sends values
  5. The Publisher sends a completion event
  6. Once a subscriber is created, it needs to receive a subscription.

— Creating a custom subscription

class GestureSubscription<S: Subscriber>: Subscription where S.Input == GestureType, S.Failure == Never {
    private var subscriber: S?
    private var gestureType: GestureType
    private var view: UIView
    
    init(subscriber: S, view: UIView, gestureType: GestureType) {
        self.subscriber = subscriber
        self.view = view
        self.gestureType = gestureType
        configureGesture(gestureType)
    }
    
    private func configureGesture(_ gestureType: GestureType) {
        let gesture = gestureType.get()
        gesture.addTarget(self, action: #selector(handler))
        view.addGestureRecognizer(gesture)
    }
    
    func request(_ demand: Subscribers.Demand) { }
    
    func cancel() {
        subscriber = nil
    }
    
    @objc
    private func handler() {
        _ = subscriber?.receive(gestureType)
    }
}

Our GestureSubscription object must conform to the Subscription protocol which has two required methods namely request(_ demand: Subscribers.Demand) and cancel(). Since a publisher emits values to a downstream subscriber, its Output type must match the Subscriber’s Input type, same goes for Failure.

We’ll be using the cancel() method to cancel our subscription hence the use of optional on the subscriber property. While our implementation is almost done, our subscribers won’t be able to receive any values yet since we didn’t really handle the user gesture.

Let’s fix it by adding the provided recognizer to the view and passing the gesture type to the subscriber from our selector.

— Finalizing the implementation

Wrapping it all up by using a method in a UIView extension which will create a custom publisher using the provided gesture type.

extension UIView {
    func gesture(_ gestureType: GestureType = .tap()) ->
    GesturePublisher {
        .init(view: self, gestureType: gestureType)
    }
}

var cancellables = Set<AnyCancellable>()

view.gesture().sink { recognizer in
    print("Tapped !")
}.store(in: &cancellables)
// prints "Tapped !"
view.gesture(.swipe()).sink { recognizer in
    print("Swiped !")
}.store(in: &cancellables)
// prints "Swiped !"

Let’s combine (:D) all of our gestures to leverage the full power of the framework.

let tap = view.gesture(.tap())
let swipe = view.gesture(.swipe())
let longPress = view.gesture(.longPress())
let pan = view.gesture(.pan())
let pinch = view.gesture(.pinch())
let edge = view.gesture(.edge())

Publishers.MergeMany(tap, swipe, longPress, pan, pinch, edge)
    .sink(receiveValue: { gesture in
        switch gesture {
        case let .tap(tapRecognizer):
            print("Tapped !")
        case let .swipe(swipeRecognizer):
            print("Swiped !")
        case let .longPress(longPressRecognizer):
            print("Long pressed !")
        case let .pan(panRecognizer):
            print("Panned !")
        case let .pinch(pinchRecognizer):
            print("Pinched !")
        case let .edge(edgesRecognizer):
            print("Panned edges !")
        }
}).store(in: &cancellables)

We may now properly handle the user gesture and define a specific action using the provided associated recognizer.

— Conclusion

Combine enables to interact quite easily with UIKit by customizing a publisher and a subscription to handle the user gestures in a reactive way.