Replicating SwiftUI styles for custom components

The techniques in this article is verified to work for iOS 13 and above

Many default components in SwiftUI come with a styling option in form of a modifier. In this article we will explore how we can replicate this for our own components. If you are wondering what view styles are, or which default components support them, I highly recommend reading Apples documentation on the matter.

The benefit of using styles becomes evident when we want to conditionally change the appearance of our components.

Without style modifier

@State var shouldShowProminentButtons = false

VStack {
    if shouldShowProminentButtons {
        ProminentButton("Prominent button") {}
        ProminentButton("Prominent button") {}
    } else {
        Button("Plain button") {}
        Button("Plain button") {}
    }
}

We have to duplicate our code to switch style

With style modifier

@State var shouldShowProminentButtons = false

VStack {
    Button("Plain or prominent button") {}
    Button("Plain or prominent button") {}
}
.buttonStyle(shouldShowProminentButtons ? .borderedProminent : .plain)

Cleaner code, and we don’t have to duplicate our code for each style supported

The comparison above uses internal components, with our own implementation we want to extend our options by having our styles switchable, and have our context exposed to other components.

How styles work

Styles are defined as modifiers on View, and not the component itself. This is because it actually does not alter the component directly, but rather changes the environment value for the associated style, and this is where context comes into play. Environment values are cascading, and therefor not changed until explicitly defined on a child node.

VStack {
    Button("Plain button") {} // Plain is default style
    
    Button("Prominent button") {}
        .buttonStyle(.borderedProminent) // Overrides style
    
    VStack {
        Button("Plain Button") {} // Not overriden
        
        VStack {
            // Parent VStack overrides environment in this context
            Button("Prominent Button") {}
        }
        .buttonStyle(.borderedProminent)
    }
}

Replicating style modifiers

Let’s tackle this by creating a Card component that acts as a wrapper for any content. The component itself won’t have any modifications to it, but that’s not the point. The point is to have a uniform way of creating different kinds of cards.

struct Card<Content: View>: View {
    var content: () -> Content
    
    init(content: @escaping () -> Content) {
        self.content = content
    }
    
    var body: some View {
        content()
    }
}

Next we need a configuration that helps us define the requirements and possibilities for alteration a style can apply to the basic card style. Note that nothing in the configuration is necessarily required, but omitting the use of the content parameter for instance would make the style useless as it doesn’t implement a key feature.

We also need a protocol for how a custom style should return the new view, and expose the configuration.

struct CardStyleConfiguration {
    struct Content: View {
        init<Content: View>(content: Content) {
            body = AnyView(content)
        }

        var body: AnyView
    }

    let content: CardStyleConfiguration.Content
}

protocol CardStyle {
    associatedtype Body : View
    typealias Configuration = CardStyleConfiguration
    
    func makeBody(configuration: Self.Configuration) -> Self.Body
}

Now we can create some styles:

struct BasicCardStyle: CardStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.content
            .padding()
            .background(RoundedRectangle(cornerRadius: 8.0).fill(.white))
            .background(RoundedRectangle(cornerRadius: 8.0).stroke(.black))
    }
}

struct ElevatedCardStyle: CardStyle {
    func makeBody(configuration: Configuration) -> some View {
        configuration.content
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 8.0).fill(.white)
                    .shadow(radius: 5, x: 3, y: 3)
            )
    }
}

Now we could actually use these styles, but our Card component could only use one of them, as they are not interchangeable. For this we need a stateful parameter that holds what kind of style we want to use. Obviously this could be done with a @Binding, but then we would have no context. Now we should set up the environment, so that we can set a context.

To simplify access to our styles we first create an enum defining each style.

enum CardStyles {
    case basic, elevated
    
    func get() -> any CardStyle {
        switch self {
        case .basic: return BasicCardStyle()
        case .elevated: return ElevatedCardStyle()
        }
    }
}

/// Defines the KeyPath \.cardStyle on environment
extension EnvironmentValues {
    var cardStyle: CardStyles {
        get { self[CardStyleKey.self] }
        set { self[CardStyleKey.self] = newValue }
    }
}

/// Sets the default value for cardStyle in the environment
struct CardStyleKey: EnvironmentKey {
    static var defaultValue = CardStyles.basic
}

Now let’s revisit our Card component so that it actually uses the style defined by the environment, and let us also create a modifier that allows us to change the environment value. Note that we return an AnyView, this is because each style does not hold the same view, but can hold any view.

struct Card<Content: View>: View {
    @Environment(\.cardStyle) var style
    var content: () -> Content
    
    init(content: @escaping () -> Content) {
        self.content = content
    }
    
    var body: some View {
        AnyView (
            style.get().makeBody(
                configuration: CardStyleConfiguration(
                    content: CardStyleConfiguration.Content(content: content())
                )
            )
        )
    }
}

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

Now we can use it like this:

Card {
    // Content here
}
.cardStyle(.basic)

To further utilize the context we now have enforced, we can create another component that changes its appearance based on the card style.

Do note that native styles are internal and therefore not exposed to the environment, so this can only be done with custom styles.

struct CardTitle: View {
    @Environment(\.cardStyle) private var cardStyle
    
    let title: String
    
    init(_ title: String) {
        self.title = title
    }
    
    var body: some View {
        switch cardStyle {
        case .basic:
            Text(title)
                .font(.title)
                .bold()
        case .elevated:
            Text(title)
                .font(.title2)
                .fontWeight(.heavy)
                .foregroundStyle(.cyan)
        }
    }
}

And the result when CardTitle is used inside our Card with different styles:

VStack(spacing: 30) {
    Card {
        VStack(alignment: .leading) {
            CardTitle("Basic card")
            Text("Lorem...")
        }
    }
    
    Card {
        VStack(alignment: .leading) {
            CardTitle("Elevated card")
            Text("Lorem...")
        }
    }
    .cardStyle(.elevated)
}

Conclusion

With this technique we have a uniform way of altering our content with a single modifier that can be explicitly set for one component, or at a higher level. This replicates a pattern that is already well established within SwiftUI and therefore makes it easy for other maintainers of our code to use.

Styles can be conditionally set, not only for theming purposes, but can also be used to set specific styles based on user preferences in the operating system, such as accessibility settings.

Terje Lønøy

Terje har jobbet med produktutvikling av applikasjoner for mobil siden 2012. Han har stor interesse for moderne teknologi, design og gode brukeropplevelser.

Han har vist sterkt faglig engasjement gjennom å avholde kurs og workshops, både internt og eksternt, samt være en aktiv pådriver for interne faggrupper innenfor mobil.

Noen av de kundene Terje har hjulpet med å lage gode mobilapplikasjoner er Politiets IT-tjenester, Norwegian, Kolonial/Oda og Møller.

Terje brenner for produktutvikling, og er aktiv deltaker rundt diskusjoner om retning og behov for å ivareta brukerene på best mulig måte. Han er svært oppdatert på det som rører seg i mobilverden og har fokusert mye på SwiftUI, Compose og Kotlin Multiplatform den siste tiden.

Terje er en sosial og kreativ person som drives av utfordringer og tørst for kunnskap, selv i stressende situasjoner. Videre er han selvstendig, men jobber godt sammen med andre.

Forrige
Forrige

Scelto ønsker velkommen til Emil og Lars!

Neste
Neste

Building a promise based dialog component