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…
Or more interestingly this…
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.
-
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. ↩︎