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.
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:
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:
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:
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:
While having styling defined in one place is nice, it’s important to remember to use the MyButton
view instead of SwiftUI’s Button
everywhere in the app. Otherwise, we’ll end up with inconsistent styling.
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:
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:
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 buttonStyle(_:)
modifier that we use to set a style for buttons takes a type that conforms to either the ButtonStyle
protocol or the PrimitiveButtonStyle
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 makeBody(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:
The code above enables us to set the style of a button with .buttonStyle(CustomButtonStyle())
.
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:
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 isEnabled
environment value:
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 RangeSlider
control:
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.
ButtonStyle
makes it easier to create new styles, since it lets Button
take care of the gesture handling, while PrimitiveButtonStyle
gives the style control over implementing how the button interaction works.
Other style protocols in SwiftUI resemble the PrimitiveButtonStyle
protocol, in that they also give control of the interaction to the style. For example, when implementing a ToggleStyle
, 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:
To style the RangeSlider
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 PrimitiveButtonStyleConfiguration
might look a little familiar, since they map almost directly to the types from one of Button
’s initializers:
So for our own configuration type, we can take the same approach and copy over the types from our view into the configuration:
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 RangeSlider
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 ButtonConfiguration.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 RangeSlider
generic over its label without the user of the style API having to know which specific labels will be used:
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 AnyView
to get the same effect:
For the style protocol, we can copy what SwiftUI does:
With the protocol in place, we can implement the first style by using the values from the style configuration:
To set the style for a view hierarchy, we add an extension on View
that matches the buttonStyle(_:)
signature, but we can use the some
keyword to make it a little more concise:
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 RangeSliderStyle
.
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:
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 makeBody
method on the style we get from the environment:
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 RangeSliderStyle
, we need to wrap the view we get back from the makeBody
call in an AnyView
:
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:
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:
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:
Here, we assume that most sliders will want to use accessibilityAdjustableAction(_:)
, 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.
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 controlSize
is large, we could set the controlSize
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:
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
CheckboxMultipleChoiceStyle
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:
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 DynamicProperty
, and that it does so recursively.
The documentation for DynamicProperty
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 DynamicProperty
?
While the style is on a view and the @Environment
property wrapper also conforms to DynamicProperty
, this doesn’t work.
We can instead try to put the style on an intermediate view:
Then, we pass the style along to the intermediate view in our component:
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:
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
:
Back in the body
of our component, we can now call the resolve
method on the style instead:
The code above compiles again. By conforming the style to DynamicProperty
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 DynamicProperty
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:
Unfortunately, in some circumstances, styles for built-in SwiftUI views — like ButtonStyle
and ToggleStyle
— 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 fullscreenCover
, 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 NavigationStack
, and if we have a NavigationStack
within a TabView
, 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 UIViewControllerRepresentable
that, in turn, is using a UIHostingController
to display that view, we can present sheets and have the styles propagate:
Then, we can apply all our styles in the root of our application and add the preserveStylesInSheets()
modifier to ensure those styles are also used in any modal presentations.
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: