Building an adaptive button

When you want something done right, sometimes you just have to do it yourself.

I know. I started this series by extolling the virtue of using a UIButton instead of building something completely custom. You’d think for this next instalment you’d be settling in to learn the secrets of how to make your UIButtons look like this…

image-20200407073922753

Or more interestingly this…

image-20200407073958596

If we wanted to create buttons with only a single line of text or with only manual layout, this would be absolute simplicity. However, I specifically want multiple lines of text and auto layout is a requirement for any reasonable component at this point. But given how jealously UIButton manages its constraints, the only way to build a component similar to what I want would be to completely ignore its titleLabel and imageView. After exploring a number of possibilities, I reluctantly concluded I couldn’t create a UIButton derived button at all.

But just because I’m not using UIButton doesn’t mean I’m giving up and doing everything from scratch. There’s still no excuse for using UITapGestureRecognizer to solve this problem. The solution lies in moving one step up the class tree to UIControl.

By subclassing UIControl we get touch tracking implemented for us. All we need do is properly configure our constraints for titleLabel, subtitleLabel, and imageView and when our button receives touches update our colours appropriately in response to isHightlighted.

public override var isHighlighted: Bool {
    didSet {
        self.updateColors()
    }
}

The result is a fully functional button as you can see here:

Because I’ve configured both titleLabel and subtitleLabel to adjust their text size for Dynamic Type, all I need to do is tweak the size of the image when the content size category changes.

func updateImageHeightConstraint() {
    guard let imageView = self.imageView else { return }
    guard let image = imageView.image else { return }

    let bodyFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body)
    let lineHeight = bodyFont.lineHeight

    let size = image.size
    let aspectRatio = size.width / size.height
    let width = lineHeight * aspectRatio

    if self.imageWidthConstraint == nil {
        self.imageWidthConstraint = imageView.widthAnchor.constraint(equalToConstant: width)
        self.imageWidthConstraint?.priority = UILayoutPriority.defaultHigh
        self.imageWidthConstraint?.isActive = true
    } else {
        self.imageWidthConstraint?.constant = width
    }
}

The result is a button that’s both adaptive to different layouts and accessible.

Of course, when the button reaches accessibility text sizes it gets rather unusably large, so it might make sense to limit the point size of the fonts for the title and subtitle labels. We want the button to grow with Dynamic Type but not become crazy large.

var titleFont: UIFont {
    let largestContentSizeCategory = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory.accessibilityMedium)
    let normalContentSizeCategory = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory.large)

    let largestFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body,
                                           compatibleWith: largestContentSizeCategory)
    let baseFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.body,
                                        compatibleWith: normalContentSizeCategory)

    let fontMetrics = UIFontMetrics(forTextStyle: UIFont.TextStyle.body)
    return fontMetrics.scaledFont(for: baseFont,
                                  maximumPointSize: largestFont.pointSize,
                                  compatibleWith: self.normalContentSizeCategory)
}

var subtitleFont: UIFont {
    let largestContentSizeCategory = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory.accessibilityMedium)
    let normalContentSizeCategory = UITraitCollection(preferredContentSizeCategory: UIContentSizeCategory.large)

    let largestFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.footnote,
                                           compatibleWith: largestContentSizeCategory)
    let baseFont = UIFont.preferredFont(forTextStyle: UIFont.TextStyle.footnote,
                                        compatibleWith: normalContentSizeCategory)

    let fontMetrics = UIFontMetrics(forTextStyle: UIFont.TextStyle.footnote)
    return fontMetrics.scaledFont(for: baseFont,
                                  maximumPointSize: largestFont.pointSize,
                                  compatibleWith: normalContentSizeCategory)

}

The one drawback to using a UIControl instead of a UIButton is there doesn’t seem to be a good way to convince it to send UIControl.Event.primaryActionTriggered when the user taps the button. Instead you’ll need to add your actions to UIControl.Event.touchUpInside.1 This is a bummer, but not the end of the world. I’ll probably add a bit more code to simulate the .primaryActionTriggered control event.

An additional draw back when using the UIControl in Interface builder is that dragging a link from the button to a View Controller to set up an action will connect the UIControl.Event.valueChanged handler. I wish I had some clue how to fix this, but I don’t use Interface Builder and in my regular workflow.

Using UIControl to replace UIButton seems completely viable. The result is accessible to Dynamic Type and Voice Over as well as meeting my design goals. I admit I’m disappointed I wasn’t able to find a solution using UIButton, but that doesn’t mean you shouldn’t use UIButton for simpler tasks.

If you’d like to take a look at the code, please hop over to GitHub. I make no promises or warrantees. I might even update it as I explore new solutions.


  1. I find lots of iOS developers still use UIControl.Event.touchUpInside any way, at least when configuring their target/actions in code. So this might not be too big a problem. ↩︎