Raphael Cruzeiro

A simple protocol extension for adding a loader to an UIButton

One of the most common UI idioms for mobile applications is to have a button be disabled and overlaid with a spinning activity indicator after the user taps it. This is a nice way to not only let users know something is happening and they should wait, but this also prevents the user from tapping the button multiple times thus triggering whatever the button is supposed to trigger more times than it should.

There is no built in way of achieving this with UIKit, but lucky for us, Swift makes it very simple to have a reusable solution for this by means of a simple protocol extension (and by leveraging the Objective-C runtime). In this article I will show you how to implement such a button in a way that it can be reused in all your UIKit projects and I’ll also show how you can optionally combine this button with RxSwift and bind together your UI, validation and action.

The first thing that needs to be done is to declare a protocol defining the interface that your buttons will conform to. Notice that this protocol also defines a viewsToDim array. The purpose of this array is to list the subviews that might need to be dimmed when showing the activity indicator. This is useful if you want to eventually subsclass UIButton and define your own button with custom subviews that need to disappear when the activity indicator is animating.

protocol ActivityAnimatorProtocol {
    var isLoading: Bool { get set }
    var viewsToDim: [UIView] { get set }
}

Now that we know what our protocol will look like, it’s time to make UIButton conform to this protocol by default:

extension UIButton: ActivityAnimatorProtocol {

}

This of course will not compile as we have not yet implemented the two properties required by our protocol. This is where an old Objective-C will come to our rescue. We will dynamically associate those two fields to our UIButton. We will start by defining some keys on the top of our file (outside the extension and protocol declaration):

private var isLoadingKey: UInt8 = 0
private var viewsToDimKey: UInt8 = 0

Notice the use of private there. We don’t want this leaking to all other source files of our project.

Now we can finally define the two properties required by our protocol inside the extension:

var viewsToDim: [UIView] {
    get {
        return objc_getAssociatedObject(self, &viewsToDimKey) as? [UIView] ?? []
    }
    set {
        objc_setAssociatedObject(self, &viewsToDimKey, newValue, .OBJC_ASSOCIATION_RETAIN)
    }
}
    
var isLoading: Bool {
    get {
        return objc_getAssociatedObject(self, &isLoadingKey) as? Bool ?? false
    }
    set {
        objc_setAssociatedObject(self, &isLoadingKey, newValue, .OBJC_ASSOCIATION_RETAIN)
        handleActivityIndicator()
    }
}

private func handleActivityIndicator() {

}

What we are basically doing here is relying on the Objective-C runtime to associate new properties to UIButton instances. This can be done with any type that inherits from NSObject and iis one of those “with great power comes great responsibility” moments. The handleActivityIndicator() is where we will do the UI work necessary to either make our button start animating its loader or to reset it to its initial state.

Before we start to implement handleActivityIndicator(), you might be wondering where the activity indicator is defined and what is keeping its reference. Since an UIActivityIndicator is an UIView that will be added to the subviews of our button, there is really no need to define another associated object for it. We can rely on view tags in order to get a reference to our activity indicator. Let's start by defining a constant at the top of the file with the tag we will want to use to refer to our activity indicator:

private let activityIndicatorTag = 8874527

With this tag we can now write a simple method that will either return the activity indicator if it is already present in this UIButton’s subviews or create one if not:

private func getActivityIndicator() -> UIActivityIndicatorView {
    if let indicator = viewWithTag(activityIndicatorTag) as? UIActivityIndicatorView {
        return indicator
    }
        
    let indicator = UIActivityIndicatorView()
    indicator.tag = activityIndicatorTag
    indicator.isUserInteractionEnabled = false
    indicator.color = titleLabel?.textColor ?? .black
    addSubview(indicator)

    indicator.translatesAutoresizingMaskIntoConstraints = false
    indicator.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
    indicator.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true    

    return indicator
}

With this we can now finally write that handleActivityIndicator() method to take care of our animations:

private func handleActivityIndicator() {
    let activityIndicator = getActivityIndicator()
    let targetOpacity: Float = isLoading ? 0 : 1
    let targetActivityOpacity: Float = isLoading ? 1 : 0
        
    DispatchQueue.main.async {
        self.isUserInteractionEnabled = !self.isLoading
        
        if self.isLoading {
            activityIndicator.startAnimating()
        } else {
            activityIndicator.stopAnimating()
        }
            
        self.setNeedsLayout()
        self.layoutIfNeeded()
            
        UIView.animate(withDuration: 0.2) {
            self.imageView?.layer.opacity = targetOpacity
            self.titleLabel?.layer.opacity = targetOpacity
            activityIndicator.layer.opacity = targetActivityOpacity
            self.viewsToDim.forEach { $0.layer.opacity = targetOpacity }
        }
    }
}

The method above is pretty straightforward. We get a reference to the activity indicator and then decide the final values of the animation based on the isLoading flag. Then, just to be on the safe side of things as we do not know in which queue isLoading might end up being set, we dispatch to the main queue and perform the animations. Note that the viewsToDim array is also iterated upon with each item of the array taking part in the animation as well.

Since we were pretty much guessing which colour the activity indicator should be, it’s nice to leave an easy way for the colour to be set:

func setActivityIndicator(color: UIColor) {
    getActivityIndicator().color = color
}

And that is it! With the code above, the following should work on every UIButton in your project:

myButton.isLoading = true

Bonus: Making our isLoading extension RxSwift-friendly

In order to make our isLoading extension usable from RxSwift, we need to simple extend Reactive for UIButton:

extension Reactive where Base: UIButton {
    
    var isLoading: Binder<Bool> {
        return Binder(self.base) { control, value in
            control.isLoading = value
        }
    }
    
}

With the extension above, we can now write things such as:

button.rx.tap
    .do(onNext: { button.isLoading = true })
    .flatMap { [unowned self] in
        self.viewModel.signInTap()
            .do(
                onError: { [unowned self] error in
                    button.isLoading = false
                    let vc = UIAlertController(
                        title: nil,
                        message: "There was an error signing you in",
                        preferredStyle: .alert
                    )
                    vc.addAction(UIAlertAction(title: "Ok", style: .default, handler: nil))
                    self.present(vc, animated: true, completion: nil)
                },
                onCompleted: { [unowned self] in
                    self.dismiss(animated: true, completion: nil)
                }
            ) 
        }
        .subscribe()
        .disposed(by: disposeBag)

Here is the complete source for our isLoading extension with the RxSwift extension included. If you do not use RxSwift, you can just remove those two imports and the Reactive extension at the bottom. Everything else should work just fine as this loader extension has no dependencies.

import UIKit
import RxSwift
import RxCocoa

private var isLoadingKey: UInt8 = 0
private var viewsToDimKey: UInt8 = 0
private let activityIndicatorTag = 8874527

protocol ActivityAnimatorProtocol {
    var isLoading: Bool { get set }
    var viewsToDim: [UIView] { get set }
}

extension UIButton: ActivityAnimatorProtocol {
    
    var viewsToDim: [UIView] {
        get {
            return objc_getAssociatedObject(self, &viewsToDimKey) as? [UIView] ?? []
        }
        set {
            objc_setAssociatedObject(self, &viewsToDimKey, newValue, .OBJC_ASSOCIATION_RETAIN)
        }
    }
    
    var isLoading: Bool {
        get {
            return objc_getAssociatedObject(self, &isLoadingKey) as? Bool ?? false
        }
        set {
            objc_setAssociatedObject(self, &isLoadingKey, newValue, .OBJC_ASSOCIATION_RETAIN)
            handleActivityIndicator()
        }
    }
    
    private func handleActivityIndicator() {
        let activityIndicator = getActivityIndicator()
        let targetOpacity: Float = isLoading ? 0 : 1
        let targetActivityOpacity: Float = isLoading ? 1 : 0
        
        DispatchQueue.main.async {
            if self.isLoading {
                activityIndicator.startAnimating()
            } else {
                activityIndicator.stopAnimating()
            }
            
            self.setNeedsLayout()
            self.layoutIfNeeded()
            
            UIView.animate(withDuration: 0.2) {
                self.imageView?.layer.opacity = targetOpacity
                self.titleLabel?.layer.opacity = targetOpacity
                activityIndicator.layer.opacity = targetActivityOpacity
                self.viewsToDim.forEach { $0.layer.opacity = targetOpacity }
            }
        }
    }
    
    func setActivityIndicator(color: UIColor) {
        getActivityIndicator().color = color
    }
    
    private func getActivityIndicator() -> UIActivityIndicatorView {
        if let indicator = viewWithTag(activityIndicatorTag) as? UIActivityIndicatorView {
            return indicator
        }
        
        let indicator = UIActivityIndicatorView()
        indicator.tag = activityIndicatorTag
        indicator.isUserInteractionEnabled = false
        indicator.color = titleLabel?.style.foregroundColor ?? .black
        addSubview(indicator)
        
        indicator.translatesAutoresizingMaskIntoConstraints = false
        indicator.centerXAnchor.constraint(equalTo: centerXAnchor).isActive = true
        indicator.centerYAnchor.constraint(equalTo: centerYAnchor).isActive = true
        
        return indicator
    }
    
}

extension Reactive where Base: UIButton {
    
    var isLoading: Binder<Bool> {
        return Binder(self.base) { control, value in
            control.isLoading = value
        }
    }
    
}
Tagged with: