Search code examples
iosswiftdelegatesprotocolsmixpanel

Alternative to storing a variable in a Swift extension


I am currently working on a large app where we require the ability to track specific events (click, swipe..) on essentially any custom class derived from UIView. We have, for example, multiple subclasses of UITableView which all need to respond to click and/or swipe events in different combinations - occurrences of these events then send properties to an external service. Subclassing UIView and using this as a parent class of all of our other custom subclasses is not an option.

The kicker is that the data sent when these events occur varies based on the page of our app in which the UI elements are displayed. My first thought was to create a Trackable protocol. Ideally, I'd like to place all of the boilerplate code for setting up gesture recognisers inside an extension of this protocol, but cannot because the #selector syntax requires an @objc annotation which is unavailable in protocol extensions.

Furthermore, when I attempt to instead extend UIView I no longer have access to a property required by the Trackable protocol and cannot add a default implementation of it because, as mentioned above, extensions do not support variable declaration. Below is a (very rough) idea of what I'd like to achieve. Is this even possible? Does a better pattern exist? I have also looked at the delegate pattern and it does not solve any of the issues above.

protocol Trackable: class {
    var propertiesToSend: [String : [String : String]] { get set }
}

extension UIView: Trackable {
    //need alternative way to achieve this, as it is not allowed here
    var propertiesToSend = [:] 

    func subscribe(to event: String, with properties: [String : String]) {
        propertiesToSend[event] = properties
        startListening(for: event)
    }

    func unsubscribe(from event: String) {
        propertiesToSend.removeValue(forKey: event)
    }

    private func startListening(for event: String) {
        switch (event) {
            case "click":
                let clickRecogniser = UITapGestureRecognizer(target: self, action: #selector(track(event:)))
                addGestureRecognizer(clickRecogniser)

            case "drag":
                for direction: UISwipeGestureRecognizerDirection in [.left, .right] {
                    let swipeRecogniser = UISwipeGestureRecognizer(target: self, action: #selector(track(event:)))
                    swipeRecogniser.direction = direction
                    addGestureRecognizer(swipeRecogniser)
                }

            default: return
        }
    }

    @objc
    func track(event: UIEvent) {
        let eventString: String

        switch (event.type) {
            case .touches:
                eventString = "click"
            case .motion:
                eventString = "drag"
            default: return
        }

        if let properties = propertiesToSend[eventString] {
            sendPropertiesToExternalService("Interaction", properties: properties)
        }
    }
}

Solution

  • Don't make it more complex than it needs to be. UIView and its subclasses must derive from NSObject. Read the documentation on objc_getAssociatedObject and objc_getAssociatedObject. No need for protocols or other abstractions.

    import ObjectiveC
    
    private var key: Void? = nil // the address of key is a unique id.
    
    extension UIView {
        var propertiesToSend: [String: [String: String]] {
            get { return objc_getAssociatedObject(self, &key) as? [String: [String: String]] ?? [:] }
            set { objc_setAssociatedObject(self, &key, newValue, .OBJC_ASSOCIATION_RETAIN) }
        }
    }
    

    This can be used as follows.

    let button = UIButton()
    
    button.propertiesToSend = ["a": ["b": "c"]]
    print(button.propertiesToSend["a"]?["b"] ?? "unknown")