Composable Styles in SwiftUI

A look at how to compose styles and how to make custom views support composable styles.

By Kasper Lahti on

In the previous article, Styling Components in SwiftUI, we discussed the benefits of using SwiftUI’s view style APIs in an application and walked through how to give custom views a styling API.

One benefit we highlighted is that we can define a style to use on a view hierarchy and have the style propagate down to all relevant views within that view hierarchy.

However, depending on what views the view hierarchy is made up of, this style propagation behavior won’t behave as we might expect. It turns out there’s a reason for this, and it’s what gives SwiftUI’s styling APIs the ability to compose styles together.

In this post, we’ll take a closer look at how styles propagate, as well as how we can build composable styles like this:

Button {
    // Share
} label: {
    Label("Share", systemImage: "square.and.arrow.up")
}
.buttonStyle(.borderedProminent.labelStyle(.trailing).shimmerWhenEnabled)

Let’s get started.

View Groups

In the last post, we discussed how to style two controls: a button, and a range slider. Let’s look at styling a group view this time.

To guide users in an interface, we can collect elements in visual groups that help the user associate elements on the screen as a unit that belong together.

For example, take this view that we sometimes use at Moving Parts to test controls in different configurations:

GroupBox("Configuration") {
    Toggle("Enabled", isOn: $isEnabled)
    Toggle("Tint", isOn: $isTinted)
    Picker("Control Size", selection: $controlSize) {
        ForEach(ControlSize.allCases, id: \.self) { size in
            Text(String(describing: size).capitalized).tag(size)
        }
    }
}
.pickerStyle(.segmented)

In the example above, we use SwiftUI’s Group­Box view to visually group these controls into a unit that’s distinct from other views on the screen.

As an interface gains more features, it might be necessary to introduce a group within an existing group to avoid making the interface overwhelming. On smaller screens, a common way to organize a lot of features is to use navigation. This is done by grouping views into different detail views that the user can navigate to and use in isolation.

Another way to organize features is to put views and controls into groups that are displayed inline within other groups. As a result, the user won’t need to navigate between different detail views to complete a task.

For example, the code below uses a Group­Box within another Group­Box to collect elements related to the Theme customization options to organize them into a group distinct from other customization options:

GroupBox("Customization") {
    GroupBox("Theme") {
        MyColorPicker("Accent Color", selection: $accentColor)

GroupBox("Font") { Picker("Design", selection: $fontDesign) { Text("Default").tag(FontDesign.default) Text("Rounded").tag(FontDesign.rounded) Text("Serif").tag(FontDesign.serif) } Picker("Size", selection: $fontSize) { ForEach(FontSize.allCases) { size in Text(size.name).tag(size) } } } }
Toggle("Sound Effects", isOn: $isSoundEffectsEnabled) } .pickerStyle(.segmented)

Custom Group Box Style

SwiftUI’s Group­Box view has a style API that we can use to create our own Group­Box styles.

Let’s create a custom style:

struct CustomGroupBoxStyle: GroupBoxStyle {
    func makeBody(configuration: Configuration) -> some View {
        VStack(alignment: .leading) {
            configuration.label
                .font(.subheadline)
            configuration.content
        }
        .padding()
        .background {
            RoundedRectangle(cornerRadius: 16, style: .continuous)
                .stroke(.secondary)
        }
    }
}

Now we’ll use it for our customization panel:

GroupBox("Customization") {
    GroupBox("Theme") {
        MyColorPicker("Accent Color", selection: $accentColor)

GroupBox("Font") { // ... } }
Toggle("Sound Effects", isOn: $isSoundEffectsEnabled) } .groupBoxStyle(.custom) .pickerStyle(.segmented)

The nested Group­Box views don’t use the .custom style. Instead, it looks like they use the default automatic style.

It seems that when setting a style for one of SwiftUI’s built-in views, that style doesn’t propagate to any nested views of the same view type.

This is surprising, because we expect styles to propagate down the view hierarchy like environment values do.

For example, a container view can check the color­Scheme environment value to decide which border color to use, all without affecting views within that container view that also use the color­Scheme to adapt their colors.

For many styleable views, the fact that nested views won’t inherit the style that’s been set higher up in the view hierarchy isn’t a problem, since, for example, we rarely need to place a button within another button. But for view groupings, it’s not too uncommon to nest views like in the customization panel example above.

Styling Nested Views

At a first glance, it seems like SwiftUI resets the style to its default style for the views nested within the Group­Box view.

So what can we do to use the same style in the nested views?

One thing we could try is to reapply the style on each Group­Box:

GroupBox("Customization") {
    GroupBox("Theme") {
        MyColorPicker("Accent Color", selection: $accentColor)

GroupBox("Font") { // ... } .groupBoxStyle(.custom) } .groupBoxStyle(.custom)
Toggle("Sound Effects", isOn: $isSoundEffectsEnabled) } .groupBoxStyle(.custom) .pickerStyle(.segmented)

But doing this cancels out one of the benefits of using view styles, which is that we can specify the styling in one place. This isn’t very different to applying a convenience view modifier for each view to configure the styling.

Another way we can control the styling of any nested views is if we instead reapply the style within the style’s implementation:

struct CustomGroupBoxStyle: GroupBoxStyle {
    var cornerRadius: Double = 16

func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading) { configuration.label .font(.subheadline) configuration.content } .padding() .background { RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) .stroke(.secondary) } .groupBoxStyle(CustomGroupBoxStyle(cornerRadius: cornerRadius)) } }

We can also reapply the style with .group­Box­Style(self). By using self, we’re ensuring that corner­Radius is passed along, and if we add more properties to the style, those will be passed along too.

With the style being reapplied by itself, we make sure we get the styling we want for any nested views and no longer need to apply the style to each Group­Box view:

GroupBox("Customization") {
    GroupBox("Theme") {
        MyColorPicker("Accent Color", selection: $accentColor)

GroupBox("Font") { // ... } }
Toggle("Sound Effects", isOn: $isSoundEffectsEnabled) } .groupBoxStyle(.custom) .pickerStyle(.segmented)

So when we’re making styles for views that can have nested views of the same type, we need to remember to reapply the style within the style’s implementation to make sure we don’t end up with any styling inconsistencies.

Alternating Styles

When creating styles for grouping views like Group­Box, we might also want to consider adjusting the style for any nested views to ensure the nested views are distinguishable.

For a style to be able to, for example, change a background color — like we saw Group­Box‘s default style do in the first customization panel example — we need to make the style aware of which nesting level the view it’s styling is on.

We can create an environment value that increments a value for each nesting level and, based on that value, adjust its rounding and background color:

struct RelativeRoundedGroupBoxStyleNestingLevelKey: EnvironmentKey {
    static var defaultValue: Int = 0
}

extension EnvironmentValues { var relativeRoundedGroupBoxStyleNestingLevel: Int { get { self[RelativeRoundedGroupBoxStyleNestingLevelKey.self] } set { self[RelativeRoundedGroupBoxStyleNestingLevelKey.self] = newValue } } }
struct RelativeRoundedGroupBoxStyle: GroupBoxStyle { @Environment(\.relativeRoundedGroupBoxStyleNestingLevel) var nestingLevel
func makeBody(configuration: Configuration) -> some View { VStack { configuration.label .font(.subheadline.bold()) configuration.content } .padding(8) .background(Color(white: nestingLevel % 2 == 0 ? 0.2 : 0.1), in: RoundedRectangle(cornerRadius: max(0, 24 - CGFloat(nestingLevel) * 8), style: .continuous)) .transformEnvironment(\.relativeRoundedGroupBoxStyleNestingLevel) { nestingLevel in nestingLevel += 1 } .groupBoxStyle(self) } }

Custom Components

So how does this work in a custom group view that’s using the pattern outlined in our previous post to become styleable?

Let’s look at this Label­Group view:

struct LabelGroup<Label: View, Content: View>: View {
    var label: Label

var content: Content
@Environment(\.labelGroupStyle) var style
init(@ViewBuilder content: () -> Content, @ViewBuilder label: () -> Label) { self.content = content() self.label = label() }
var body: some View { let configuration = LabelGroupStyleConfiguration( content: content, label: label )
AnyView(style.resolve(configuration: configuration)) } }

We can use this view to group labels — for example, details about a bag of coffee:

LabelGroup {
    LabeledContent("Origin", value: coffee.origin)
    LabeledContent("Region", value: coffee.region)
    LabeledContent("Altitude", value: coffee.altitude, format: .measurement(width: .narrow, usage: .asProvided))
    LabeledContent("Taste Notes", value: coffee.tasteNotes, format: .list(type: .and))
    LabeledContent("Year", value: coffee.year.date!, format: .dateTime.year())
}

These labels could use some styling to make them easier to read:

struct ListLabelGroupStyle: LabelGroupStyle {
    func makeBody(configuration: Configuration) -> some View {
        HStack(alignment: .firstTextBaseline) {
            configuration.label
                .foregroundStyle(.secondary)
                .padding(.vertical, 11.5)
                .frame(minHeight: 44)
            VStack(alignment: .trailing, spacing: 0) {
                Divided {
                    configuration.content
                        .padding(.vertical, 11.5)
                        .frame(minHeight: 44)
                }
            }
        }
    }
}

extension LabelGroupStyle where Self == ListLabelGroupStyle { static var list: Self { .init() } }

And we can use the list style like this:

LabelGroup {
    LabeledContent("Origin", value: coffee.origin)
    LabeledContent("Region", value: coffee.region)
    LabeledContent("Altitude", value: coffee.altitude, format: .measurement(width: .narrow, usage: .asProvided))
    LabeledContent("Taste Notes", value: coffee.tasteNotes, format: .list(type: .and))
    LabeledContent("Year", value: coffee.year.date!, format: .dateTime.year())
}
.labelGroupStyle(.list)
.labeledContentStyle(.custom)

The way the tasting notes are listed in the example above results in some unfortunate line breaks. But we can try to nest a Label­Group to lay those labels out differently:

LabelGroup {
    LabeledContent("Origin", value: coffee.origin)
    LabeledContent("Region", value: coffee.region)
    LabeledContent("Altitude", value: coffee.altitude, format: .measurement(width: .narrow, usage: .asProvided))
    LabelGroup("Taste Notes") {
        ForEach(coffee.tasteNotes, id: \.self) { note in
            Text(note)
        }
    }
    LabeledContent("Year", value: coffee.year.date!, format: .dateTime.year())
}
.labelGroupStyle(.list)
.labeledContentStyle(.custom)

Here, we don’t need to reapply the style for custom view styles like we do for the built-in views.

While this has the propagation behavior we expect it to have, it’s unfortunate that we need to treat styles for custom views differently than styles for built-in views.

So what do we need to do to match the style propagation behavior of the built-in styles?

We could try to set the style back to its default plain style inside the component:

var body: some View {
    let configuration = LabelGroupStyleConfiguration(
        content: content,
        label: label
    )

AnyView(resolvedStyle.resolve(configuration: configuration)) .labelGroupStyle(.plain) }

This seems to match how SwiftUI does things, but there’s a bit more to it.

To explain why, we need look at another feature of styling APIs in SwiftUI.

Style Configuration Initializers

Sometimes we need a style that’s a variation of an existing style.

Depending on how an existing style is set up and how the new style needs to look, we might not need to make the new style from scratch.

For example, maybe the .bordered­Prominent style looks like we want when paired with a few modifications to the font and shape:

Button("Edit") {
    // Edit
}
.buttonStyle(.borderedProminent)
.buttonBorderShape(.roundedRectangle(radius: 4))
.overlay {
    RoundedRectangle(cornerRadius: 4, style: .continuous)
        .strokeBorder(.primary, lineWidth: 2)
}
.foregroundStyle(.white)
.fontDesign(.monospaced)
.fontWeight(.semibold)
.tint(.purple)

If we want to use this button style across our app, we have a couple options:

  1. Recreate the bordered­Prominent style in a new style that also applies these modifiers.

  2. Add a view modifier that applies these modifiers and use it on all buttons.

Both options aren’t great. The bordered­Prominent style isn’t trivial to recreate, and it can be easy to forget adding this view modifier on every Button in the app.

Fortunately, Button and a couple more views in SwiftUI come with an initializer that helps with this.

The documentation for init(_ configuration: Primitive­Button­Style­Configuration) details how this initializer is used to create an instance of the button that we want to style. It goes on to say:

“This is useful for custom button styles that modify the current button style, rather than implementing a brand new style.”

So instead of applying the style modifiers in a view modifier, we can create a new style that constructs a Button with this initializer and applies the styling modifiers to that button:

struct ModifiedButtonStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        Button(configuration)
            .buttonBorderShape(.roundedRectangle(radius: 4))
            .overlay {
                RoundedRectangle(cornerRadius: 4, style: .continuous)
                    .strokeBorder(.primary, lineWidth: 2)
            }
            .foregroundStyle(.white)
            .fontDesign(.monospaced)
            .fontWeight(.semibold)
            .tint(.purple)
    }
}

So how do we use this style?

To define what the “current” style should be, we need to also specify the bordered­Prominent button style:

Button("Done") {
    // Done
}
.buttonStyle(ModifiedButtonStyle())
.buttonStyle(.borderedProminent)

Note that the order in which these styles are applied matters.

For example, say we were to switch these lines around:

Button("Done") {
    // Done
}
.buttonStyle(.borderedProminent)
.buttonStyle(ModifiedButtonStyle())

Now, it looks like the button was only styled by the .bordered­Prominent style and the Modified­Button­Style wasn’t used at all.

To fix this, the Modified­Button­Style needs to be applied lower in the view hierarchy than another button style. This ensures it’ll be used when displaying a button.

The end result is a modified button style type that we can use together with a built-in style and have it applied to all buttons in a view hierarchy without adding some styling modifiers directly on each button. Nice!

Having the style of our buttons defined in two separate styles can be practical. For example, we can reuse the Modified­Button­Style on buttons that use the less prominent .bordered style and on a custom style if the result matches what we need:

Button("Undo") {
    // Undo
}
.buttonStyle(ModifiedButtonStyle())
.buttonStyle(.bordered)

These styles compose, so we can, for example, split up the Modified­Button­Style into separate styles.

To do this, we can extract the code that adds the outline into a separate style:

struct RoundedOutlineButtonStyle: PrimitiveButtonStyle {
    func makeBody(configuration: Configuration) -> some View {
        Button(configuration)
            .buttonBorderShape(.roundedRectangle(radius: 4))
            .overlay {
                RoundedRectangle(cornerRadius: 4, style: .continuous)
                    .strokeBorder(.primary, lineWidth: 2)
            }
    }
}
Button("Redo") {
    // Redo
}
.buttonStyle(ModifiedButtonStyle())
.buttonStyle(RoundedOutlineButtonStyle())
.buttonStyle(.bordered)

By using this technique, we can compose multiple styles to apply additional styling to a control without adding more and more styling modifiers into an existing style.

Just like we can compose simple views to create more complex ones, we can now compose simple styles to create more complex ones.

Back to GroupBox

Understanding that styles can be composed like this, does it explain the behavior we saw on nested Group­Box views that we observed in the beginning of the post?

Let’s experiment with a Group­Box­Style that lets us tag each style:

struct TaggedGroupBoxStyle: GroupBoxStyle {
    var tag: String

func makeBody(configuration: Configuration) -> some View { VStack(alignment: .leading) { configuration.label
configuration.content } .padding(12) .overlay(alignment: .topTrailing) { Text(tag) .font(.callout) .padding(.horizontal, 12) .padding(.vertical, 4) .background(.tint, in: RoundedRectangle(cornerRadius: 8, style: .continuous)) .padding(8) } .background { RoundedRectangle(cornerRadius: 16, style: .continuous) .stroke(.secondary) } } }

We can use it like this to tag the nested views:

GroupBox("Customization") {
    GroupBox("Theme") {
        // ...
        GroupBox("Font") {
            // ...
        }
        .groupBoxStyle(.tagged("C"))
    }
    .groupBoxStyle(.tagged("B"))
}
.groupBoxStyle(.tagged("A"))

Now, what happens if we move these group­Box­Style(_:) modifiers to the outermost Group­Box view?

GroupBox("Customization") {
    GroupBox("Theme") {
        // ...
        GroupBox("Font") {
            // ...
        }
    }
}
.groupBoxStyle(.tagged("A"))
.groupBoxStyle(.tagged("B"))
.groupBoxStyle(.tagged("C"))

If we apply a style multiple times, the style gets propagated down to nested views, making the two examples above exactly the same.

This means that styles aren’t reset to the view’s default style as we speculated before. Instead, it seems like styles are pushed onto a stack, and they’re popped off the stack when used by the view.

Composable Styles for Custom Views

The pattern we outlined for styling custom views in the previous post doesn’t have a style configuration initializer for the view, and it doesn’t maintain styles on a stack.

So to implement these styling behaviors in a custom view, we need to make some changes to that pattern.

To do that, let’s look at the Label­Group view again.

To add the style configuration initializer, we need to constrain it so that the generic type of our view matches the type-erased view in the configuration:

extension LabelGroup where Label == LabelGroupStyleConfiguration.Label, Content == LabelGroupStyleConfiguration.Content {
    init(_ configuration: LabelGroupStyleConfiguration) {
        self.content = configuration.content
        self.label = configuration.label
    }
}

We create a new style that’s using the style configuration initializer:

struct CardLabelGroupStyle: LabelGroupStyle {
    @Environment(\.colorScheme)
    var colorScheme

func makeBody(configuration: Configuration) -> some View { LabelGroup(configuration) .padding() .background { RoundedRectangle(cornerRadius: 16, style: .continuous) .fill(Color(white: colorScheme == .light ? 1 : 0.1) .shadow(.inner(color: .white.opacity(0.5), radius: 0.25, x: 0, y: 0.5))) .shadow(color: .black.opacity(0.2), radius: 10, x: 0, y: 5) } } }
LabelGroup {
    // ...
}
.labelGroupStyle(CardLabelGroupStyle())
.labelGroupStyle(.list)

However, if we try to run it, we notice that something isn’t working right. This is because we’ve introduced an infinite loop.

The Label­Group asks the Card­Label­Group­Style style to make a body, and the Card­Label­Group­Style style then creates a new Label­Group, which, in turn, asks the current style, which is Card­Label­Group­Style, to make a body… and on it goes.

So what do we do?

In the documentation for Button‘s initializer that takes a style configuration, the initializer was described as being useful for styles that modify the view’s “current” style.

And in our example above, we want the Card­Label­Group­Style style to be applied to the “current” list style.

So when the Card­Label­Group­Style style is set on a view hierarchy, it needs to retain the existing style somehow so that the Label­Group view can apply the Card­Label­Group­Style style to the list style.

Additionally, we need to be able to add multiple styles that modify the current style.

To break out of the loop and compose many styles, we need to maintain a stack of styles. Let’s revisit our custom style implementation from the previous post and modify it:

struct LabelGroupStyleStackKey: EnvironmentKey {
    static var defaultValue: [any LabelGroupStyle] = []
}

extension EnvironmentValues { var labelGroupStyleStack: [any LabelGroupStyle] { get { self[LabelGroupStyleStackKey.self] } set { self[LabelGroupStyleStackKey.self] = newValue } } }

We can replace the existing environment value used to store the label group style with a computed property that returns the last style from the stack — or the default style if no style has been added:

extension EnvironmentValues {
    var labelGroupStyle: any LabelGroupStyle {
        labelGroupStyleStack.last ?? .plain
    }
}

To add a style to the stack, we need to modify the label­Group­Style­Stack environment value.

And to modify the stack, we can make use of the transform­Environment(_:transform:) modifier, which accepts a closure that takes an inout parameter that we can append the style to:

extension View {
    func labelGroupStyle(_ style: some LabelGroupStyle) -> some View {
        transformEnvironment(\.labelGroupStyleStack) { styles in
            styles.append(style)
        }
    }
}

Now when we add multiple label­Group­Style(_:) view modifiers to a view hierarchy, we’ll add each style to the stack on the environment.

Back in the Label­Group view, we need to pop the style off the stack after the style’s make­Body(configuration:) function is called. To do that, we can use transform­Environment(_:transform:) again:

let configuration = LabelGroupStyleConfiguration(content: content)
AnyView(style.resolve(configuration: configuration))
    .transformEnvironment(\.labelGroupStyleStack) { styles in
        if styles.isEmpty { return }
        styles.removeLast()
    }

With the changes to how the style is added to the environment and read in the component, we no longer have an infinite loop, and we can compose styles for a custom view:

LabelGroup {
    LabeledContent("Origin", value: elSalvadorMenendez.origin)
    LabeledContent("Region", value: elSalvadorMenendez.region)
    LabeledContent("Altitude", value: elSalvadorMenendez.altitude, format: .measurement(width: .narrow, usage: .asProvided))
    LabeledContent("Taste Notes", value: elSalvadorMenendez.tasteNotes, format: .list(type: .and))
    LabeledContent("Year", value: elSalvadorMenendez.year.date!, format: .dateTime.year())
}
.labelGroupStyle(.withSerifFont)
.labelGroupStyle(.card)
.labelGroupStyle(.list)

And if we try the example with the nested Label­Group views, we’ll see that this behaves the same way as the built-in styles:

LabelGroup {
    LabeledContent("Origin", value: coffee.origin)
    LabeledContent("Region", value: coffee.region)
    LabeledContent("Altitude", value: coffee.altitude, format: .measurement(width: .narrow, usage: .asProvided))
    LabelGroup("Taste Notes") {
        ForEach(coffee.tasteNotes, id: \.self) { note in
            LabeledContent("Taste Note", value: note)
        }
        .labelsHidden()
    }
    LabeledContent("Year", value: coffee.year.date!, format: .dateTime.year())
}
.labelGroupStyle(.list)
.labeledContentStyle(.custom)

The nested Label­Group views get the default plain style instead of the one that has dividers, just like we saw in the Group­Box example earlier.

Scoped View Modifiers

Another thing we can do is use styles to apply modifiers for a view hierarchy and only have them apply to a specific type of view there, but not to other views that would normally be affected by those modifiers.

For example, maybe we want buttons in a section of an app to have a different font, but we don’t want to change the font used by other views in that section.

If we’re using a custom button style, we could set the font in that style. But what if we’re using a built-in style or a style from a third-party library?

Instead of applying the adjustments to each button in the view, we can create a style that does the work for us:

struct FontModifierButtonStyle: PrimitiveButtonStyle {
    var font: Font

func makeBody(configuration: Configuration) -> some View { Button(configuration) .font(font) } }
extension PrimitiveButtonStyle where Self == FontModifierButtonStyle { static func font(_ font: Font) -> Self { .init(font: font) } }
.buttonStyle(.font(.customCallout))
.buttonStyle(.borderless)

We can also use this technique to apply a style for a view only when it’s nested in a specific type of component.

For example, it’s common to use Label with Buttons. Label has a style API, so we can create a button style that lets us set which label style to use for Buttons only:

struct LabelButtonStyle<Style: LabelStyle>: PrimitiveButtonStyle {
    var label: Style

func makeBody(configuration: Configuration) -> some View { Button(configuration) .labelStyle(label) } }
extension PrimitiveButtonStyle where Self == LabelButtonStyle<any LabelStyle> { static func labelStyle<Style: LabelStyle>(_ style: Style) -> LabelButtonStyle<Style> { LabelButtonStyle(label: style) } }

Then, we can set up our button styling in one place:

WindowGroup {
    ContentView()
        .fontDesign(.serif)
        .buttonStyle(.font(.system(.body, design: .rounded).bold()))
        .buttonStyle(.labelStyle(.trailing))
        .buttonStyle(.shimmerWhenEnabled)
        .buttonStyle(.bordered)
}

One thing to note is that to override the label style used for buttons within this Content­View, we need to reapply the other styles too:

ShareLink(item: post)
    .buttonStyle(.font(.system(.body, design: .rounded).bold()))
    .buttonStyle(.labelStyle(.iconOnly))
    .buttonStyle(.shimmerWhenEnabled)
    .buttonStyle(.bordered)

If we had only applied .button­Style(.label­Style(.icon­Only)) to the button, it wouldn’t have had any effect, because the Label­Button­Style set higher up in the view hierarchy would take precedence.

Composable Style Maintenance

Being able to compose styles like this can be quite powerful. But the fact that the ordering in which the styles are applied in matters can lead to some problems.

For example, we might have a bordered style composed with two other styles for our application:

WindowGroup {
    ContentView()
        .buttonStyle(.corporateFont)
        .buttonStyle(.branded)
        .buttonStyle(.bordered)
        // ...
}

For the primary call to action, we want the bordered­Prominent style, but if we forget to add one of the other styles, or if we just put one on the wrong line, the result is the wrong look for an important button:

HStack {
    Button("Cancel") {

} Button("Checkout") {
} .buttonStyle(.corporateFont) .buttonStyle(.borderedProminent) .buttonStyle(.branded) }

Parameterized Styles

To avoid the problem shown above, we can instead make the modified style parameterized on a base style:

struct CorporateFontButtonStyle<BaseStyle: PrimitiveButtonStyle>: PrimitiveButtonStyle {
    var base: BaseStyle

func makeBody(configuration: Configuration) -> some View { Button(configuration) .buttonStyle(base) .font(.custom("Helvetica", size: 17).bold()) } }

This way, we have to provide a base style that the modification should be applied to when using the Corporate­Font­Button­Style.

To construct this style, we write the following:

.buttonStyle(CorporateFontButtonStyle(base: .bordered))

This doesn’t read that well, especially if we want to compose more styles together.

To address the readability problem, we can add an extension on the style protocol:

extension PrimitiveButtonStyle {
    var withCorporateFont: CorporateFontButtonStyle<Self> {
        CorporateFontButtonStyle(base: self)
    }
}

Then, we can compose the style in a more readable fashion:

Button("Edit") {

} .buttonStyle(.borderedProminent.branded.withCorporateFont)

Style Modifiers

Many style modifications are applicable to multiple view types. For example, setting the font or label style for a specific type of view — as we did for Button above — could be just as useful for a Picker, a Toggle, or a Group­Box view.

When we run in to this problem with views, we can define a View­Modifier to make a modifier reusable for any view.

When implementing a View­Modifier, we don’t know much about the view we’re modifying except that it’s a type that conforms to the View protocol.

The styles that modify an existing style have some resemblance to View­Modifiers, in that they modify an unknown style.

As such, we can use SwiftUI’s View­Modifier API to make these styles reusable:

struct ModifiedStyle<Style, Modifier: ViewModifier>: DynamicProperty {
    var style: Style
    var modifier: Modifier
}

extension ModifiedStyle: PrimitiveButtonStyle where Style: PrimitiveButtonStyle { func makeBody(configuration: PrimitiveButtonStyleConfiguration) -> some View { Button(configuration) .buttonStyle(style) .modifier(modifier) } }
extension PrimitiveButtonStyle { func modifier(_ modifier: some ViewModifier) -> some PrimitiveButtonStyle { ModifiedStyle(style: self, modifier: modifier) } }

We can also rewrite our Label­Button­Style type to conform to the View­Modifier protocol:

struct LabelStyleModifier<Style: LabelStyle>: ViewModifier {
    var style: Style

func body(content: Content) -> some View { content.labelStyle(style) } }

And then, we can use the Label­Style­Modifier like this:

ContentView()
    .buttonStyle(.bordered.modifier(LabelStyleModifier(style: .trailing)))

Since the Label­Style­Modifier is a View­Modifier, we can use it with another view if we extend the Modified­Style type for that view’s style protocol:

extension ModifiedStyle: GroupBoxStyle where Style: GroupBoxStyle {
    func makeBody(configuration: GroupBoxStyleConfiguration) -> some View {
        GroupBox(configuration)
            .groupBoxStyle(style)
            .modifier(modifier)
    }
}

extension GroupBoxStyle { func modifier(_ modifier: some ViewModifier) -> some GroupBoxStyle { ModifiedStyle(style: self, modifier: modifier) } }
ContentView()
    .groupBoxStyle(.custom.modifier(LabelStyleModifier(style: .trailing))

In SwiftUI, it’s common to define an extension on View that applies the view modifier.

In the same way, we can add an extension to the style protocol to make using the Label­Style­Modifier a bit less verbose when composing multiple styles:

extension GroupBoxStyle {
    func labelStyle(_ style: some LabelStyle) -> some GroupBoxStyle {
        modifier(LabelStyleModifier(style: style))
    }
}
ContentView()
    .groupBoxStyle(.custom.fontDesign(.rounded).labelStyle(.trailing).toggleStyle(.checkbox))

In doing this, it becomes more obvious that this style will compose with another style, so when working on our app, there should be less room for making mistakes.

Dynamic Styles

There’s one more technique SwiftUI’s built-in views use to compose styles. Many of the default styles are called automatic and compose different styles depending on the context in which a view is displayed.

If we want to do something similar, we might try to set the style based on a condition:

struct DynamicButtonStyle: PrimitiveButtonStyle {
    var theme: Theme

func makeBody(configuration: Configuration) -> some View { Button(configuration) .buttonStyle(theme == .aqua ? .aqua : .bordered) } }

But if we do this, we’ll run into this compiler warning:

Type ‘ButtonStyle’ has no member ‘aqua’

The compiler warning is a little confusing because the Button­Style does have a member named aqua. However, if we rewrite the code to not use the shorthand .aqua syntax, we’ll get a different error:

Result values in ‘? :’ expression have mismatching types ‘AquaButtonStyle’ and ‘BorderedButtonStyle’

So button­Style(_:) expects the style to be of a specific type.

We can work around this by writing a style that, depending on a value, sets the button style to use:

struct DynamicButtonStyle: PrimitiveButtonStyle {
    var theme: Theme

func makeBody(configuration: Configuration) -> some View { let button = Button(configuration) switch theme { case .aqua: button.buttonStyle(.aqua) case .default: button.buttonStyle(.bordered) } } }

We can also create a style that checks an environment value to make the style context sensitive.

For example, just like how SwiftUI’s automatic Toggle style changes to use the button style when a Toggle is placed in a toolbar, we can make a dynamic style that changes which style a view should use when displayed, based on a user preference:

struct DynamicCompactLabelStyle: LabelStyle {
    @Environment(\.locale.language.script)
    var script

func makeBody(configuration: Configuration) -> some View { let label = Label(configuration) switch script { case .korean?, .japanese?: label.labelStyle(.titleOnly) default: label.labelStyle(.iconOnly) } } }

In the example above, the button labels are displayed with different label styles depending on the current language setting.

If we don’t need this contextual behavior, we can write a conditional style:

struct ConditionalStyle<TrueStyle, FalseStyle> {
    var condition: Bool
    var trueStyle: TrueStyle
    var falseStyle: FalseStyle
}

extension ConditionalStyle: ToggleStyle where TrueStyle: ToggleStyle, FalseStyle: ToggleStyle { func makeBody(configuration: Configuration) -> some View { if condition { trueStyle.makeBody(configuration: configuration) } else { falseStyle.makeBody(configuration: configuration) } } }
extension ToggleStyle where Self == ConditionalStyle<any ToggleStyle, any ToggleStyle> { static func conditional<T: ToggleStyle, F: ToggleStyle>(_ condition: Bool, then trueStyle: T, else falseStyle: F) -> ConditionalStyle<T, F> { ConditionalStyle(condition: condition, trueStyle: trueStyle, falseStyle: falseStyle) } }

We can then use the conditional style to dynamically choose between two styles:

GroupBox("Coffee Options") {
    ForEach($options) { $option in
        Toggle(option.name, isOn: $option.isSelected)
    }
}
.toggleStyle(
    .conditional(
        options.count == 1,
        then: .switch,
        else: .checkbox
    )
)

Summary

Being able to compose styles and reuse modifiers for different controls and views in an app can help reduce the number of styles we need to write from scratch.

And by making styles that dynamically select which style to use, we can make views adapt to user preferences or which context a view is in without making a single complex style.

You can try this out yourself by downloading this Xcode Playground on our GitHub page, which has a custom view with a composable styling API.

If you’d like to follow along with our work and stay up to date with future posts like this, consider following us on Mastodon or Twitter or joining our mailing list.

Thanks to Chris Eidhof and Matthew Cheok for giving feedback on a draft of this post, and to Natalye Childress for editing this post.