What is UIButton.Configuration, and when does its configurationUpdateHandler run?

7.10.4 · L3 · Interview

Predict first

When does the update handler run after a state change?

import UIKit

var cfg = UIButton.Configuration.filled()
cfg.title = "Pay"
let b = UIButton(configuration: cfg)
var states: [String] = []
b.configurationUpdateHandler = { btn in
    states.append("sel=\(btn.isSelected) title=\(btn.configuration?.title ?? "nil")")
    var c = btn.configuration
    c?.title = btn.isSelected ? "Paying" : "Pay"
    btn.configuration = c
}
let w = UIWindow(frame: CGRect(x: 0, y: 0, width: 300, height: 300))
w.addSubview(b)
w.makeKeyAndVisible()
b.layoutIfNeeded()
print(states)
b.isSelected = true
print("before layout:", states.count)
b.layoutIfNeeded()
print(states, b.configuration?.title ?? "nil")
print(UIButton(type: .system).configuration == nil)
  • [sel=false title=Pay] / before layout: 1 / [sel=false title=Pay, sel=true title=Pay] Paying / true
  • [] / before layout: 0 / [sel=true title=Pay] Paying / true
  • [sel=false title=Pay] / before layout: 2 / … / false

Mental model. The configuration is a recipe card for the button: you do not repaint the button, you edit the card and hand it back, and the button re-cooks at its next update.

UIButton.Configuration (iOS 15) is a value type that describes a button's content and style: title, image, imagePlacement, imagePadding, contentInsets, cornerStyle, showsActivityIndicator, plus a background. You start from .plain(), .tinted(), .filled() or .gray(), copy it, change it and assign it back (the usual var c = button.configuration; c?.title = …; button.configuration = c). A button created with UIButton(type:) and setTitle has configuration == nil and uses the older per-state API; once you set a configuration, drive content through it, because the legacy setters do not update the configured title.

configurationUpdateHandler is how a configuration reacts to state (selected, highlighted, disabled, loading). It is not called synchronously: in the check it ran once at the first layout, and after isSelected = true nothing ran until the next update pass (layoutIfNeeded forces it), and the new title appeared only then. Use setNeedsUpdateConfiguration() when your own state changes (a spinner while a request runs) and do all state-dependent styling there instead of in actions. Because the handler can run often, keep it cheap and never start work or toggle state inside it, and avoid retaining the view controller from it (capture weakly, see the retain-cycle question in this section).

The handler runs at first layout and again at the next update pass, not at the moment of the change.

Common traps

  • Expecting the handler to fire immediately after changing a property. It runs at the next update pass.
  • Mixing setTitle with a configuration. The configuration owns the title; the legacy setter does not change it.

They might follow up with

  • How do I show a loading spinner? showsActivityIndicator = true in the configuration.
  • Is it the only way in iOS 15+? No, legacy buttons still work; configuration is simply the supported modern path.

Related questions

  • 7.11.2 Which UIKit patterns keep a view controller alive after it is dismissed, and how do you break each one?
  • 9.1.2 What is the difference between layoutSubviews and draw(_:), and what triggers each?

← How does UIScrollView scroll, how do you convert points between views…