Dynamic button configuration in iOS 15
Table of Contents
The second part in the series "What's new in UIKit button". We will learn how to change button configuration based on the internal changes, e.g., highlighted and selected, and external changes, e.g., your business logic. We will see how this new approach works with old methods such as setTitle(_:for:)
. Can it replace the old one, or can it work side by side? Let's find out.
In the previous article, we learn about button configuration and how we style our button using it. But what we did so far is quite static. It can't adapt to any button state changes like highlighted or selected.
In the old API, a button can change its content and appearance based on the button state. Here are some examples.
button.setTitle("Normal", for: .normal)
button.setTitle("Selected", for: .selected)
button.setBackgroundImage(UIImage(named: "foo"), for: .normal)
With the new button configuration API, buttons also get new methods to adjust the configuration based on the button state changes. Additionally, this new API also makes it easier than ever for a button to adapt to external state change, e.g., change according to your business logic.
I will group the changes into two categories.
- Internal state changes such as highlighted and selected.
- External state changes. An example of this is a button's title that shows the number of items in the shopping cart.
Internal state changes
Internal state change refers to any button state change, e.g., highlighted, selected, and disabled.
All configurations we discuss in the A new way to style UIButton with UIButton.Configuration in iOS 15 doesn't depend on any button states. We just set it and forgot. While that might suffice for most cases, it is great that we can modify further.
In the old day, we can set some of that in state-dependent property setter methods.
func setTitle(_ title: String?, for state: UIControl.State)
func titleColor(for: UIControl.State) -> UIColor?
func setBackgroundImage(UIImage?, for: UIControl.State)
With the coming of UIButton.Configuration
, Apple goes with a new approach that is more flexible than the previous API. Button has a dedicated place to update its configuration through the new property, configurationUpdateHandler
.
Configuration Update Handler
UIButton.ConfigurationUpdateHandler is a closure to update the configuration of a button.
typealias ConfigurationUpdateHandler = (UIButton) -> Void
We pass UIButton.ConfigurationUpdateHandler to the new button's property, configurationUpdateHandler, which will call the passing closure when the button state changes.
var configurationUpdateHandler: UIButton.ConfigurationUpdateHandler? { get set }
Here is an example where the title of a button will update according to the button state.
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
let handler: UIButton.ConfigurationUpdateHandler = { button in // 1
switch button.state { // 2
case [.selected, .highlighted]:
button.configuration?.title = "Highlighted Selected"
case .selected:
button.configuration?.title = "Selected"
case .highlighted:
button.configuration?.title = "Highlighted"
case .disabled:
button.configuration?.title = "Disabled"
default:
button.configuration?.title = "Normal"
}
}
let button = UIButton(configuration: configuration, primaryAction: nil)
button.configurationUpdateHandler = handler // 3
let selectedButton = UIButton(configuration: configuration, primaryAction: nil)
selectedButton.isSelected = true
selectedButton.configurationUpdateHandler = handler // 4
let disabledButton = UIButton(configuration: configuration, primaryAction: nil)
disabledButton.isEnabled = false
disabledButton.configurationUpdateHandler = handler // 5
<1> A update handler get called everytime button state changes.
<2> We set different title based on button.state
. We can apply to a single state or combination just like we did with .setTitle("Highlighted Selected", for: [.selected, .highlighted])
.
<3, 4, 5> We can use the same handler for all buttons.
Replaceable old API
configurationUpdateHandler alone can replace most of the state-dependent setter methods.
func setTitle(_ title: String?, for state: UIControl.State)
setTitle
can be replaced with the following update handler.
// Old API
button.setTitle("Normal", for: .normal)
button.setTitle("Highlighted", for: .highlighted)
// New API
button.configurationUpdateHandler = { button in
switch button.state {
case .highlighted:
button.configuration?.title = "Highlighted"
default:
button.configuration?.title = "Normal"
}
}
func setAttributedTitle(_ title: NSAttributedString?, for state: UIControl.State)
setAttributedTitle
can be replaced with the following update handler.
// Old API
let button = UIButton(configuration: configuration, primaryAction: nil)
let attributes: [NSAttributedString.Key: Any] = [
.underlineStyle: NSUnderlineStyle.single.rawValue
]
let string = NSAttributedString(
string: "Underline",
attributes: attributes)
button.setAttributedTitle(string, for: .highlighted)
button.setTitle("Normal", for: .normal)
// New API
var container = AttributeContainer()
container.underlineStyle = .single
let button2 = UIButton(configuration: configuration, primaryAction: nil)
button2.configurationUpdateHandler = { button in
switch button.state {
case .highlighted:
button.configuration?.attributedTitle = AttributedString("Underline", attributes: container)
default:
button.configuration?.title = "Normal"
}
}
As you can see, the results from both API are the same.
Missing old API
Some property setters are not available in the new API, e.g., func setTitleShadowColor(UIColor?, for: UIControl.State)
, func setBackgroundImage(UIImage?, for: UIControl.State)
. We don't have a way to configure these properties in the new API, and try to set them would yield no effect.
In the following example, setBackgroundImage
and setTitleShadowColor
would take no effect to the button.
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
let button = UIButton(configuration: configuration, primaryAction: nil)
button.setTitle("Normal", for: .normal)
let attributes: [NSAttributedString.Key: Any] = [
.underlineStyle: NSUnderlineStyle.single.rawValue
]
let string = NSAttributedString(
string: "Underline",
attributes: attributes)
// The following setter take no effect.
button.setBackgroundImage(UIImage(systemName: "scribble.variable"), for: .highlighted)
button.setTitleShadowColor(UIColor.systemPink, for: .highlighted)
button.titleLabel?.shadowOffset = CGSize(width: 5, height: 5)
You can easily support sarunw.com by checking out this sponsor.
AI Grammar: Correct grammar, spell check, check punctuation, and parphrase.
Old vs. New
In the last sections, we see that we can use an old API on a button created with a new initializer. This section will explore the limitations and effects of using old/new API over a button created with old/new methods.
Old button
Let's see how the old button reacts when using it with a new API.
Nil configuration
A button created with the old API contains no configuration (button.configuration
is nil
), so modifying any configuration takes no effect.
let button = UIButton(type: .system)
var container = AttributeContainer()
container.underlineStyle = .single
print(button.configuration)
// nil
button.setTitle("Old", for: .normal)
button.configuration?.title = "New" // 1
In this example, set button.configuration?.title
will not everride the one set with setTitle
since button.configuration
is nil
.
Run-time error
Try to assign any value to configurationUpdateHandler
on a button created with the old API will cause a run-time error with the following message.
Terminating app due to uncaught exception 'NSInternalInconsistencyException', reason: 'Invalid parameter not satisfying: configuration != nil'
let button = UIButton(type: .system)
var container = AttributeContainer()
container.underlineStyle = .single
button.setTitle("Old", for: .normal)
button.configurationUpdateHandler = { button in // 1
}
<1> Old button doesn't support this new API.
New button
Here is an interesting part. What happens if we try to update a new button using old and new methods together.
Old setter override configuration values
An old setter method will take precedence over the new button configuration API.
In the following example, we set title with both old and new methods with a different title.
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
let button = UIButton(configuration: configuration, primaryAction: nil)
button.configuration?.title = "New"
button.setTitle("Old", for: .normal)
The order that we set the title doesn't matter—both of the following yields the same result. The button title will be "Old".
button.setTitle("Old", for: .normal)
button.configuration?.title = "New"
// Both yield the same result
button.configuration?.title = "New"
button.setTitle("Old", for: .normal)
configurationUpdateHandler override everything
Any values set in a configuration handler will take precedence over anything set outside the closure.
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
let button = UIButton(configuration: configuration, primaryAction: nil)
button.setTitle("Old", for: .normal)
button.configuration?.title = "New"
button.configurationUpdateHandler = { button in
switch button.state {
case .highlighted:
button.configuration?.title = "New Highlighted" // 1
default:
break
}
}
button.setTitle("Old Highlighted", for: .highlighted) // 2
Highlighted title that we set in configurationUpdateHandler
<1> take precedence over setTitle
<2>.
Set configuration title in default
case would also override what we set outside configurationUpdateHandler
.
button.configurationUpdateHandler = { button in
switch button.state {
case .highlighted:
button.configuration?.title = "New Highlighted"
default:
button.configuration?.title = "New Normal"
}
}
Caveat
There is one interesting thing in the previous example. If we only set the title for the highlighted state in configurationUpdateHandler
, it will pick up a normal title from what we set in the setTitle
method.
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
let button = UIButton(configuration: configuration, primaryAction: nil)
button.setTitle("Old", for: .normal)
button.configuration?.title = "New"
button.configurationUpdateHandler = { button in
switch button.state {
case .highlighted:
button.configuration?.title = "New Highlighted"
default:
break // 1
}
}
button.setTitle("Old Highlighted", for: .highlighted)
<1> We leave other button states empty.
A button uses a normal title set in the setTitle
method as a fallback.
This kind of fallback behavior only works with the old setter method. If you set the title via button.configuration?.title
, it won't work, and the title will always be "New Highlighted".
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
let handler: UIButton.ConfigurationUpdateHandler = { button in
switch button.state {
case .highlighted:
button.configuration?.title = "New Highlighted"
default:
break // 1
}
}
let button = UIButton(configuration: configuration, primaryAction: nil)
button.setTitle("Old", for: .normal) // 2
button.configurationUpdateHandler = handler
let button2 = UIButton(configuration: configuration, primaryAction: nil)
button2.configuration?.title = "New" // 3
button2.configurationUpdateHandler = handler
<1> We not set any title in default state.
<2> button
set title with old API, setTitle
.
<3> button2
set title with new API, button2.configuration?.title = "New"
.
button
will use the title set in setTitle
as a fallback while button2
will start with "New" but permanently changed to "New Highlighted" once tap.
So, you should make sure you set every state and never leave a default case empty when using configurationUpdateHandler
.
External state changes
What we have seen so far is how we replicate the old functionality with the new API. But what I think is a great addition to this new API is how easy it is to make a button react to external changes.
When I refer to external state changes, I mean any change to the button styles that come from non-button state changes, e.g., the title of checkout button, which changes according to added items, like/unlike state. In other words, it is a change of your button based on business logic.
We need two methods for this update to work.
- We need to tell a button that we need to update configuration when an external state changes.
- We need to do the actual update to the button.
Tell a button that we need to update
Since an external state can be anything, a button can't possibly know which state might affect its appearance. You need to tell the button when that state changed. You can do that by calling the new button's method, setNeedsUpdateConfiguration()
.
setNeedsUpdateConfiguration()
requests the system to update the button configuration. The system will make the update once it receives setNeedsUpdateConfiguration
, which might not be immediate. If you call this method multiple times before the system gets a chance to do the update, the system might combine multiple requests into a single update.
Here is an example where we update the checkout button when the itemCount
property change.
var checkoutButton: UIButton = {
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
return UIButton(configuration: configuration, primaryAction: nil)
}()
private var itemCount: Int = 0 {
didSet { // 1
checkoutButton.setNeedsUpdateConfiguration() // 2
}
}
<1> When our interested state change (didSet
), we trigger setNeedsUpdateConfiguration
.
<2> Call setNeedsUpdateConfiguration()
on the button that we want to do the update.
Update the button after the state change
The way we update the button the same way we did earlier, with the configurationUpdateHandler
property.
Here is an example of that you might have in ecommerce app where checkout button showing a total number of purchase items.
private var itemCount: Int = 0 {
didSet {
checkoutButton.setNeedsUpdateConfiguration() // 1
}
}
var checkoutButton: UIButton = {
var configuration = UIButton.Configuration.filled()
configuration.baseBackgroundColor = UIColor.systemPink
configuration.buttonSize = .large
return UIButton(configuration: configuration, primaryAction: nil)
}()
override func viewDidLoad() {
super.viewDidLoad()
let addButton = UIButton(configuration: .gray(), primaryAction: UIAction(handler: { [unowned self] _ in
itemCount += 1
}))
addButton.setTitle("Add Item", for: .normal)
checkoutButton.configurationUpdateHandler = { [unowned self] button in
button.configuration?.title = "Checkout \(itemCount)" // 2
}
...
}
<1> Change in itemCount
will trigger update for checkout button.
<2> Set configurationUpdateHandler
to modify configuration from itemCount
value.
You can easily support sarunw.com by checking out this sponsor.
AI Grammar: Correct grammar, spell check, check punctuation, and parphrase.
Conclusion
In this article, we compare the new approach with the old one. The result is impressive. It can do what the old API can but in a more unified way. We can use the same interface to respond to both internal and external changes.
In the next part, I want to explore how we can use this new configuration mechanism and produce my custom style and behavior.
Read more article about iOS 15, UIKit, Button, or see all available topic
Enjoy the read?
If you enjoy this article, you can subscribe to the weekly newsletter.
Every Friday, you'll get a quick recap of all articles and tips posted on this site. No strings attached. Unsubscribe anytime.
Feel free to follow me on Twitter and ask your questions related to this post. Thanks for reading and see you next time.
If you enjoy my writing, please check out my Patreon https://www.patreon.com/sarunw and become my supporter. Sharing the article is also greatly appreciated.
Become a patron Buy me a coffee Tweet ShareAlways show search bar in a navigation bar in SwiftUI
In UISearchController, we can set search bar hiding behavior with hidesSearchBarWhenScrolling property. But how to control this behavior isn't obvious in SwiftUI. Learn how to control search bar hiding behavior in SwiftUI.
How to show and hide a sidebar in a SwiftUI macOS app
Once the sidebar is collapsed, there is no way to get it back. Learn how to mitigate the situation.