Composable Styles in SwiftUI
A look at how to compose styles and how to make custom views support composable styles.
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:
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:
In the example above, we use SwiftUI’s GroupBox
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 GroupBox
within another GroupBox
to collect elements related to the Theme customization options to organize them into a group distinct from other customization options:
Custom Group Box Style
SwiftUI’s GroupBox
view has a style API that we can use to create our own GroupBox
styles.
Let’s create a custom style:
Now we’ll use it for our customization panel:
The nested GroupBox
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 colorScheme
environment value to decide which border color to use, all without affecting views within that container view that also use the colorScheme
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 GroupBox
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 GroupBox
:
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:
We can also reapply the style with .groupBoxStyle(self)
. By using self
, we’re ensuring that cornerRadius
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 GroupBox
view:
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 GroupBox
, 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 GroupBox
‘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:
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 LabelGroup
view:
We can use this view to group labels — for example, details about a bag of coffee:
These labels could use some styling to make them easier to read:
And we can use the list
style like this:
The way the tasting notes are listed in the example above results in some unfortunate line breaks. But we can try to nest a LabelGroup
to lay those labels out differently:
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:
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 .borderedProminent
style looks like we want when paired with a few modifications to the font and shape:
If we want to use this button style across our app, we have a couple options:
-
Recreate the
borderedProminent
style in a new style that also applies these modifiers. -
Add a view modifier that applies these modifiers and use it on all buttons.
Both options aren’t great. The borderedProminent
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: PrimitiveButtonStyleConfiguration)
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:
So how do we use this style?
To define what the “current” style should be, we need to also specify the borderedProminent
button style:
Note that the order in which these styles are applied matters.
For example, say we were to switch these lines around:
Now, it looks like the button was only styled by the .borderedProminent
style and the ModifiedButtonStyle
wasn’t used at all.
To fix this, the ModifiedButtonStyle
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 ModifiedButtonStyle
on buttons that use the less prominent .bordered
style and on a custom style if the result matches what we need:
These styles compose, so we can, for example, split up the ModifiedButtonStyle
into separate styles.
To do this, we can extract the code that adds the outline into a separate style:
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 GroupBox
views that we observed in the beginning of the post?
Let’s experiment with a GroupBoxStyle
that lets us tag each style:
We can use it like this to tag the nested views:
Now, what happens if we move these groupBoxStyle(_:)
modifiers to the outermost GroupBox
view?
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 LabelGroup
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:
We create a new style that’s using the style configuration initializer:
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 LabelGroup
asks the CardLabelGroupStyle
style to make a body, and the CardLabelGroupStyle
style then creates a new LabelGroup
, which, in turn, asks the current style, which is CardLabelGroupStyle
, 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 CardLabelGroupStyle
style to be applied to the “current” list
style.
So when the CardLabelGroupStyle
style is set on a view hierarchy, it needs to retain the existing style somehow so that the LabelGroup
view can apply the CardLabelGroupStyle
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:
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:
To add a style to the stack, we need to modify the labelGroupStyleStack
environment value.
And to modify the stack, we can make use of the transformEnvironment(_:transform:)
modifier, which accepts a closure that takes an inout
parameter that we can append the style to:
Now when we add multiple labelGroupStyle(_:)
view modifiers to a view hierarchy, we’ll add each style to the stack on the environment.
Back in the LabelGroup
view, we need to pop the style off the stack after the style’s makeBody(configuration:)
function is called. To do that, we can use transformEnvironment(_:transform:)
again:
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:
And if we try the example with the nested LabelGroup
views, we’ll see that this behaves the same way as the built-in styles:
The nested LabelGroup
views get the default plain style instead of the one that has dividers, just like we saw in the GroupBox
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:
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 Button
s. Label
has a style API, so we can create a button style that lets us set which label style to use for Button
s only:
Then, we can set up our button styling in one place:
One thing to note is that to override the label style used for buttons within this ContentView
, we need to reapply the other styles too:
If we had only applied .buttonStyle(.labelStyle(.iconOnly))
to the button, it wouldn’t have had any effect, because the LabelButtonStyle
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:
For the primary call to action, we want the borderedProminent
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:
Parameterized Styles
To avoid the problem shown above, we can instead make the modified style parameterized on a base style:
This way, we have to provide a base style that the modification should be applied to when using the CorporateFontButtonStyle
.
To construct this style, we write the following:
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:
Then, we can compose the style in a more readable fashion:
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 GroupBox
view.
When we run in to this problem with views, we can define a ViewModifier
to make a modifier reusable for any view.
When implementing a ViewModifier
, 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 ViewModifier
s, in that they modify an unknown style.
As such, we can use SwiftUI’s ViewModifier
API to make these styles reusable:
We can also rewrite our LabelButtonStyle
type to conform to the ViewModifier
protocol:
And then, we can use the LabelStyleModifier
like this:
Since the LabelStyleModifier
is a ViewModifier
, we can use it with another view if we extend the ModifiedStyle
type for that view’s style protocol:
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 LabelStyleModifier
a bit less verbose when composing multiple styles:
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:
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 ButtonStyle
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 buttonStyle(_:)
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:
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:
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:
We can then use the conditional style to dynamically choose between two styles: