Hooking up UIButton to closure? (Swift, target-act

2019-01-21 19:24发布

问题:

I want to hook up a UIButton to a piece of code – from what I have found, the preferred method to do this in Swift is still to use the addTarget(target: AnyObject?, action: Selector, forControlEvents: UIControlEvents) function. This uses the Selector construct presumably for backwards compatibility with Obj-C libraries. I think I understand the reason for @selector in Obj-C – being able to refer to a method since in Obj-C methods are not first-class values.

In Swift though, functions are first-class values. Is there a way to connect a UIButton to a closure, something similar to this:

// -- Some code here that sets up an object X

let buttonForObjectX = UIButton() 

// -- configure properties here of the button in regards to object
// -- for example title

buttonForObjectX.addAction(action: {() in 

  // this button is bound to object X, so do stuff relevant to X

}, forControlEvents: UIControlEvents.TouchUpOutside)

To my knowledge, the above is currently not possible. Considering that Swift looks like it's aiming to be a quite functional, why is this? The two options could clearly co-exist for backwards compatibility. Why doesn't this work more like onClick() in JS? It seems that the only way to hook up a UIButton to a target-action pair is to use something that exists solely for backwards compatibility reasons (Selector).

My use case is to create UIButtons in a loop for different objects, and then hook each up to a closure. (Setting a tag / looking up in a dictionary / subclassing UIButton are dirty semi-solutions, but I'm interested in how to do this functionally, ie this closure approach)

回答1:

The general approach for anything you think should be in the libraries but isn't: Write a category. There's lots of this particular one on GitHub but didn't find one in Swift so I wrote my own:

=== Put this in its own file, like UIButton+Block.swift ===

import ObjectiveC

var ActionBlockKey: UInt8 = 0

// a type for our action block closure
typealias BlockButtonActionBlock = (sender: UIButton) -> Void

class ActionBlockWrapper : NSObject {
    var block : BlockButtonActionBlock
    init(block: BlockButtonActionBlock) {
        self.block = block
    }
}

extension UIButton {
    func block_setAction(block: BlockButtonActionBlock) {
        objc_setAssociatedObject(self, &ActionBlockKey, ActionBlockWrapper(block: block), objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        addTarget(self, action: "block_handleAction:", forControlEvents: .TouchUpInside)
    }

    func block_handleAction(sender: UIButton) {
        let wrapper = objc_getAssociatedObject(self, &ActionBlockKey) as! ActionBlockWrapper
        wrapper.block(sender: sender)
    }
}

Then invoke it like this:

myButton.block_setAction { sender in
    // if you're referencing self, use [unowned self] above to prevent
    // a retain cycle

    // your code here

}

Clearly this could be improved, there could be options for the various kinds of events (not just touch up inside) and so on. But this worked for me. It's slightly more complicated than the pure ObjC version because of the need for a wrapper for the block. Swift compiler does not allow storing the block as "AnyObject". So I just wrapped it.



回答2:

You can replace target-action with a closure by adding a helper closure wrapper (ClosureSleeve) and adding it as an associated object to the control so it gets retained.

This is a similar solution to the one in n13's (top) answer. But I find it simpler and more elegant. The closure is invoked more directly and the wrapper is automatically retained (added as an associated object).

Swift 3 and 4

class ClosureSleeve {
    let closure: () -> ()

    init(attachTo: AnyObject, closure: @escaping () -> ()) {
        self.closure = closure
        objc_setAssociatedObject(attachTo, "[\(arc4random())]", self, .OBJC_ASSOCIATION_RETAIN)
    }

    @objc func invoke() {
        closure()
    }
}

extension UIControl {
    func addAction(for controlEvents: UIControlEvents = .primaryActionTriggered, action: @escaping () -> ()) {
        let sleeve = ClosureSleeve(attachTo: self, closure: action)
        addTarget(sleeve, action: #selector(ClosureSleeve.invoke), for: controlEvents)
    }
}

Usage:

button.addAction {
    print("Hello")
}

It automatically hooks to the .primaryActionTriggered event which equals to .touchUpInside for UIButton.



回答3:

This is not necessarily a "hooking," but you can effectively achieve this behavior by subclassing UIButton:

class ActionButton: UIButton {
    var touchDown: ((button: UIButton) -> ())?
    var touchExit: ((button: UIButton) -> ())?
    var touchUp: ((button: UIButton) -> ())?

    required init?(coder aDecoder: NSCoder) { fatalError("init(coder:)") }
    override init(frame: CGRect) {
        super.init(frame: frame)
        setupButton()
    }

    func setupButton() {
        //this is my most common setup, but you can customize to your liking
        addTarget(self, action: #selector(touchDown(_:)), forControlEvents: [.TouchDown, .TouchDragEnter])
        addTarget(self, action: #selector(touchExit(_:)), forControlEvents: [.TouchCancel, .TouchDragExit])
        addTarget(self, action: #selector(touchUp(_:)), forControlEvents: [.TouchUpInside])
    }

    //actions
    func touchDown(sender: UIButton) {
        touchDown?(button: sender)
    }

    func touchExit(sender: UIButton) {
        touchExit?(button: sender)
    }

    func touchUp(sender: UIButton) {
        touchUp?(button: sender)
    }
}

Use:

let button = ActionButton(frame: buttonRect)
button.touchDown = { button in
    print("Touch Down")
}
button.touchExit = { button in
    print("Touch Exit")
}
button.touchUp = { button in
    print("Touch Up")
}


回答4:

This is easily solved using RxSwift

import RxSwift
import RxCocoa

...

@IBOutlet weak var button:UIButton!

...

let taps = button.rx.tap.asDriver() 

taps.drive(onNext: {
    // handle tap
})

Edit:

I wanted to acknowledge that RxSwift/RxCocoa is a pretty heavy-weight dependency to add to a project just to solve this one requirement. There may be lighter-weight solutions available or just stick with target/action pattern.

In any case, if the idea of a general purpose declarative approach to handling application and user events appeals to you, definitely give RxSwift a look. It's the bomb.



回答5:

According to n13's solution, I made a swift3 version.

Hopes it could help some people like me.

import Foundation
import UIKit
import ObjectiveC

var ActionBlockKey: UInt8 = 0

// a type for our action block closure
typealias BlockButtonActionBlock = (_ sender: UIButton) -> Void

class ActionBlockWrapper : NSObject {
    var block : BlockButtonActionBlock
    init(block: @escaping BlockButtonActionBlock) {
        self.block = block
    }
}

extension UIButton {
    func block_setAction(block: @escaping BlockButtonActionBlock, for control: UIControlEvents) {
        objc_setAssociatedObject(self, &ActionBlockKey, ActionBlockWrapper(block: block), objc_AssociationPolicy.OBJC_ASSOCIATION_RETAIN_NONATOMIC)
        self.addTarget(self, action: #selector(UIButton.block_handleAction), for: .touchUpInside)
    }

    func block_handleAction(sender: UIButton, for control:UIControlEvents) {

        let wrapper = objc_getAssociatedObject(self, &ActionBlockKey) as! ActionBlockWrapper
        wrapper.block(sender)
    }
}


回答6:

UIButton inherits from UIControl, which handles the receiving of input and forwarding to the selection. According to the docs, the action is "A selector identifying an action message. It cannot be NULL." And a Selector is strictly a pointer to a method.

I would think that given the emphases Swift seems to be placing on Closures, this would be possible, but this doesnt seem to be the case.



回答7:

You can approach this with a proxy class that routes events through the target / action (selector) mechanism to a closure of your making. I have done this for gesture recognizers, but the same pattern should hold for controls.

You could do something like this:

import UIKit

@objc class ClosureDispatch {
    init(f:()->()) { self.action = f }
    func execute() -> () { action() }
    let action: () -> ()
}

var redBlueGreen:[String] = ["Red", "Blue", "Green"]
let buttons:[UIButton] = map(0..<redBlueGreen.count) { i in
    let text = redBlueGreen[i]
    var btn = UIButton(frame: CGRect(x: i * 50, y: 0, width: 100, height: 44))
    btn.setTitle(text, forState: .Normal)
    btn.setTitleColor(UIColor.redColor(), forState: .Normal)
    btn.backgroundColor = UIColor.lightGrayColor()
    return btn
}

let functors:[ClosureDispatch] = map(buttons) { btn in
    let functor = ClosureDispatch(f:{ [unowned btn] in
        println("Hello from \(btn.titleLabel!.text!)") })
    btn.addTarget(functor, action: "execute", forControlEvents: .TouchUpInside)
    return functor
}

The one caveat of this, is that since addTarget:... does not retain the target, you need to hold on to the dispatch objects (as done with the functors array). You don't strictly need to hold on to the buttons of course, as you could do that via a captured reference in the closure, but likely you will want explicit references.

PS. I tried to test this in playground, but could not get sendActionsForControlEvents to work. I have used this approach for gesture recognizers though.



回答8:

The associatedObject and wrapping and pointers and import of ObjectiveC are unnecessary, at least in Swift 3. This works great and is much more Swift-y. Feel free to throw a typealias in there for () -> () if you find it more readable, bit I find it easier to read the block signature directly.

import UIKit

class BlockButton: UIButton {
    fileprivate var onAction: (() -> ())?

    func addClosure(_ closure: @escaping () -> (), for control: UIControlEvents) {
        self.addTarget(self, action: #selector(actionHandler), for: control)
        self.onAction = closure
    }

    dynamic fileprivate func actionHandler() {
        onAction?()
    }
}