Default UIBarButtonItems with protocols

:  ~ 1 min read

Having to create the same set of buttons over and over can become cumbersome. We'll try to make use of protocols and implement some default ones. Let's start with that:

protocol CanGoBack { // Sounds better than Backable :)
  func back()
}
extension CanGoBack where Self: UIViewController {
  func back() {
    if presentingViewController != nil && navigationController?.childViewControllers.first == self {
      navigationController?.dismissViewControllerAnimated(true, completion: nil)
    }
    else {
	  navigationController?.popViewControllerAnimated(true)
    }
  }
  
  func setupBackButton() {
    guard
      navigationController?.childViewControllers.first != self
      else { return }
	
    let backButton = UIButton.myBackButton()
    backButton.addTarget(self, action: #selector(back), forControlEvents: .TouchUpInside)
    navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)  
  }
}

But here we stumble upon the problem: back has to be marked as @objc, to be able to create the #selector; but to do that, we'd actually have to mark the whole protocol as @objc; but if we did that, we'd need to implement the back method in all places where we conform to CanGoBack, meaning the only thing we'd be saving, would be the creation of the button, but not the action as well.

Sadly, there's no way to work around this without some subclassing, but the good news is it's rather straightforward:

class Button: UIButton {
  // `action` is the internal variable of type `Selector`.
  private let buttonAction: () -> Void
    
  @objc private func performButtonAction() {
    buttonAction()
  }
	
  init(action: () -> Void) {
    buttonAction = action
    let image = UIImage(named: "back")
    super.init(frame: CGRect(origin: .zero, size: image?.size ?? .zero))
    setImage(image, forState: .Normal)
    addTarget(self, action: #selector(performButtonAction), forControlEvents: .TouchUpInside)
  }
}

We then modify our protocol, by replacing the UIButton creation:

func setupBackButton() {
  guard
    navigationController?.childViewControllers.first != self
    else { return }

  let backButton = Button(action: { [weak self] in self?.back() } )
  navigationItem.leftBarButtonItem = UIBarButtonItem(customView: backButton)
}

And from now on, all we have to do for an UIViewController to have a back button and action is:

class SecondMainViewController: UIViewController, CanGoBack {
  override viewDidLoad() {
    super.viewDidLoad()
    setupBackButton()
  }
}

We could go a step further and subclass UIBarButtonItem, but since the logic is exactly the same, there's no need to include it here.