Simplifying communication patterns with closure in Swift

Khoa Pham
May 29 · 10 min read
Thanks to Jardson Almeida for the inspiration

Table of Contents

The many communication patterns

In iOS, we can handle button tap by using addTarget which is a function that any UIControl has:

let button = UIButton()
button.addTarget(self, action: #selector(buttonTouched(_:)), for: .touchUpInside)@objc func buttonTouched(_ sender: UIButton) {}
button.target = self
button.action = #selector(buttonTouched(_:))@objc func buttonTouched(_ sender: NSButton) {}
https://www.objc.io/issues/7-foundation/communication-patterns/
button.on.tap { print("button was tapped" }
user.on.propertyChange(\.name) { print("name has changed to \($0)"}
tableView.on.tapRow { print("row \($0) was tapped" }

Associated objects

With Objective C category and Swift extension, we can’t add a stored property, that’s where associated objects come into play. The associated object allows us to attach other objects to the lifetime of any NSObject , with these 2 free functions objc_getAssociatedObject and objc_setAssociatedObject .

Detecting object deallocation

Since the associated object is attached to the host object, there’s this very handy use case that allows detection of the life cycle of the host object. We can, for example, observe UIViewControllerto tell if it has really been deallocated.

class Notifier {
    deinit {
        print("host object was deinited")
    }
}extension UIViewController {
    private struct AssociatedKeys {
        static var notifier = "notifier"
    }var notifier: Notifier? {
        get {
            return objc_getAssociatedObject(self, &AssociatedKeys.notifier) as? Notifier
        }set {
            if let newValue = newValue {
                objc_setAssociatedObject(
                    self,
                    &AssociatedKeys.notifier,
                    newValue as Notifier?,
                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC
                )
            }
        }
    }
}
var viewController: UIViewController? = UIViewController()
viewController?.notifier = Notifier()
viewController = nil
XCTAssertNil(viewController?.notifier)

Confusing code completion

If we’re gonna make the on extension to UIButton, UITableView, UIViewController we have to add the associated object into NSObject for all these classes to have on property.

class On {
    func tap(_ closure: () -> Void) {}
    func tapRow(_ closure: () -> Void) {}
    func textChange(_ closure: () -> Void) {}
}
extension NSObject {
    private struct AssociatedKeys {
        static var on = "on"
    }var on: On? {
        get {
            return objc_getAssociatedObject(self, &AssociatedKeys.on) as? On
        }set {
            if let newValue = newValue {
                objc_setAssociatedObject(
                    self,
                    &AssociatedKeys.on,
                    newValue as On?,
                    .OBJC_ASSOCIATION_RETAIN_NONATOMIC
                )
            }
        }
    }
}
button.on.textChange {}

Protocol with associated type

To remedy this awkward API, we can use a very sweet feature of Swift called protocol with an associated type. We start by introducing EasyClosureAware that has a host EasyClosureAwareHostType of type AnyObject. This means that this protocol is for any class that wants to attach itself to a host object.

private struct AssociatedKey {
    static var key = "EasyClosure_on"
}public protocol EasyClosureAware: class {
    associatedtype EasyClosureAwareHostType: AnyObjectvar on: Container<EasyClosureAwareHostType> { get }
}extension EasyClosureAware {
    public var on: Container<Self> {
        get {
            if let value = objc_getAssociatedObject(self, &AssociatedKey.key) as? Container<Self> {
                return value
            }let value = Container(host: self)
            objc_setAssociatedObject(self, &AssociatedKey.key, value, .OBJC_ASSOCIATION_RETAIN_NONATOMIC)
            return value
        }
    }
}extension NSObject: EasyClosureAware { }
public class Container<Host: AnyObject>: NSObject {
    public unowned let host: Host
    
    public init(host: Host) {
        self.host = host
    }
    
    // Keep all targets alive
    public var targets = [String: NSObject]()
}
public extension Container where Host: UIButton {
    func tap(_ action: @escaping Action) {
        let target = ButtonTarget(host: host, action: action)
        targets[ButtonTarget.uniqueId] = target
    }
}class ButtonTarget: NSObject {
    var action: Action?init(host: UIButton, action: @escaping Action) {
        super.init()self.action = action
        host.addTarget(self, action: #selector(handleTap), for: .touchUpInside)
    }// MARK: - Action@objc func handleTap() {
        action?()
    }
}
button.on.tap {}
textField.on. // ummh?
public extension Container where Host: UITextField {
    func textChange(_ action: @escaping StringAction) {
        let target = TextFieldTarget(host: host, textAction: action)
        targets[TextFieldTarget.uniqueId] = target
    }
}class TextFieldTarget: NSObject {
    var textAction: StringAction?required init(host: UITextField, textAction: @escaping StringAction) {
        super.init()self.textAction = textAction
        host.addTarget(self, action: #selector(handleTextChange(_:)), for: .editingChanged)
    }// MARK: - Action@objc func handleTextChange(_ textField: UITextField) {
        textAction?(textField.text ?? "")
    }
}

Action to RxSwift observable

Having button.on.tap {} is nice, but it would be great if that can transform into Observablefor some RxSwift fans like me.

final class RxButton: UIButton {
    let tap = PublishSubject<()>()override init(frame: CGRect) {
        super.init(frame: frame)self.on.tap { tap.onNext(()) }
    }
}
button.tap.subscribe(onNext: {})

Timer keeps a strong reference to its target

Unlike target-action in UIControl where target is held weakly, Timer keeps its target strongly to deliver tick event.

func schedule() {
    DispatchQueue.main.async {
      self.timer = Timer.scheduledTimer(timeInterval: 20, target: self,
                                   selector: #selector(self.timerDidFire(timer:)), userInfo: nil, repeats: false)
    }
}@objc private func timerDidFire(timer: Timer) {
    print(timer)
}
public extension Container where Host: Timer {func tick(_ action: @escaping Action) {
        self.timerTarget?.action = action
    }
}class TimerTarget: NSObject {
    var action: Action?@objc func didFire() {
        action?()
    }
}public extension Timer {
    static func scheduledTimer(withTimeInterval interval: TimeInterval, repeats: Bool) -> Timer {
        let target = TimerTarget()
        let timer = Timer.scheduledTimer(timeInterval: interval,
                                         target: target,
                                         selector: #selector(TimerTarget.didFire),
                                         userInfo: nil,
                                         repeats: repeats)
        timer.on.timerTarget = target
        return timer
    }
}
timer = Timer.scheduledTimer(withTimeInterval: 1, repeats: true)
timer.on.tick { print("tick") }
DispatchQueue.main.async {
      self.timer = Timer.scheduledTimer(withTimeInterval: 20, repeats: false) { timer in
        print(timer)
      }
}

Key-value observing in Swift 5

Key-value observing is the ability to watch property change for an NSObject , before Swift 5 the syntax is quite verbose and error-prone with addObserver and observeValuem methods. This is not to mention the usage of context , especially in the subclassing situation, where we need a context key to distinguish between observations of different objects on the same keypath.

public extension Container where Host: NSObject {func observe(object: NSObject, keyPath: String, _ action: @escaping AnyAction) {
        let item = KeyPathTarget.Item(object: object, keyPath: keyPath, action: action)
        keyPathTarget.items.append(item)
        object.addObserver(keyPathTarget, forKeyPath: keyPath, options: .new, context: nil)
    }func unobserve(object: NSObject, keyPath: String? = nil) {
        let predicate: (KeyPathTarget.Item) -> Bool = { item in
            return item.object === object
                && (keyPath != nil) ? (keyPath! == item.keyPath) : true
        }keyPathTarget.items.filter(predicate).forEach({
            object.removeObserver(keyPathTarget, forKeyPath: $0.keyPath)
        })keyPathTarget.items = keyPathTarget.items.filter({ !predicate($0) })
    }
}class KeyPathTarget: NSObject {
    class Item {
        let object: NSObject
        let keyPath: String
        let action: AnyActioninit(object: NSObject, keyPath: String, action: @escaping AnyAction) {
            self.object = object
            self.keyPath = keyPath
            self.action = action
        }
    }var items = [Item]()deinit {
        items.forEach({ item in
            item.object.removeObserver(self, forKeyPath: item.keyPath)
        })items.removeAll()
    }// MARK: - KVO
    override func observeValue(forKeyPath keyPath: String?,
                               of object: Any?,
                               change: [NSKeyValueChangeKey : Any]?,
                               context: UnsafeMutableRawPointer?) {
        guard let object = object as? NSObject,
            let keyPath = keyPath,
            let value = change?[.newKey] else {
                return
        }let predicate: (KeyPathTarget.Item) -> Bool = { item in
            return item.object === object
                && keyPath == item.keyPath
        }items.filter(predicate).forEach({
            $0.action(value)
        })
    }
}
let observer = NSObject()
observer.on.observe(object: scrollView: keyPath: #keyPath(UIScrollView.contentSize)) { value in  print($0 as? CGSize)}
@objc class User: NSObject {
    @objc dynamic var name = "random"
}

let thor = Person()thor.observe(\User.name, options: .new) { user, change in
    print("User has a new name \(user.name)")
}thor.name = "Thor"

Block-based Notification Center

NotificationCenter is the mechanism to post and receive notification system-wide. Starting from iOS 4, NotificationCenter got its block API addObserverForName:object:queue:usingBlock:

public extension Container where Host: NSObject {func observe(notification name: Notification.Name,
                 _ action: @escaping NotificationAction) {
        let observer = NotificationCenter.default.addObserver(
            forName: name, object: nil,
            queue: OperationQueue.main, using: {
                action($0)
        })notificationTarget.mapping[name] = observer
    }func unobserve(notification name: Notification.Name) {
        let observer = notificationTarget.mapping.removeValue(forKey: name)if let observer = observer {
            NotificationCenter.default.removeObserver(observer as Any)
            notificationTarget.mapping.removeValue(forKey: name)
        }
    }
}class NotificationTarget: NSObject {
    var mapping: [Notification.Name: NSObjectProtocol] = [:]deinit {
        mapping.forEach({ (key, value) in
            NotificationCenter.default.removeObserver(value as Any)
        })mapping.removeAll()
    }
}
viewController.on.observe(notification: Notification.Name.UIApplicationDidBecomeActive) { notification in
  print("application did become active")
}

viewController.on.unobserve(notification: Notification.Name.UIApplicationDidBecomeActive)

Where do we go from here

We’ve learned how to make use of associated objects and make nicer APIs. EasyClosure is designed to be extensible and we can wrap any communication patterns. KVO and NotificationCenter APIs have become better starting iOS 10 and Swift 5, and we see a trend of more closure based API as they are declarative and convenient. When we can, we should stick to the system APIs as much as possible and only make our sugar when needed.

func allOn() -> Bool {
  return [good, cheap, fast].filter({ $0.isOn }).count == 3
}

good.on.valueChange { _ in
  if allOn() {
    fast.setOn(false, animated: true)
  }
}

cheap.on.valueChange { _ in
  if allOn() {
    good.setOn(false, animated: true)
  }
}

fast.on.valueChange { _ in
  if allOn() {
    cheap.setOn(false, animated: true)
  }
}

Flawless iOS

🍏 Community around iOS development, mobile design, and marketing

Khoa Pham

Written by

Khoa Pham

Cageball apprentice at @hyperoslo. My apps https://onmyway133.github.io/

Flawless iOS

🍏 Community around iOS development, mobile design, and marketing