Nobody loves UIButton

Dating back all the way to the first iOS SDK you’d think there’d be a bit more love for this venerable component.

I’m rather particular about ensuring my apps follow accessibility best practices. I really want to ensure as many people are getting the benefit from my work as possible. That’s why I cringe when I see code like this:

let tapRecogniser = UITapGestureRecognizer(target: self,
                      action: #selector(doSomethingGreat))
complicatedView.addGestureRecognizer(tapRecognizer)

In this example complicatedView is a view containing maybe a label or two and an image. You know, rather like a button.

Chances are you’ve seen something like this in code you work on. Maybe you’ve even written something just like it. There are a number of accessibility problems with using a UITapGestureRecognizer as if it were a button. Lets start with the first most obvious one: it isn’t a button, so your views won’t behave like a button.

We’re all familiar with buttons. So familiar in fact that they’ve blended into the background of iOS user interfaces. We’ve probably forgotten many of the intricacies of how buttons interact with users. First, they provide feedback as demonstrated in the video below.

As you can see, tapping on a button causes the text to highlight while tapping on the views using a UITapGestureRecognizer does not. This is an important bit of feedback. It’s one of the many things customers look for when they determine whether your app feels like a native app.

I’ve also heard complaints about how hard it is to work with multiple lines of text in UIButton. As you can see, the demo I’m using has two lines of text each in a different font. To make this easier on myself, I subclassed UIButton.1 My subclass exposes two additional properties title and subtitle and makes setTitle(_ title:, for state:) and setAttributedTitle(_ title, for state:) unavailable to ensure code uses the new properties.

The properties are both very simple:

public var title: String? {
    didSet {
        self.updateTitle()
    }
}

public var subtitle: String? {
    didSet {
        self.updateTitle()
    }
}

All the complexity lives in the updateTitle method. After fetching the paragraph style to use based on the content alignment, we set to work creating an attributed string for the button text:

if let title = self.title {
    let font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
    let attributes = [NSAttributedString.Key.font: font]
    let attributedTitle = NSAttributedString(string: title,
                                             attributes: attributes)
    buttonText.append(attributedTitle)
    accessibilityParts.append(title)
}

Starting with the title property, I create an attributed string using the preferred font for body text. You’ll note I also record the title for use in the accessibility label later. Next, I do the same thing for the subtitle property:

if let subtitle = self.subtitle {
    let font = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.footnote)
    let attributes = [NSAttributedString.Key.font: font]
    let attributedSubtitle = NSAttributedString(string: subtitle,
                                                attributes: attributes)
    if buttonText.length > 0 {
        buttonText.append(NSAttributedString(string: "\n"))
    }
    buttonText.append(attributedSubtitle)
    accessibilityParts.append(subtitle)
}

Finally, I apply the paragraph style corresponding to the value of UIControl.ContentHorizontalAlignment and use a wee bit of trickery to ensure the title updates without any unneeded blinking:

let range = NSRange(location: 0, length: buttonText.length)
buttonText.addAttribute(NSAttributedString.Key.paragraphStyle,
                        value: paragraphStyle,
                        range: range)

// Prevent an unwanted title update animation by turning off animations
// and calling layoutIfNeeded.
UIView.performWithoutAnimation {
    super.setAttributedTitle(buttonText, for: UIControl.State.normal)
    self.layoutIfNeeded()
}

And because I want to ensure my multi-line button sounds just right I set the accessibilityLabel value when I’m all done2:

self.accessibilityLabel = accessibilityParts.joined(separator: "\n")

That probably seems like a lot of work just to get multiple lines of text working in a UIButton, but it’s worth it. First, now I have a multiline button that behaves like a button. And it’s accessible as you can see in the following video.

Voice Over users expect to be given a hint that the item they’re interacting with can be activated — that it isn’t just static text. In the case of buttons, that hint is the additional announcement of “Button” after the phrase “Edit something, you know you want to.” Furthermore, the entire phrase is one Voice Over element, while the multi-view UITapGestureRecognizer exposes each label individually — and each can be activated to perform the action of the pseudo-button.

Of course, if you’ve gone all in on SwiftUI you won’t be looking back at UIButton at all, but if you’re like most of us and can’t yet adopt SwiftUI there’s still a lot to love about UIButton. I’d urge you to take a closer look. I bet you’ve misjudged it.


  1. So scandalous! There once was a time when we were told not to do this, because of the dangers of class clusters. But now this seems to be totally fine as long as you’re using a standard button. ↩︎

  2. In theory, UIButton should use the attributedTitle for the accessibilityLabel, however it doesn’t seem to pause correctly for the newline. This just makes it sound a little better. ↩︎