Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

Dynamic type for UIButton's attributeTitle

Anyone ever got dynamic type working for attributedTitle on UIButton? Consider the super simple code below:

let font = UIFont(name: "Helvetica", size: 14)!
let scaledFont = UIFontMetrics.default.scaledFont(for: font)

let button = UIButton(type: .custom)
button.titleLabel?.font = scaledFont
button.titleLabel?.adjustsFontForContentSizeCategory = true

let attributes: [NSAttributedString.Key: Any] = [ .font: scaledFont ]
let attributedText = NSAttributedString(string: "Press me", attributes: attributes)
button.setAttributedTitle(attributedText, for: .normal)

If I scale the font size up and down using Accessibility Inspector, the button's size and label text doesn’t scale properly.

If I just call button.setTitle() passing an ordinary string, though, dynamic type scaling works fine.

Using the same pattern for attributed text directly on a UILabel works fine… it just seems to be when I use attributed text for a UIButton’s title.

Any thoughts or suggestions would be awesome. Thanks

Edit: After a bit more poking, it looks like what is happening is that the text is trying to scale, but the button's width/height aren't growing with it. If I dial up dynamic type to the largest text size and then create the screen and proceed to shrink the font size, it works OK because the buttons width/height constraints are set to an initially large value. But if I start with a small dynamic type setting and grow larger, the button doesn't accommodate the text size change

like image 973
Craig Edwards Avatar asked Dec 10 '22 02:12

Craig Edwards


2 Answers

If I scale the font size up and down using Accessibility Inspector, the button's size and label text doesn’t scale properly.

To scale the attributed string label of a UIButton with the Dynamic Type, set the title label as an attributed string first and then put this element in the setAttributedTitle button method.

About the button size, specify the sizeToFit method of the button in the traitCollectionDidChange instance method of the UITraitEnvironment protocol (using constraints can be another solution as well).

I created a blank project in Xcode as follows: enter image description here

Copy-paste the code snippet hereunder (Swift 5.0, iOS 12):

class ViewController: UIViewController {

    @IBOutlet weak var myButton: UIButton!

    override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)
    
        myButton.layer.borderColor = UIColor.black.cgColor
        myButton.layer.borderWidth = 4.0
    
        myButton.contentEdgeInsets = UIEdgeInsets(top: 10,
                                                  left: 20,
                                                  bottom: 10,
                                                  right: 20)
        
        let font = UIFont(name: "Helvetica", size: 19)!
        let scaledFont = UIFontMetrics.default.scaledFont(for: font)

        let attributes = [NSAttributedString.Key.font: scaledFont]
        let attributedText = NSAttributedString(string: "Press me",
                                                attributes: attributes)
        
        myButton.titleLabel?.attributedText = attributedText
        myButton.setAttributedTitle(myButton.titleLabel?.attributedText,
                                    for: .normal)

        myButton.titleLabel?.adjustsFontForContentSizeCategory = true
    }


    override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    
        myButton.sizeToFit()
    }
}

... and you get the result hereafter: enter image description here If you need further explanation, I suggest to take a look at this Dynamic Type kind of tutorial that contains {code snippets + illustrations} and at this WWDC detailed summary that deals with building apps with Dynamic Type.

⚠️ EDIT 2021/02/15 ⚠️

Thanks to @Anthony's comment, I have improved this old solution to the new context as follows:

override func viewDidAppear(_ animated: Bool) {
        super.viewDidAppear(animated)

        myButton.layer.borderColor = UIColor.black.cgColor
        myButton.layer.borderWidth = 4.0

        let font = UIFont(name: "Helvetica", size: 20)!
        let scaledFont = UIFontMetrics.default.scaledFont(for: font)

        let attributes = [NSAttributedString.Key.font: scaledFont]
        let attributedText = NSAttributedString(string: "Press Me Huge Button",
                                                attributes: attributes)

        myButton.titleLabel?.attributedText = attributedText
        myButton.titleLabel?.numberOfLines = 0
        myButton.titleLabel?.textAlignment = .center
        
        myButton.setAttributedTitle(attributedText,
                                    for: .normal)

        myButton.titleLabel?.adjustsFontForContentSizeCategory = true
        
        createConstraints()
    }

... and added constraints between the button and its content:

override func traitCollectionDidChange(_ previousTraitCollection: UITraitCollection?) {
    
    DispatchQueue.main.async() {
        self.myButton.setNeedsUpdateConstraints() // For updating constraints.
    }
}

private func createConstraints() {
    
    myButton.translatesAutoresizingMaskIntoConstraints = false
    myButton.titleLabel?.translatesAutoresizingMaskIntoConstraints = false
    
    let spacing = 10.0
    myButton.titleLabel?.trailingAnchor.constraint(equalTo: myButton.trailingAnchor,
                                                   constant: -CGFloat(spacing)).isActive = true
    myButton.titleLabel?.leadingAnchor.constraint(equalTo: myButton.leadingAnchor,
                                                  constant: CGFloat(spacing)).isActive = true
    myButton.titleLabel?.topAnchor.constraint(equalTo: myButton.topAnchor,
                                              constant: CGFloat(spacing)).isActive = true
    myButton.titleLabel?.bottomAnchor.constraint(equalTo: myButton.bottomAnchor,
                                                 constant: -CGFloat(spacing)).isActive = true
}

With some new constraints for the button inside Interface Builder, I finally get those screenshots using the Xcode Environment Overrides pane: enter image description here enter image description here

⚠️ EDIT 2022/01/28 ⚠️

iOS 15 introduced a new button style that provides a native adaptation to the multiline titles and the Dynamic Type features. 🙏

No code is necessary anymore to reach the initial goal of Craig. 👍

like image 136
XLE_22 Avatar answered Dec 24 '22 11:12

XLE_22


Just in case anyone is following along, the key to resolving this is contained in the first para of @XLE_22's post but is quite subtle.

When calling setAttributedTitle() on the button, you need to pass the attributed string from the label. ie. myButton.titleLabel?.attributedText... passing the local variable attributedText does not work. I can't rationalise why this is the case though. Consider the following code:

myButton.titleLabel?.attributedText = attributedText
let labelAttributedText = myButton.titleLabel?.attributedText

myButton.setAttributedTitle(labelAttributedText, for: .normal)  // works
myButton.setAttributedTitle(attributedText, for: .normal)       // doesn't work

If I compare attributedText with labelAttributedText, I can see that the former is a NSConcreteAttributedString whereas the latter is a NSConcreteMutableAttributedString. Asking them if they are equal return true and a quick comparison of their attributes seems to suggest that they are indeed the same, but there must be something ever so slightly different.

I tried initially creating attributedText as a mutable attributed string, but that didn't help either. Very puzzling, but a huge thanks to @XLE_22 for the tip... I doubt I would have ever tried that specific trick on my own.

like image 21
Craig Edwards Avatar answered Dec 24 '22 10:12

Craig Edwards