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
}
}
}