How to make a custom button style with UIButton.Configuration in iOS 15

⋅ 18 min read ⋅ iOS 15 UIKit Button

Table of Contents

The final part in the series "What's new in UIKit button". In this last part, we will learn how to create our own button style. It is not as easy as I first thought, but I am quite satisfied with the result.

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

As we learn in the Button Configuration post, Apple come up with four pre-defined UIButton.Configuration, plain, gray, tinted, and filled.

From top to bottom: plain(), gray(), tinted(), filled() configurations.
From top to bottom: plain(), gray(), tinted(), filled() configurations.

It might look limited, but with a combination of background and foreground color, it should suffice for most of the design you have. But if you or your designers have a specific need that does not match these pre-defined configurations, how would you do it? This is the question we are going to discover in this article.

We will try to create a new custom button style and see what it take to do so. It will be a capsule-shape outline button style, and here is how it look.

Outline button style in different states.
Outline button style in different states.

How UIButton.Configuration work

The system-defined styles are in the form of static functions.

public static func plain() -> UIButton.Configuration

So, I plan to make something like this.

public static func outline() -> UIButton.Configuration

Since we want to create a new UIButton.Configuration, let's see what is UIButton.Configuration really is. UIButton.Configuration act as a container of a button's contents and appearance.

You can set content like title, subtitle, and image or appearance like baseBackgroundColor and baseForegroundColor. Each button configuration style will combine this information with a button state to produce different behavior for a state.

The brain of button configuration lies in a updated(for button: UIButton) method. This instance method returns a copy of the configuration based on the given button (state).

The following example shows that the same configuration can result differently based on a button state.

var configuration = UIButton.Configuration.plain()

configuration.title = "Normal"
let normal = UIButton(configuration: configuration, primaryAction: nil)

configuration.title = "Highlighted"
let highlighted = UIButton(configuration: configuration, primaryAction: nil)
highlighted.isHighlighted = true

configuration.title = "Selected"
let selected = UIButton(configuration: configuration, primaryAction: nil)
selected.isSelected = true

configuration.title = "Highlighted Selected"
let highlightedSelected = UIButton(configuration: configuration, primaryAction: nil)
highlightedSelected.isSelected = true
highlightedSelected.isHighlighted = true

configuration.title = "Disabled"
let disabled = UIButton(configuration: configuration, primaryAction: nil)
disabled.isEnabled = false
The same button configuration yield a different appearance based on button states.
The same button configuration yield a different appearance based on button states.

All the magic happens inside this updated(for button: UIButton) method. This method will produce a different configuration for the specified style based on a passing button.

Let's see the conversion again, but this time we directly called the updated(for button: UIButton) and manually passed a button instance.

let configuration = UIButton.Configuration.plain()

let normalButton = UIButton()
let c1 = configuration.updated(for: normalButton)
let b1 = c1.background.backgroundColor
print(b1)
// Optional(UIExtendedGrayColorSpace 0 0)

let selectedButton = UIButton()
selectedButton.isSelected = true
let c2 = configuration.updated(for: selectedButton)
let b2 = c2.background.backgroundColor
print(b2)
// Optional(UIExtendedSRGBColorSpace 0 0.478431 1 0.18)

The same configuration produces a style that adapts to a passing button state. In this example, the selected button results in a default blue background color.

Plain button configuration applies blue background color for a button with a selected state set to true.
Plain button configuration applies blue background color for a button with a selected state set to true.

Custom UIButton.Configuration

It is tempted to create a new outline button configuration and override this updated method for our new style. Too bad we can't either define a new UIButton.Configuration or override updated(for button: UIButton) method because button configuration is a struct :(

The best we can do is modify an existing configuration, which doesn't work since we can't override the updated(for button: UIButton). Our change can only be static and won't change according to button states.

extension UIButton.Configuration {
public static func outline() -> UIButton.Configuration {
var style = UIButton.Configuration.plain()
var background = UIButton.Configuration.plain().background
background.cornerRadius = 20
background.strokeWidth = 1
background.strokeColor = UIColor.systemGray5
style.background = background

return style
}
}

As you can see, our new button style works across all button states, which is not a behavior we want. Modifying an existing configuration like this only works if you have a style that remains constant regardless of button states.

Modify existing configuration only work if you have a style that remains constant regardless of button states.
Modify existing configuration only work if you have a style that remains constant regardless of button states.

We want a way to make our button configuration adapt to a button state. But where should we do that?

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

Sponsor sarunw.com and reach thousands of iOS developers.

updateConfiguration()

Since we can't create a new button style using button configuration, we should shift our focus to an UIButton. Luckily, UIButton got a new method that I think suits our need, updateConfiguration().

updateConfiguration() will update configuration in response to button state change. I think this is a perfect place for our needs. We can override button configuration, but this time we also get access to a button state since we are inside a UIButton instance method.

The default implementation of this method should look something like this:

override func updateConfiguration() {   
// 1
let updatedConfiguration = configuration?.updated(for: self)
// 2
self.configuration = updatedConfiguration
}

This method trigger when there is a button state change.
1 First, we ask for a new configuration for a new button state using updated(for button: UIButton) method.
2 We then apply this newly updated configuration back to the button's configuration.

As we know where to put our custom logic, let's create our outline button by subclass an UIButton.

Outline button

Here is how our outline button looks like. I create an UIButton subclass, override the updateConfiguration() method, and put styling logic there.

We have quite a big chunk of code in updateConfiguration(), so I will split the implementation detail into two parts for readability.

Part 1:

class OutlineButton: UIButton {    
override func updateConfiguration() {
guard let configuration = configuration else {
return
}

// 1
var updatedConfiguration = configuration

// 2
var background = UIButton.Configuration.plain().background

// 3
background.cornerRadius = 20
background.strokeWidth = 1

// 4
let strokeColor: UIColor
let foregroundColor: UIColor
let backgroundColor: UIColor
let baseColor = updatedConfiguration.baseForegroundColor ?? UIColor.tintColor

// ....
}
}

1 First, we get the current button configuration. We need this to get the user's current content, such as title, subtitle, and image. We don't use an updated state method (configuration?.updated(for: self)) here because we want to do the transformation manually.
2 User can pass any button configuration style which might effect a background, so I start with a known state by getting UIBackgroundConfiguration from UIButton.Configuration.plain().
3 Set basic property that apply to all button state.
4 Create a variable to hold properties that change according to state. We will fill these properties in the next chunk of code.

Part 2:
We finish the first preparation phase. The next one is a brain of our button where we try to simulate func updated(for button: UIButton) -> UIButton.Configuration method and produce configuration that adapts to button state.

class OutlineButton: UIButton {    
override func updateConfiguration() {
// ...

// 1
switch self.state {
case .normal:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
case [.highlighted]:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = baseColor.withAlphaComponent(0.3)
case .selected:
strokeColor = .clear
foregroundColor = .white
backgroundColor = baseColor
case [.selected, .highlighted]:
strokeColor = .clear
foregroundColor = .white
backgroundColor = baseColor.darker()
case .disabled:
strokeColor = .systemGray6
foregroundColor = baseColor.withAlphaComponent(0.3)
backgroundColor = .clear
default:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
}

background.strokeColor = strokeColor
background.backgroundColor = backgroundColor

updatedConfiguration.baseForegroundColor = foregroundColor
updatedConfiguration.background = background

// 2
self.configuration = updatedConfiguration
}
}

1 We switch over button state and set different colors based on that state, then we assign it back to UIButton.Configuration.
2 Don't forget to set the modified version back to the button's configuration to apply the new style.

Replace UIButton with OutlineButton and run again.

var configuration = UIButton.Configuration.filled()

configuration.title = "Normal"
let normal = OutlineButton(configuration: configuration, primaryAction: nil)

configuration.title = "Highlighted"
let highlighted = OutlineButton(configuration: configuration, primaryAction: nil)
highlighted.isHighlighted = true

configuration.title = "Selected"
let selected = OutlineButton(configuration: configuration, primaryAction: nil)
selected.isSelected = true

configuration.title = "Highlighted Selected"
let highlightedSelected = OutlineButton(configuration: configuration, primaryAction: nil)
highlightedSelected.isSelected = true
highlightedSelected.isHighlighted = true

configuration.title = "Disabled"
let disabled = OutlineButton(configuration: configuration, primaryAction: nil)
disabled.isEnabled = false

The result is almost perfect.

OutlineButton in different states.
OutlineButton in different states.

Caveats

There are two problems in our current implementation.

  1. The text color of the disabled state is not what I expected. I expected it to be light blue as define here foregroundColor = baseColor.withAlphaComponent(0.3).
  2. If you run the code, you will find that the selected buttons' appearance isn't working correctly.
Selected buttons' appearance is acting weird when tapped.
Selected buttons' appearance is acting weird when tapped.

Let's tackle the problems one by one.

Button override baseForegroundColor in disabled state

Set title color with updatedConfiguration?.baseForegroundColor = foregroundColor work for every cases but disabled state. I think UIButton does some magic under the hood for a disabled state. Luckily, we can overcome this magic by using attributedTitle. We can set text color for disabled state by setting attributedTitle.

override func updateConfiguration() {
guard let configuration = configuration else {
return
}

var updatedConfiguration = configuration

var background = UIButton.Configuration.plain().background
background.cornerRadius = 20
background.strokeWidth = 1

let strokeColor: UIColor
let foregroundColor: UIColor
let backgroundColor: UIColor
let baseColor = updatedConfiguration.baseForegroundColor ?? UIColor.tintColor

switch self.state {
case .normal:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
case [.highlighted]:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = baseColor.withAlphaComponent(0.3)
case .selected:
strokeColor = .clear
foregroundColor = .white
backgroundColor = baseColor

case [.selected, .highlighted]:
strokeColor = .clear
foregroundColor = .white
backgroundColor = baseColor.darker()
case .disabled:
strokeColor = .systemGray6
foregroundColor = baseColor.withAlphaComponent(0.3)
backgroundColor = .clear

// 1
var container = AttributeContainer()
container.foregroundColor = baseColor.withAlphaComponent(0.3)
let title = updatedConfiguration.title!
updatedConfiguration.attributedTitle = AttributedString(title, attributes: container)
default:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
}

background.strokeColor = strokeColor
background.backgroundColor = backgroundColor

updatedConfiguration.baseForegroundColor = foregroundColor
updatedConfiguration.background = background

self.configuration = updatedConfiguration
}

1 We assign attributedTitle to button configuration for disabled case.

With this change, we can override text color for the disabled state, and the appearance matches what we want in the first place. We only have one problem left, but a bigger one.

We can set text color for disabled state by setting attributedTitle.
We can set text color for disabled state by setting attributedTitle.

Wrong configuration when a state change

The problem occurs when you tap a selected button. The background color disappears and becomes gray when highlighted instead of blue.

Let's see the problem again.

The background color disappears and becomes gray when highlighted instead of blue.
The background color disappears and becomes gray when highlighted instead of blue.

The cause of the problem comes from the following line.

let baseColor = updatedConfiguration.baseForegroundColor ?? UIColor.tintColor

I use baseForegroundColor as a color for the title and background. I assume it won't change over time, and that was a mistake.

Let see the problem in slow-motion.

override func updateConfiguration() {
guard let configuration = configuration else {
return
}

var updatedConfiguration = configuration

var background = UIButton.Configuration.plain().background
background.cornerRadius = 20
background.strokeWidth = 1

let strokeColor: UIColor
let foregroundColor: UIColor
let backgroundColor: UIColor
// 1
let baseColor = updatedConfiguration.baseForegroundColor ?? UIColor.tintColor

switch self.state {
case .normal:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
case [.highlighted]:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = baseColor.withAlphaComponent(0.3)
case .selected:
strokeColor = .clear
// 2
foregroundColor = .white
backgroundColor = baseColor

case [.selected, .highlighted]:
strokeColor = .clear
foregroundColor = .white
// 4
backgroundColor = baseColor.darker()
case .disabled:
strokeColor = .systemGray6
foregroundColor = baseColor.withAlphaComponent(0.3)
backgroundColor = .clear

var container = AttributeContainer()
container.foregroundColor = baseColor.withAlphaComponent(0.3)
let title = updatedConfiguration.title!
updatedConfiguration.attributedTitle = AttributedString(title, attributes: container)
default:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
}

background.strokeColor = strokeColor
background.backgroundColor = backgroundColor

// 3
updatedConfiguration.baseForegroundColor = foregroundColor
updatedConfiguration.background = background

self.configuration = updatedConfiguration
}

First appear:
1 On the fist run baseForegroundColor is nil, so we use default tint color (blue) as a baseColor.
2 For .selected state we use baseColor as a background (backgroundColor) and set text color (foregroundColor) to .white.
3 We assign foregroundColor (.white) to updatedConfiguration.baseForegroundColor. Right now baseForegroundColor become .white instead of nil.

The first run would result as expected, but after we tap on the selected button, the background turns gray. Let's revisit the flow again.

Gray background in highlighted state:
1 On the second run baseForegroundColor is .white.
4 For [.selected, .highlighted] state we use baseColor.darker() as a background color (backgroundColor). A .darker() of white is gray.
3 We set gray (backgroundColor) as our background color and again, we assign foregroundColor (.white) to updatedConfiguration.baseForegroundColor.

After releasing a finger and the button go back to a selected state, our button turns completely white.

Background become white after lift your finger:
1 On the third run baseForegroundColor is .white.
2 For .selected state we use baseColor (.white) as a background color.
3 We assign foregroundColor (.white) to updatedConfiguration.baseForegroundColor.

As you can see, we rely on baseForegroundColor to dictate our button's color, but we keep changing this value as the button state change.

Solution

To fix the Wrong configuration when state change problem, we must not change the source of truth, baseForegroundColor, since we rely on this value to be the same through the course of change of button state.

If we won't change baseForegroundColor, how can we manipulate background and foreground color? Turn out we can do that with a help of two tranformer .titleTextAttributesTransformer and .backgroundColorTransformer. You can think of transformer as a map function that takes in input and produce a new output without changing the input. This is perfect for what we need.

The change is minimal. Instead of setting backgroundColor and foregroundColor directly, we use transformer methods to get a color we want without touching our source of truth.

override func updateConfiguration() {
guard let configuration = configuration else {
return
}

var updatedConfiguration = configuration

var background = UIButton.Configuration.plain().background
background.cornerRadius = 20
background.strokeWidth = 1

let strokeColor: UIColor
let foregroundColor: UIColor
let backgroundColor: UIColor
let baseColor = updatedConfiguration.baseForegroundColor ?? UIColor.tintColor

switch self.state {
case .normal:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
case [.highlighted]:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = baseColor.withAlphaComponent(0.3)
case .selected:
strokeColor = .clear
foregroundColor = .white
backgroundColor = baseColor

case [.selected, .highlighted]:
strokeColor = .clear
foregroundColor = .white
backgroundColor = baseColor.darker()
case .disabled:
strokeColor = .systemGray6
foregroundColor = baseColor.withAlphaComponent(0.3)
backgroundColor = .clear
default:
strokeColor = .systemGray5
foregroundColor = baseColor
backgroundColor = .clear
}

background.strokeColor = strokeColor
// 1
// background.backgroundColor = backgroundColor
background.backgroundColorTransformer = UIConfigurationColorTransformer { color in
return backgroundColor
}

// 2
// updatedConfiguration.baseForegroundColor = foregroundColor
updatedConfiguration.titleTextAttributesTransformer = UIConfigurationTextAttributesTransformer { incoming in
var container = incoming
container.foregroundColor = foregroundColor

return container
}

updatedConfiguration.background = background

self.configuration = updatedConfiguration
}

1 Instead of set .backgroundColor which affect the state of the next update, we set .backgroundColorTransformer which return a background color we want instead.
2 The same rule apply to .baseForegroundColor. We don't want to change this and mess up the next update. We apply a new foregroundColor to an AttributeContainer and return it from the transformer. This change also solves our disabled text color problem, so we can remove the fix we made in last section.

With these two changes, everything work as expected. We finally got an outline style button.

Outline button in different states.
Outline button in different states.

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

Sponsor sarunw.com and reach thousands of iOS developers.

Conclusion

The implementation in this article is just a basic guideline and contains a lot of rough edges. I only assume a user will only set a title with this outline button, and the way we initialize the button that ignores a specified button style feels weird (We can create a new initializer to make it less weird). But I think you can take it from here and add the functionality you want.

// We ignore the passing configuration style
var configuration = UIButton.Configuration.filled()

configuration.title = "Normal"
let normal = OutlineButton(configuration: configuration, primaryAction: nil)

And this is the end of the "What's new in UIKit button" series. It took me longer than I was expected, and I'm happy I can finally end the series. If you love this kind of long post series, don't forget to Subscribe so you don't miss when the next one is coming. Until next time.


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
How to set custom CodingKey for the convertFromSnakeCase decoding strategy

The thing you should know before using the convertFromSnakeCase decoding strategy.

Next
7 ways to pass a closure as an argument in Swift

There are many ways we can satisfy a closure argument. Some of them do not even look like closure. Let's learn all of them in this article.

← Home