Starting in iOS 15, Swift provides the AttributedString struct that embodies a string's text along with its style attributes. Question: given an existing AttributedString, and assuming (for the sake of simplicity) that the attributes consist of a single style run, how can you change just the string part of an AttributedString?
Here's a typical use case — a UIButton. Suppose I have a configuration-based button, with an attributed title:
let button = UIButton(configuration: .plain())
let font = UIFont(name: "Georgia", size: 16)
button.configuration?.attributedTitle = AttributedString(
"Hello", attributes: AttributeContainer.font(font!)
)
If I later come along and set the configuration's title to a different title, the attribute information is lost. For example:
button.configuration?.title = "Goodbye"
// Button title is no longer in Georgia font!
What I want to do here, evidently, is to replace the text of an attributed string title without disturbing its attributes. But Swift's AttributedString doesn't seem to provide a way to do that.
So, as I asked at the outset: What's the correct approach here?
Changing the text of an AttributedString is remarkably tricky. You have to replace the contents of the attributed string's character view — its characters
property. To make things even harder, you cannot do this simply by assigning another string!
Using our button as an example, this won't compile:
button.configuration?.attributedTitle?.characters = "Goodbye" // error
Nor is it sufficient to derive the character view from a simple string. This doesn't compile either:
button.configuration?.attributedTitle?.characters = "Goodbye".characters // error
This is because the separate character view of a simple string no longer exists; you're still trying to assign a String into a character view, and we already know you can't do that.
Instead, you can make an AttributedString.CharacterView directly from your String and assign that into the target attributed string's characters
property. Swift inferred typing is a big help here:
button.configuration?.attributedTitle?.characters = .init("Goodbye")
That replaces the button's title without disturbing the button's title's style attributes.
Replacing a button's title is such a useful thing to be able to do that I've made a little utility extension on UIButton that covers all the cases — a button that isn't configuration-based, a button that is configuration-based but has no attributed title, and a button that is configuration-based and has an attributed title:
extension UIButton {
func replaceTitle(_ newTitle: String) {
guard configuration != nil else {
setTitle(newTitle, for: .normal)
return
}
guard configuration?.attributedTitle != nil else {
configuration?.title = newTitle
return
}
configuration?.attributedTitle?.characters = .init(newTitle)
}
}
It may be (and has been) asked, in the case of the configuration-based button, why use the attributedTitle
at all? Why not set the font in the button configuration's buttonConfig.titleTextAttributesTransformer
?
That answer is that that doesn't work for real-life fonts that need to respond dynamically to the user changing their text size preference. To see what I mean, try this example:
let button = UIButton(configuration: .plain())
let font = UIFontMetrics(forTextStyle: .subheadline)
.scaledFont(for: UIFont(name: "Georgia", size: 16)!)
button.configuration?.title = "Hello"
button.configuration?.titleTextAttributesTransformer = .init { container in
container.merging(AttributeContainer.font(font))
}
You will see that, although the button title appears initially in the correct font, and although the font survives setting the button's configuration title, the dynamism of the font size has been lost. With a dynamically sized font, you must set the font as part of the attributed title.
This is, in fact, the use case that ultimately inspired my original question, and hence this answer.