iOS Bug of the Week: View Controller Gets Pushed Multiple Times

jeriel

Jeriel Ng

Posted on July 13, 2022

iOS Bug of the Week: View Controller Gets Pushed Multiple Times

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

The Bug

Loading up the app, we click on the proceed button, and it navigates to the detail view controller. No issues there.

Image description

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.

Image description

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


Enter fullscreen mode Exit fullscreen mode

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


Enter fullscreen mode Exit fullscreen mode

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.

💖 💪 🙅 🚩
jeriel
Jeriel Ng

Posted on July 13, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related