Celebrating 10 Years!

profile picture

The Case of the Broken Buttons

September 23, 2017 - Roundwall Software

On a client project the other day, I spent most of the day tracking down a rather serious bug. All UIBarButtonItems were nearly un-tappable on iOS 11. These were not especially crafty buttons, most of them were using either an icon of reasonable size or a title. Some were even using the system standard items. None of them worked. Icons were un-tappable, back buttons were only tappable on the edges. All users on iOS 11 were left out in the cold and would not be happy.

Instantly I assumed there was some clever code hiding somewhere that was causing the problem. I searched the project (this was a fairly new client so I did not know all of what lurked there) for subclasses of UINavigationController and UINavigationBar. I looked for categories and extensions on UIBarButtonItem as well as the navigation classes. I even assumed the problem was with one of the libraries included in the app. Surely third party code is doing something silly and breaking every button.

In the end none of that was correct. Here's how I actually found the problem:

All UIView subclasses implement a method called -hitTest:withEvent:. Documentation explains that this is how the system determines which view a user is tapping on when they tap on the screen. The message is first sent to the key UIWindow which in turn calls the method for each of its subviews. Each subview recursively searches its own subviews to try to find the front-most view which contains the point the user has tapped on.

I tried replacing the key UIWindow of the app with a custom subclass which implements this method and prints out the resulting view before returning it. No change in logic, just displaying for debug purposes.

class HitTestWindow: UIWindow {
  open override hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
    let view = super.hitTest(point, with: event)
    print(view ?? "No view!")
    return view
  }
}

Tapping on an item with this in place yielded an object of class _UIModernBarButton. The underscore implied it was not a public class which was a relief because I've never heard of it.

When I did the same thing on a dummy project which worked as expected on iOS 11, I instead got an object of class _UIButtonBarButton. Aha! Something is changing what gets returned when the hit test is executed.

A search of the project for the phrase hitTest yielded the perpetrator:

extension UIButton {
   open override func hitTest(_ point: CGPoint, with event: UIEvent?) -> UIView? {
       if self.isHidden {
           return nil
       }
       let buttonSize = self.frame.size
       let widthToAdd = (44 - buttonSize.width > 0) ? 44 - buttonSize.width : 0
       let heightToAdd = (44 - buttonSize.height > 0) ? 44 - buttonSize.height : 0
       let largerFrame = CGRect(x: 0 - (widthToAdd / 2), y: 0 - (heightToAdd / 2), width: buttonSize.width + widthToAdd, height: buttonSize.height + heightToAdd)
       return largerFrame.contains(point) ? self : nil
   }
}

This extension seems harmless and even came with comments explaining how the goal was to make sure buttons had a minimum touchable size of 44x44pts even if they appeared smaller. It misses out on one critical part of the requirements of the method though, the hit test should ignore views which have their isUserInterationEnabled property set to false.

The modern button in the project's isUserInteractionEnabled property was set to false, but was returning itself as the result of the hit test anyway. This means the view underneath it which actually should be recieving the touch event never got it. The recursive search stopped with a disabled view and nothing happened. No working back buttons, no working cancel buttons, no working search buttons.

To confirm some more, I had a look at the app with the UI debugger. In iOS 9 and 10, UIBarButtonItems are built differently. This weirdly-disabled modern button is a new thing in iOS 11. That explains why it worked fine up until now.

So what did we learn from this?

  1. Extensions on system-provided classes can be tricky, especially if you're using override methods.
  2. For this specific case, it might have been easier to just make sure buttons were not tiny rather than faking a larger touch area.
  3. Even experienced devs like me can take a while to find things that might look fairly simple in the end.
  4. Stuff like this is why it's nice to have a senior developer around.
  5. Reading the documentation is good for everyone at all levels.