Jeriel Ng
Posted on July 13, 2022
Introduction
In iOS Bug of the Week (not necessarily weekly), I'll go over an interesting edge case bug that hasn't been heavily documented due to its uncommon or specific nature.
For this installment, I'll discuss an interesting navigation issue that showed up when pushing a detail view controller to a UINavigationController
's stack.
The Setup
I aimed to create a reusable button style with default values so the app can have a consistent look and feel for all of our buttons.
To achieve this, I extended UIButton
with a set of properties I would like to apply to any button of this type.
extension UIButton {
static let defaultStyle: UIButton = {
let button = UIButton()
button.layer.cornerRadius = 15.0
button.backgroundColor = .blue
return button
}()
}
With this extension, the caller can easily create a button that keeps a style consistent with other buttons using this configuration.
So far so good. Let’s put this button into use.
Overview of our example
To keep this example simple, we will use our button in a parent view controller to navigate into a detail view controller by pushing it to the navigation stack.
In our example, we can also “log out” from the parent view controller onto a login screen, which will be separate from the parent-to-detail navigation flow.
Creating our screens
In our parent view controller, MainViewController
, let's create a lazy
instance of UIButton
, which will use our button extension. Therefore, it should inherit all the properties from defaultStyle
.
class MainViewController {
private lazy var proceedButton: UIButton = {
let button = UIButton.defaultStyle
button.translatesAutoresizingMaskIntoConstraints = false
return button
}()
}
Now let’s assign a selector to this button in the viewDidLoad
of our view controller.
With our selector, we'll create an empty DetailViewController
and push it to the navigation stack.
@objc
private func didTapProceedButton() {
let viewController = DetailViewController()
navigationController?.pushViewController(viewController, animated: true)
}
override func viewDidLoad() {
super.viewDidLoad()
proceedButton.addTarget(self, action: #selector(didTapProceedButton), for: .touchUpInside)
}
The Bug
Loading up the app, we click on the proceed button, and it navigates to the detail view controller. No issues there.
But what would happen if we navigated to another part of the app and re-navigated back to MainViewController
?
For this example, say I decided to click on some logout button and log back into my MainViewController
.
When I log back into the MainViewController
, clicking the proceed button will now push the detail view controller to the navigation stack twice.
Repeating this process again will cause the button to now push three instances of the detail view controller. And so on and so forth.
We observe that this behavior is consistent with the number of times we navigate back to MainViewController
, but what is causing this bug?
The Fix
The issue ended up being caused by the setup of the UIButton
extension.
Notice in the code for our button style that we're calling this function with the ()
syntax and then assigning its result to defaultStyle
as a static let
property.
// Incorrect approach
static let defaultStyle: UIButton = {
let button = UIButton()
button.layer.cornerRadius = 15.0
button.backgroundColor = .blue
return button
}()
Why is this problematic? Well, it's only created once, so anytime we call this button extension, we'll be getting back the same instance wherever else it's used.
Let's make a fix to this setup:
extension UIButton {
// Correct approach
static func defaultStyle() -> UIButton {
let button = UIButton()
button.layer.cornerRadius = 15.0
button.backgroundColor = .blue
return button
}
}
In our new approach, we are deferring the creation of our button to whenever that function is called. This creates a new UIButton
instance every time, as opposed to calling the same instance.
Recall also that we assigned the selector to the button in the viewDidLoad
of MainViewController
. Therefore, every time we load this view controller, we would be stacking that selector onto the button yet again.
Since it's the same instance of the button, it will retain each iteration of the selector. So every time the user taps the proceed button, it will trigger every instance of the selector that has been assigned upon loading the view controller. Hence the reason multiple instances of DetailViewController
get pushed for one button press.
As you can see, the bug is created through multiple steps, and it's important to identify where the root cause is.
Posted on July 13, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.