Styling Components in SwiftUI

SwiftUI has a great API for styling views independent of a view’s implementation. In this post, we’ll look at how we can style custom views in the same way.

By Kasper Lahti on

Last year, over the course of a few episodes on Swift Talk, we demonstrated how to make a custom stepper control for incrementing and decrementing a value. It was similar to SwiftUI’s Stepper, but with an API that makes it styleable.

This post is a recap of what we covered then, along with a few tricks we’ve learned in the time since to make our custom view styles work even more like SwiftUI’s built-in ones. In Composable Styles in SwiftUI, we go though some more advanced use cases.

Styling a Button

To start, let’s look at a simple button:

Button("Continue") {
    // Continue
}

We can change the style of this button by adding a few view modifiers and using a different method to create the button’s label:

Button {
    // Continue
} label: {
    HStack {
        Spacer()
        Text("Continue")
        Spacer()
    }
    .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
}
.font(.system(.title2, design: .rounded, weight: .bold))
.foregroundColor(.yellow)
.background(Capsule().stroke(.yellow, lineWidth: 2))

While SwiftUI makes configuring views very convenient, having to apply the same modifiers and wrapping the label in an HStack every time doesn’t scale well. As such, we need a more reusable approach.

Reusing a Style

When building an app, we usually want views and controls to have a consistent style throughout the app, both to make them recognizable to the user as they move from screen to screen, and to establish a theme for the app or a tie-in to the branding of a company.

To make it easier to apply the same styling to many Button views, one option is to make a new button view with an API similar to SwiftUI’s Button and apply the styling there:

struct MyButton<Label: View>: View {
    var action: () -> Void
    var label: Label

init(action: @escaping () -> Void, @ViewBuilder label: () -> Label) { self.action = action self.label = label() }
var body: some View { Button { action() } label: { HStack { Spacer() label Spacer() } .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24)) } .font(.system(.title2, design: .rounded, weight: .bold)) .foregroundColor(.yellow) .background(Capsule().stroke(.yellow, lineWidth: 2)) } }

However, making a wrapper view that supports the same convenience initializers as the wrapped view can be a bit of work, as the code below demonstrates:

struct MyButton where Label == Text {
    @_disfavoredOverload
    init(_ title: some StringProtocol, role: ButtonRole? = nil, action: @escaping () -> Void) {
        self.action = action
        self.role = role
        self.label = Text(title)
    }

init(_ titleKey: LocalizedStringKey, role: ButtonRole? = nil, action: @escaping () -> Void) { self.action = action self.role = role self.label = Text(titleKey) } }

While having styling defined in one place is nice, it’s important to remember to use the My­Button view instead of SwiftUI’s Button everywhere in the app. Otherwise, we’ll end up with inconsistent styling.

VStack {
    MyButton("OK") {
        // Confirm
    }
    Button("Cancel") {
        // Oops, wrong button
    }
}

Fortunately, SwiftUI has an API that solves this problem.

View Styles

SwiftUI’s view style APIs work like the view modifiers font(_:) and tint(_:), in that they allow adding a style to a view hierarchy and applying that style to all relevant views within that view hierarchy:

HStack {
    Button("Undo") { /* … */ }
    Button("Redo") { /* … */ }
}
.foregroundColor(.black)
.font(.system(.title3, design: .rounded).bold())
.tint(.yellow)
.buttonStyle(.borderedProminent)

We can use this behavior to ensure the same style of button is used across an entire app by applying the modifier at the root of the view hierarchy:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .tint(.yellow)
                .buttonStyle(.borderedProminent)
        }
    }
}

SwiftUI has a few built-in styles to choose from, and for some of its styleable views, it’s possible to create new styles.

Making a Custom Button Style

To completely customize the style of a Button, we first need something that SwiftUI can use for styling when displaying a Button.

The button­Style(_:) modifier that we use to set a style for buttons takes a type that conforms to either the Button­Style protocol or the Primitive­Button­Style protocol.

So to make a custom style, we create a new type that conforms to one of these protocols.

Both protocols require a function called make­Body(configuration:). The configuration that’s passed to this function has a few properties that represent the button we’re styling.

We can use configuration.label to get a view representing the label of a button and apply our styling to that label:

struct CustomButtonStyle: ButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack {
            Spacer()
            configuration.label
            Spacer()
        }
        .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
        .font(.system(.title2, design: .rounded).bold())
        .padding(EdgeInsets(top: 12, leading: 24, bottom: 12, trailing: 24))
        .foregroundColor(.yellow)
        .background {
            Capsule()
                .stroke(.yellow, lineWidth: 2)
        }
    }
}

The code above enables us to set the style of a button with .button­Style(Custom­Button­Style()).

The style configuration also has properties that let the style check if a button is pressed and what role the button has.

We can use these properties to give the button a different look when pressed or if the button has a destructive action:

.background {
    Capsule()
        .stroke(configuration.role == .destructive ? .red : .yellow, lineWidth: 2)
}
.opacity(configuration.isPressed ? 0.5 : 1)
VStack(spacing: 16) {
    Button(role: .destructive) {
        // Delete
    } label: {
        Label("Delete", systemImage: "trash")
    }
    Button("Cancel", role: .cancel) {
        // Cancel Deletion
    }
}
.buttonStyle(CustomButtonStyle())

Styling Disabled States

Notably absent from the configuration is a flag that indicates whether or not the button is disabled. So to set the disabled state on a button, we use the disabled(_:) view modifier.

Since this view modifier is an extension on View, we can set the disabled state anywhere in the view hierarchy. This is convenient when, for example, we want to disable all controls in a view while a form is being sent.

However, the documentation doesn’t make it clear that this modifier sets an environment value. As such, to adjust the button when it’s disabled, we need to check the somewhat surprisingly named is­Enabled environment value:

@Environment(\.isEnabled) var isEnabled

func makeBody(configuration: Configuration) -> some View { // ... .saturation(isEnabled ? 1 : 0) }

A pile of range sliders in different styles.

Styling Custom Views

Being able to create custom styles for built-in SwiftUI views can be very useful when they don’t look like we want them to, but unfortunately, it isn’t possible to create custom styles for all built-in SwiftUI views.

While we can resort to adding view modifiers for styling inline in custom views or make do with what SwiftUI provides, we can do better by making our custom views styleable.

Let’s try this out on a custom Range­Slider control:

struct RangeSlider<Label: View>: View {
    @Binding
    var range: ClosedRange<Double>

var bounds: ClosedRange<Double>?
var label: Label
init(value: Binding<ClosedRange<Double>>, in bounds: ClosedRange<Double>? = nil, @ViewBuilder label: () -> Label) { self._value = value self.bounds = bounds self.label = label() }
var body: some View { LabeledContent { // ... } label: { label } } }

So what needs to happen to make this view styleable?

Returning to SwiftUI’s style APIs for Button, we can see it has two protocols for styling a button.

Button­Style makes it easier to create new styles, since it lets Button take care of the gesture handling, while Primitive­Button­Style gives the style control over implementing how the button interaction works.

Other style protocols in SwiftUI resemble the Primitive­Button­Style protocol, in that they also give control of the interaction to the style. For example, when implementing a Toggle­Style, the Toggle view doesn’t handle the gesture for us; instead, it provides a binding to control its value that the style needs to update when the user taps the view.

To give more control to the range slider styles to define what the interaction should be, we can base our style protocol on the primitive version of SwiftUI’s button styling API.

There are three parts to the styling APIs — a view modifier used to set the style, a protocol, and a style configuration:

extension View {
    public func buttonStyle<S>(_ style: S) -> some View where S : PrimitiveButtonStyle
}
public protocol PrimitiveButtonStyle {
    associatedtype Body : View

@ViewBuilder func makeBody(configuration: Self.Configuration) -> Self.Body
typealias Configuration = PrimitiveButtonStyleConfiguration }
public struct PrimitiveButtonStyleConfiguration {
    // ...

public let role: ButtonRole?
public let label: PrimitiveButtonStyleConfiguration.Label
public func trigger() }

To style the Range­Slider in the same way as the built-in views, we can copy most of the code above.

We can start by defining our own style configuration type.

The properties on Primitive­Button­Style­Configuration might look a little familiar, since they map almost directly to the types from one of Button’s initializers:

public init(
    role: ButtonRole?,
    action: @escaping () -> Void,
    @ViewBuilder label: () -> Label
)

So for our own configuration type, we can take the same approach and copy over the types from our view into the configuration:

struct RangeSliderStyleConfiguration {
    @Binding
    let range: ClosedRange<Double>

let bounds: ClosedRange<Double>?
let label: Label }

The code above is straightforward for value and bounds, since we can just copy them as is. However, for label, we need to do something different, since this type is now referring to SwiftUI’s Label type instead of the generic type Range­Slider has for its label.

Button also has a generic type for its label, which allows us to create buttons that use different view types for their labels. The button label is often a Text, Label, or HStack type, but it could be any type.

Because there’s an unlimited number of possible Button types, SwiftUI uses type erasure to hide all of them behind an opaque Button­Configuration.Label view. This isolates the style from the concrete, parameterized component and allows a single style to be applied to any possible Button.

We’ll use the same pattern to keep Range­Slider generic over its label without the user of the style API having to know which specific labels will be used:

/// A type-erased label of a button.
public struct Label : View {
    public typealias Body = Never
}

SwiftUI defines the Body type as Never, which means it’ll use private APIs to render the label view.

We can’t do exactly what SwiftUI does here, but we can use Any­View to get the same effect:

/// A type-erased label of a range slider.
struct Label: View {
    let underlyingLabel: AnyView

init(_ label: some View) { self.underlyingLabel = AnyView(label) }
var body: some View { underlyingLabel } }

For the style protocol, we can copy what SwiftUI does:

protocol RangeSliderStyle {
    associatedtype Body: View

@ViewBuilder func makeBody(configuration: Configuration) -> Body
typealias Configuration = RangeSliderStyleConfiguration }

With the protocol in place, we can implement the first style by using the values from the style configuration:

struct DefaultRangeSliderStyle: RangeSliderStyle {
    func makeBody(configuration: Configuration) -> some View {
        GeometryReader { proxy in
            ZStack {
                Capsule()
                    .fill(.regularMaterial)
                    .frame(height: 4)
                Capsule()
                    .fill(.tint)
                    .frame(height: 4)
                    // Width and position...
                Circle()
                    .frame(width: 27, height: 27)
                    // Gesture handling and positioning...
                Circle()
                    .frame(width: 27, height: 27)
                    // Gesture handling and positioning...
            }
        }
        .frame(height: 27)
    }
}

To set the style for a view hierarchy, we add an extension on View that matches the button­Style(_:) signature, but we can use the some keyword to make it a little more concise:

extension View {
    func rangeSliderStyle(_ style: some RangeSliderStyle) -> some View {
        environment(\.rangeSliderStyle, style)
    }
}

Built-in styles flow down the view hierarchy like environment values do. As such, we can use an environment value to pass a style through the view hierarchy to the view that should be styled. In doing so, we’ll end up with the same style propagation behavior as the built-in styles.

To be able to put any type of style conforming to our protocol in the environment as a value, we need to make the type of the environment value any Range­Slider­Style.

Environment values require a defined default value. SwiftUI’s styleable views always have a default style, so we can define what it should be for the view here:

struct RangeSliderStyleKey: EnvironmentKey {
    static var defaultValue: any RangeSliderStyle = DefaultRangeSliderStyle()
}

extension EnvironmentValues { var rangeSliderStyle: any RangeSliderStyle { get { self[RangeSliderStyleKey.self] } set { self[RangeSliderStyleKey.self] = newValue } } }

With the environment value in place, we can implement the body of our view by creating a style configuration with the inputs from our component and then calling the make­Body method on the style we get from the environment:

struct RangeSlider<Label: View>: View {
    // ...
    @Environment(\.rangeSliderStyle) var style

var body: some View { let configuration = RangeSliderStyleConfiguration( range: $range, bounds: bounds, label: .init(label) )
style.makeBody(configuration: configuration) } }

However, if we try to run this, we’ll get a compiler error.

Type ‘any View’ cannot conform to ‘View’

Since body expects us to return a concrete view type, and since the style we get from the environment is of type any Range­Slider­Style, we need to wrap the view we get back from the make­Body call in an Any­View:

AnyView(style.makeBody(configuration: configuration))

That makes it compile again. Now, with everything in place, we have a custom range slider view that can be styled by a type conforming to the style protocol, and we can set what style to use in the same way we’d set a style for a built-in SwiftUI view:

Form {
    // ...
    RangeSlider(range: $sizeRange, in: minSize...maxSize) {
        Text("Size Range")
    }
    // ...
}
.rangeSliderStyle(VerticalRangeSliderStyle())

And just like with custom styles for SwiftUI’s styles, we can add a static member on the style protocol. This enables us to set the style with the same shorthand syntax the built-in styles provide, making our custom views fit in with the built-in views:

extension RangeSliderStyle where Self == VerticalRangeSliderStyle {
    static var vertical: Self { .init() }
}
.rangeSliderStyle(.vertical)

Accessibility

When making custom views, it’s important to also make them accessible. We can do this by adding view modifiers that improve the experience when using the view with, for example, VoiceOver:

struct MySlider: View {
    // ...

var body: some View { // ... AnyView(style.makeBody(configuration: configuration)) .accessibilityElement(children: .combine) .accessibilityValue(valueText) .accessibilityAdjustableAction { direction in switch direction { case .increment: increment() case .decrement: decrement() @unknown default: break } } }
var valueText: Text { if bounds == 0.0...1.0 { return Text(value.wrappedValue, format: .percent) } else { return Text(value.wrappedValue, format: .number) } } }

Here, we assume that most sliders will want to use accessibility­Adjustable­Action(_:), so we can add this modifier to the slider view. This way, the teams working on the styles don’t need to worry about making the style accessible, because the view component already is.

A pile of switches, checkboxes, and sliders with different states and sizes.

Using Environment Values in a Custom Style

For many views, it’s important to be able to adapt them to different environment values — for example, a control should indicate when it’s disabled, a highlight effect for a view that’s being pressed could need a different color if the color scheme is set to dark, or a state change animation might need to be skipped if the reduced motion preference is on.

It’s also important to be able to use a tint style or control size, if specified.

For example, maybe controls in an onboarding flow should be a bit larger than in other places of the app. If view styles adjust when the control­Size is large, we could set the control­Size environment value for that part of the app and and avoid creating completely new styles or views specific to that onboarding flow.

Using environment values in a style for a built-in SwiftUI view works as we’d expect, but if we try the same thing in a style for a view we created our own style protocol for, it doesn’t work as expected:

struct CheckboxMultipleChoiceStyle: MultipleChoiceStyle {
    @Environment(\.isEnabled) var isEnabled

func makeBody(configuration: Configuration) -> some View { /* ... */ .saturation(isEnabled ? 1 : 0) .brightness(isEnabled ? 0 : -0.2) } }
MultipleChoice(selection: $extras) {
    ForEach(Extra.allCases) { extra in
        Text(extra.name).tag(extra)
    }
}
.disabled(isOutOfStock)
.multipleChoiceStyle(.checkbox)

This used to be a bit confusing, because it wasn’t obvious why it didn’t work. However, as of Xcode 14.1, running this code will trigger a helpful runtime warning that explains the problem:

Accessing Environment’s value outside of being installed on a View. This will always read the default value and will not update.

Checkbox­Multiple­Choice­Style isn’t a View, so to use the environment, we need to put this on a view or somehow update the environment value on the style.

One way to use an environment variable from a style is to wrap our style’s body in a new view:

struct CheckboxMultipleChoice: View {
    var configuration: MultipleChoiceStyleConfiguration

@Environment(\.isEnabled) var isEnabled
var body: some View { /* ... */ .saturation(isEnabled ? 1 : 0) .brightness(isEnabled ? 0 : -0.2) } }
struct CheckboxMultipleChoiceStyle: MultipleChoiceStyle { func makeBody(configuration: Configuration) -> some View { CheckboxMultipleChoice(configuration: configuration) } }

While the technique outlined above works, it’s unfortunate that we need treat styles for custom views differently to built-in view styles and remember to use this technique every time we’re creating a style for a custom view.

Dynamic Property

We weren’t the only ones to think so; at the SwiftUI Digital Lounges WWDC 2022, someone asked about this and got a reply that SwiftUI updates environment values on members conforming to Dynamic­Property, and that it does so recursively.

The documentation for Dynamic­Property is light on details, but it does mention the following:

“The view gives values to these properties prior to recomputing the view’s body.”

Now, it doesn’t say what values, but it sounds like these might be the values that we imagine @State or @Environment need to work.

So can we just conform our style to Dynamic­Property?

protocol RangeSliderStyle: DynamicProperty {
    // ...
}

While the style is on a view and the @Environment property wrapper also conforms to Dynamic­Property, this doesn’t work.

We can instead try to put the style on an intermediate view:

struct ResolvedRangeSliderStyle: View {
    var configuration: RangeSliderStyleConfiguration

var style: any RangeSliderStyle
var body: some View { AnyView(style.makeBody(configuration: configuration)) } }

Then, we pass the style along to the intermediate view in our component:

var body: some View {
    let configuration = RangeSliderStyleConfiguration(range: $range, bounds: bounds, label: .init(label))
    ResolvedRangeSliderStyle(configuration: configuration, style: style)
}

But trying this, we see it still doesn’t work.

In our experimentation, we found that SwiftUI updates the environment values on the style if we have a concrete style type in the environment.

So we instead write a wrapper view that’s generic over the style:

struct ResolvedRangeSliderStyle<Style: RangeSliderStyle>: View {
    var configuration: RangeSliderStyleConfiguration

var style: Style
var body: some View { style.makeBody(configuration: configuration) } }

However, now if we try to pass the style from the environment, we run into this complier error:

Type ‘any RangeSliderStyle’ cannot conform to ’RangeSliderStyle’

It might seem like we’re stuck here, but we move this to an extension on the style protocol, which gives us access to the concrete type via self:

extension RangeSliderStyle {
    func resolve(configuration: Configuration) -> some View {
        ResolvedRangeSliderStyle(configuration: configuration, style: self)
    }
}

Back in the body of our component, we can now call the resolve method on the style instead:

var body: some View {
    let configuration = RangeSliderStyleConfiguration(range: $range, bounds: bounds, label: .init(label))
    AnyView(style.resolve(configuration: configuration))
}

The code above compiles again. By conforming the style to Dynamic­Property and putting the concrete style on an intermediate view via a helper on the style protocol, styles can now use @Environment and other property wrappers conforming to Dynamic­Property without having to use a workaround in each style.

Style Propagation

One of the benefits of styleable views is that we don’t need to set a style directly on each view in our app. Instead, we can set a style on a container view for a screen or at the root of the app to use that style across the app:

WindowGroup {
    ContentView()
        .buttonStyle(.borderedProminent)
        .toggleStyle(.checkbox)
        .rangeSliderStyle(.rounded)
        // ...
}

Unfortunately, in some circumstances, styles for built-in SwiftUI views — like Button­Style and Toggle­Style — aren’t propagated to views displayed in a modal presentation.

So, if we’re presenting a view in a modal presentation like a sheet, a fullscreen­Cover, or a popover, we need to make sure to reset the styles on the presented view if we want the styles to be used there too.

Curiously, the styles are passed along to content in popovers if the modifier is within a Navigation­Stack, and if we have a Navigation­Stack within a Tab­View, the styles are passed along to all types of modal presentations.

Hopefully, this will be addressed in future versions of SwiftUI, but in the meantime, there’s a way to make this work.

If we do a little UIKit dance by wrapping a view that has a sheet modifier in a UIView­Controller­Representable that, in turn, is using a UIHosting­Controller to display that view, we can present sheets and have the styles propagate:

struct UIKitDanceView<Content: View>: UIViewControllerRepresentable {
    var content: Content

init(@ViewBuilder content: @escaping () -> Content) { self.content = content() }
func makeUIViewController(context: Context) -> UIHostingController<Content> { return UIHostingController(rootView: content) }
func updateUIViewController(_ uiViewController: UIHostingController<Content>, context: Context) {
} }
extension View {
    func preserveStylesInSheets() -> some View {
        UIKitDanceView {
            self
        }
    }
}

Then, we can apply all our styles in the root of our application and add the preserve­Styles­In­Sheets() modifier to ensure those styles are also used in any modal presentations.

WindowGroup {
    ContentView()
        .preserveStylesInSheets()
        .buttonStyle(.borderedProminent)
        .toggleStyle(.checkbox)
        .rangeSliderStyle(.rounded)
        // ...
}

Wrapping It Up

With an app built with styleable components, we can move styling modifiers from our views into styles and have a clear separation between the styling and the functionality of the app.

As a result, views become be more concise and easier to maintain, and we can work more efficiently with the styling of our app when it isn’t so intertwined with the functionality:

@main
struct MyApp: App {
    var body: some Scene {
        WindowGroup {
            ContentView()
                .theme(.springSummer2023)
        }
    }
}