SwiftUI Styling for Design Systems

By admin
When working with design systems as engineers, we want to create styles that match the design resources while being composable and easy to use across many variations in our

UI
LOC

. In this talk

Daniel Tull
PERSON

explains how we can make use of a SwiftUI approach to build extensible design systems without creating a load of difficult-to-manage custom views.

More talks are available from

Daniel Tull
PERSON

here.

You can watch the talk on

Youtube
ORG

here.

Check out the useful post on

Moving Parts
WORK_OF_ART

also,

Styling Components
ORG

in SwiftUI.

Code

You can download an

Xcode
ORG

project with the code from this talk.

Notes

This post is mostly my notes from the talk, as well as code examples shown during the presentation.

What is a Design System

Design systems are a combination of processes, documentation, common resources and functional libraries.

A challenge is keeping these resources aligned with the way we develop in

Swift
PERSON

apps, but thankfully

Swift
PERSON

offers approaches that can make it easier to create shared vocabularity and components.

A common approach: Custom button

In

Swift
PERSON

we can might want to create a primary button by creating a custom component such as:

struct

PrimaryButton
ORG

: View { let title : String let action : () -> {} var body : some View { Button ( title , action : action ) .

font
PERSON

( . title ) . padding (

16
CARDINAL

) . background ( . blue , in : RoundedRectangle ( cornerRadius :

16
CARDINAL

)) .

foregroundColor
PERSON

( . white ) } }

This produces a button with a blue background as expected. However, there’s a downside to this approach. By creating custom components for each use-case, we end up with a lot of components to manage and keep track of.

The above code also doesn’t cover some issues, such as tap state, accessibility and more.

Another weakness of the above is when someone then decides we need a button with an image. We would create a

second
ORDINAL

component, such as PrimaryImageButton . Over time this becomes tricky to scale and maintain and is a barrier to discovery.

Using buttonStyle

Instead of making custom components, buttonStyle is availble as a way of adding the styling we need to existing button components, rather than creating new components.

An example of a primary button style similar to the above might look like this:

struct

PrimaryButtonStyle
PERSON

: ButtonStyle { func makeBody ( configuration : Configuration ) -> some View { configuration . label . font ( . title ) . padding (

16
CARDINAL

) . background ( . blue , in : RoundedRectangle ( cornerRadius :

16
CARDINAL

)) .

foregroundColor
PERSON

( . white ) } }

This makes use of the same styling but instead of applying it to a

Button
PERSON

, we make use of a label that is passed to the makeBody method in the configuration object.

The configuration also includes other angles such as tap events which can be configured here too.

This could then be applied to a button using buttonStyle :


Button
PERSON

( "

Button Text
WORK_OF_ART

" , action : { } ) .

buttonStyle
PERSON

( PrimaryButtonStyle ())

Improving discoverability

To make it easier to discover this style, it’s possible to define static variable on buttonStyle that extend it like so:

extension ButtonStyle where Self == PrimaryButtonStyle { static var primary :

Self { Self
PERSON

() } }

The above sets a static variable primary that we can then apply to a

Button
PERSON

by pressing . and it’ll suggest our static variable as an option:


Button
PERSON

( "

Button Text
WORK_OF_ART

" , action : { } ) .

buttonStyle
PERSON

( . primary )

The above also supports any other

Button
PERSON

usage out of the box, such as using it with an Image label or text alongside images.

Extending Label styling

It’s possible to customise the styling of buttons further, such as adding space between the text and image on a button with both.

We would create such a button using both text and an image:


Button
PERSON

( "

Button Text
WORK_OF_ART

" , systemImage : "square.and.arrow.up" , action : { } )

In this case, the

Button
PERSON

component is making use of a

Label
PRODUCT

version that accepts both text and image inputs corresponding to a Title and

Icon
ORG

view.

An example of such a Label used directly looks like:

Label { Text ( "Text" ) } icon : { Image ( systemName : "square.and.arrow.up" ) }

labelStyle

Similar to creating a buttonStyle we can implement a labelStyle :

struct PrimaryButtonLabelStyle :

LabelStyle { func makeBody
PERSON

( configuration : Configuration ) -> some View { HStack ( spacing :

100
CARDINAL

) { configuration . icon configuration . title } } }

This styles the text and image to have a large amount of space between them.

We can use this as above to create an extension for a primary label style, or we can even use this directly within the

PrimaryButtonStyle
GPE

. This would mean any button using the .primary buttonStyle could make use of the above when using a label .

struct

PrimaryButtonStyle
PERSON

: ButtonStyle { func makeBody ( configuration : Configuration ) -> some View { configuration . label . font ( . title ) . padding (

16
CARDINAL

) . background ( . blue , in : RoundedRectangle ( cornerRadius :

16
CARDINAL

)) .

foregroundColor
PERSON

( . white ) .

labelStyle
PERSON

( PrimaryButtonLabelStyle ()) } }

In the above we add the

.labelStyle
PERSON

line and this will apply to any label that appears in a .primary buttonStyle button.


Button
PERSON

{ } label : { Label { Text ( "Text" ) } icon : { Image ( systemName : "square.and.arrow.up" ) } } .

buttonStyle
PERSON

( . primary )

Applying our style to custom components

So we can add labelStyle to our

PrimaryButtonStyle
GPE

but what if we have our own custom components to be used within a

Button
PERSON

and we want to have them handled by our

PrimaryButtonStyle
PERSON

too?

An example could be a button styled to have

three
CARDINAL

different lines of text.

A less-ideal way to achieve this is to create a custom

VerticalTextButton
PERSON

view containing a

VStack
PRODUCT

of text. But as above, making new buttons is not the best approach. It lacks discoverability but also makes it difficult to customise the content.

An approach would be to create our own approach, which would have it’s own default style that can be extended in the same way as the buttonStyle or labelStyle .

Semantic container

Much as the Label could be described as a semantic container – a component that takes an icon and text and lets labelStyle decide how it looks, we can create our own container within which to create thie

3
CARDINAL

-line text button design.

For the sake of this example, the semantic container being created is called

Detail
ORG

, and it accepts a set of

three
CARDINAL

views: Title , Subtitle and

Caption
PERSON

.

The code for this

Detail
PERSON

is like this:

struct

Detail <
ORG

Title : View , Subtitle : View , Caption : View > : View { private let title : Title private let subtitle : Subtitle private let caption : Caption init (

@ViewBuilder title
ORG

: () -> Title ,

@ViewBuilder
NORP

subtitle : () -> Subtitle ,

@ViewBuilder caption
PERSON

: () -> Caption ) { self . title = title () self . subtitle = subtitle () self . caption = caption () } var body : some View { VStack { title subtitle caption } } }

This

Detail
PERSON

view takes the

three
CARDINAL

value views, and contains a simple initialiser and gives us back the

three
CARDINAL

views as a

VStack
PRODUCT

.

To set this up to receive our styling, we create a protocol :

protocol DetailStyle :

DynamicProperty { typealias Configuration
PERSON

= DetailStyleConfiguration associatedtype Body : View @ViewBuilder func makeBody ( configuration : Configuration ) -> Body }

Configuring with AnyView

We then set up configuration to pass each of the child views out to the caller for use when styling. It looks like this:

struct DetailStyleConfiguration { struct Title : View { let body : AnyView } struct Subtitle : View { let body : AnyView } struct Caption : View { let body : AnyView } let title : Title let subtitle : Subtitle let caption : Caption fileprivate init ( title : some View , subtitle : some View , caption : some View ) { self . title = Title ( body : AnyView ( title )) self . subtitle = Subtitle ( body : AnyView ( subtitle )) self . caption =

Caption
PERSON

( body : AnyView ( caption )) } }

This configuration is wrapping each of the given views in

AnyView
ORG

, which allows the view’s type to be dynamically updated when the configuration is used for styling.

This helps protect the component’s data from being exposed to the styling but mainly is set up this way to work with how generics in

Swift
PERSON

are handled through protocols.

Adding to the environment

Next, to prepare for applying the detailStyle across the environment, the following code sets up a default “plain” style

EnvironmentKey
ORG

and extends

EnvironmentValues
PRODUCT

to be able to get and set this key, and the View component to make use of it:

struct DetailStyleKey :

EnvironmentKey
ORG

{ static var defaultValue : any DetailStyle = PlainDetailStyle () } extension

EnvironmentValues
PRODUCT

{ fileprivate var detailStyle : any DetailStyle { get { self [ DetailStyleKey . self ] } set { self [ DetailStyleKey . self ] = newValue } } } extension View { func detailStyle ( _ style : some

DetailStyle
PERSON

) -> some View { environment (\ . detailStyle , style ) } }

Provide a default style

With the mechanism in place to apply detailStyle across views, we then define the default

PlainDetailStyle
GPE

mentioned above:

struct

PlainDetailStyle
GPE

: DetailStyle { func makeBody ( configuration : Configuration ) -> some View { VStack { configuration . title .

font
PERSON

( . system ( size :

32
CARDINAL

, weight : . bold )) configuration . subtitle .

font
PERSON

( . system ( size :

24
CARDINAL

)) configuration . caption .

font
PERSON

( . system ( size :

14
CARDINAL

, weight : . light )) } } }

This default exists mostly as a template for other users of this

Detail
PERSON

view so that others can then create their own styles.

It’ll also allow us to remove the

VStack
PRODUCT

we used above when initially setting up the

Detail
PERSON

view. But

first
ORDINAL

we need to create a way of properly applying the style across views.

Resolve style

In the blog post

Styling Components
ORG

in SwiftUI by

Moving Parts
WORK_OF_ART

, an approach to resolving styles in order to apply them to components is set out. A similar method can be set up to resolve the

DetailStyle
PERSON

here:

extension

DetailStyle
PERSON

{ fileprivate func resolve ( configuration : Configuration ) -> some View { ResolvedDetailStyle ( style : self , configuration : configuration ) } } private struct ResolvedDetailStyle < Style : DetailStyle > : View { let style : Style let configuration : Style . Configuration var body : some View { style . makeBody ( configuration : configuration ) } }

This lets use use environment properties, such as when a button is enabled or disabled. By extending our

DetailStyle
PERSON

property we can ensure these environment properties are available within to be used our styles.

The above approach, which calls resolve in

DetailStyle
PERSON

, can resolve it’s type at the point at which it is used so that the right style can be applied alongside keeping these environment values available.

Applying style to Detail

With these build blocks in place we can revisit the

Detail
PERSON

view code from earlier and update it to make use of the detailStyle :

struct

Detail <
ORG

Title : View , Subtitle : View , Caption : View > : View { @Environment (\ . detailStyle ) private var style private let title : Title private let subtitle : Subtitle private let caption : Caption init (

@ViewBuilder title
ORG

: () -> Title ,

@ViewBuilder
NORP

subtitle : () -> Subtitle ,

@ViewBuilder caption
PERSON

: () -> Caption ) { self . title = title () self . subtitle = subtitle () self . caption = caption () } var body : some View { let configuration = DetailStyleConfiguration ( title : title , subtitle : subtitle , caption : caption ) AnyView ( style . resolve ( configuration : configuration )) } }

In this case we have added a new @Environment variable, style , then within the body, used DetailStyleConfiguration to create the configuration, and then style.resolve to resolve this style into an AnyView .

Default style

We can now see all this in action:

Detail { Text ( "Title" ) } subtitle : { Text ( "

Subtitle
WORK_OF_ART

" ) } caption : { Text ( "Caption" ) }

Creating PrimaryButtonDetailStyle

Now that we have a default style being applied, we can align it with the previous

ButtonStyle
PERSON

approach by creating a Primary style:

struct

PrimaryButtonDetailStyle
PERSON

: DetailStyle { func makeBody ( configuration : Configuration ) -> some View { VStack ( alignment : . leading ) { configuration . title .

font
PERSON

( . system ( size :

32
CARDINAL

, weight : . bold )) configuration . subtitle .

font
PERSON

( . system ( size :

24
CARDINAL

)) configuration . caption .

font
PERSON

( . system ( size :

14
CARDINAL

, weight : . light )) } } }

This is similar to the

PlainDetailStyle
GPE

above but contains an additional alignment to make the text items left-align.

We can then add this to

the ` PrimaryButtonStyle“
PRODUCT

, to apply any time we use a

Detail
PERSON

inside a

Button
PERSON

:

struct

PrimaryButtonStyle
PERSON

: ButtonStyle { func makeBody ( configuration : Configuration ) -> some View { configuration . label . font ( . title ) . padding (

16
CARDINAL

) . background ( . blue , in : RoundedRectangle ( cornerRadius :

16
CARDINAL

)) .

foregroundColor
PERSON

( . white ) .

labelStyle
PERSON

( PrimaryButtonLabelStyle ()) .

detailStyle
PERSON

(

PrimaryButtonDetailStyle
PERSON

()) } }

Using Detail in a

Button
GPE

We can then use the

Detail
PERSON

view inside a

Button
PERSON

and have it make use of the .primary buttonStyle :


Button
PERSON

{ } label : { Detail { Text ( "Title" ) } subtitle : { Text ( "

Subtitle
WORK_OF_ART

" ) } caption : { Text ( "Caption" ) } } .

buttonStyle
PERSON

( . primary )

Convenience initialiser

We don’t want to make developers of the design system remember to use

Detail
PERSON

view each time. A convenience initialiser can be used that accepts the title , subtitle and caption .

extension

Button
PERSON

where Label == Detail < Text , Text , Text > { init ( title :

LocalizedStringKey
PERSON

, subtitle :

LocalizedStringKey
PERSON

, caption :

LocalizedStringKey
PERSON

, action :

@escaping
PERSON

() -> Void ) { self . init ( action : action ) { Detail { Text ( "Title" ) } subtitle : { Text ( "

Subtitle
WORK_OF_ART

" ) } caption : { Text ( "Caption" ) } } } }

This would then be simpler to use:


Button
PERSON

( title : "Title" , subtitle : "Subtitle" , caption : "Caption" , action : { } ) .

buttonStyle
PERSON

( . primary )

Nesting styles

Using the

Detail
PERSON

view inside a label within the styled

Button
PERSON

even works, applying the labelStyle to apply space between the image and the “text” view, where the “text” is the

Detail
PERSON

:


Button
PERSON

{ } label : { Label { Detail { Text ( "Title" ) } subtitle : { Text ( "

Subtitle
WORK_OF_ART

" ) } caption : { Text ( "Caption" ) } } icon : { Image ( systemName : "square.and.arrow.up" ) } } .

buttonStyle
PERSON

( . primary )

This can be also wrapped in a convenience initialiser as above, so avoid having to specify the usage of the

Detail
PERSON

view:

extension

Button
PERSON

where Label == SwiftUI . Label < Detail < Text , Text , Text > , Image > { init ( title :

LocalizedStringKey
PERSON

, subtitle :

LocalizedStringKey
PERSON

, caption :

LocalizedStringKey
PERSON

, systemImage :

String
PERSON

, action :

@escaping
PERSON

() -> Void ) { self . init ( action : action ) { Label { Detail { Text ( "Title" ) } subtitle : { Text ( "

Subtitle
WORK_OF_ART

" ) } caption : { Text ( "Caption" ) } } icon : { Image ( systemName : systemImage ) } } } }

This could result in a simpler usage such as this:


Button
PERSON

( title : "Title" , subtitle : "Subtitle" , caption : "Caption" , systemImage : "square.and.arrow.up" , action : {} ) .

buttonStyle
PERSON

( . primary )

Making views optional

If you want to have flexibility to not have, for example, a caption , you can pass in

EmptyView
ORG

() to have it not show. Adding this inside a convenience initializer that simply doesn’t have a caption in it, then caption can be omitted from the above and it will just work as expected.

Examples of the Button in use

With these initialisers and a .primary Button style, some examples of use could look like this:

// Simple

Button Button
PERSON

( "Title" ) { } .

buttonStyle
PERSON

( . primary ) //

Button
PERSON

with image

Button
PERSON

{ } label : { Image ( systemName : "square.and.arrow.up" ) } .

buttonStyle
PERSON

( . primary ) //

Button
PERSON

with text and image

Button
PERSON

{ } label : { Label { Text ( "Text" ) } icon : { Image ( systemName : "square.and.arrow.up" ) } } .

buttonStyle
PERSON

( . primary ) //

Button
PERSON

with title and subtitle

Button
PERSON

( title : "Title" , subtitle : "Subtitle" , action : {} ) .

buttonStyle
PERSON

( . primary ) //

Button
PERSON

with title and subtitle and caption

Button
PERSON

( title : "Title" , subtitle : "Subtitle" , caption : "Caption" , action : { } ) .

buttonStyle
PERSON

( . primary ) //

Button
PERSON

with title and subtitle and caption and image

Button
PERSON

( title : "Title" , subtitle : "Subtitle" , caption : "Caption" , systemImage : "square.and.arrow.up" , action : { } ) .

buttonStyle
PERSON

( . primary )

You can download an

Xcode
ORG

project with the code from this talk.

Summary

Make use of custom styles rather than create views for each use cases. As the styles are composable, the styles can be nested for different contexts.

Then make use of semantic containers for custom components as they can be seamlessly combined with the composable styles.

Then make the standard types easier to use by using extensions to provide simpler APIs which handle the custom components.

Well that’s enough about me. Your turn!

Got any useful SwiftUI tips to share or questions about this post? You can message me on

Mastodon
PERSON

, I’d love to hear from you.