Dynamic button configuration in iOS 15

⋅ 14 min read ⋅ iOS 15 UIKit Button

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.

  1. Button Configuration
  2. Dynamic Button Configuration
  3. Custom button style

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.

  1. Internal state changes such as highlighted and selected.
  2. External state changes. An example of this is a button's title that shows the number of items in the shopping cart.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

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.

Buttons can change their configuration according to button state.
Buttons can change their configuration according to button state.

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.

Attribute title that adapts to button state changes.
Attribute title that adapts to button state changes.

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)

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.

The button title use the one set with setTitle method.
The button title use the one set with setTitle method.

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)
Title set with setTitle will always be used.
Title set with setTitle will always be used.

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>.

Title in configurationUpdateHandler is used for highlighted state.
Title in configurationUpdateHandler is used for highlighted state.

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"
}
}
Title in configurationUpdateHandler is used for both normal and highlighted states.
Title in configurationUpdateHandler is used for both normal and highlighted states.

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.

A button uses a normal title set in the setTitle method as a fallback.
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.

Only the title set with an old setter method work as a fallback.
Only the title set with an old setter method work as a fallback.

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.

  1. We need to tell a button that we need to update configuration when an external state changes.
  2. 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.

A button that changes its title based on item count property.
A button that changes its title based on item count property.

You can easily support sarunw.com by checking out this sponsor.

Sponsor sarunw.com and reach thousands of iOS developers.

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 Share
Previous
Always 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.

Next
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.

← Home