The Power of Custom ShapeStyle for SwiftUI Theming

Sep 16, 2024

In SwiftUI, customizing your app’s appearance to align with a specific theme can be a powerful tool for enhancing user experience. However, managing these customizations often involves a burdensome repetition of environment values across various views. In this blog post, we’ll explore how to create custom ShapeStyle implementations that allow you to use your own colors seamlessly throughout your app. This approach simplifies the process by enabling centralized theme management and dynamic color adjustments based on environment values like color schemes and user preferences.

Let’s start with some code. I created a Theme struct to have some specific colors for my app which I also want to have available through the Environment.

struct Theme {
    var brand: Color
    var text: Color
    var highlight: Color
}

extension Theme {
    static let mainTheme = Theme(brand: .orange, text: .black, highlight: .pink)
}

extension EnvironmentValues {
    @Entry var theme: Theme = .mainTheme
}

If we want to use these themed colors in our views, we need to access them through @Environment(\.theme) var theme.

struct SimpleViewWithEnvironment: View {
    @Environment(\.theme) private var theme

    var body: some View {
        VStack(spacing: 20) {
            Text("Hello to my App 👋")
                .foregroundStyle(theme.brand)
                .font(.largeTitle)

            Text("""
            Welcome to a whole new experience! Dive into the world of 
            \(Text("seamless interactions").foregroundStyle(theme.highlight)) 
            and intuitive design. We’re excited to have you on board and can't wait to show you what we've built.
            """)
                .foregroundStyle(theme.text)
        }
        .padding()
    }
}
Simple View with Environment

If you want to use your own theming in SwiftUI and don’t want to use some static property like Theme.mainTheme because changing the theme during runtime wouldn’t update the current view. Or you find it tedious to add @Environment(\.theme) var theme in any View file where you want to access your brand, text etc. colors.

There is a simple way to archive this with a custom ShapeStyle implementation:

struct ThemeColor: ShapeStyle {
    private let keyPath: KeyPath
    
    init(_ keyPath: KeyPath) {
        self.keyPath = keyPath
    }
    
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        let currentTheme = environment.theme
        return currentTheme[keyPath: keyPath]
    }
}

extension ShapeStyle where Self == ThemeColor {
    static var brand: ThemeColor { ThemeColor(\.brand) }
    static var text: ThemeColor { ThemeColor(\.text) }
    static var highlight: ThemeColor { ThemeColor(\.highlight) }
}

You can use .foregroundStyle as you would with predefined colors like .foregroundStyle(.red) just with your themed colors.

And switching theme for a subview or the whole app is as simple as changing any other environment value: .environment(\.theme, Theme(brand: .green, text: .gray, highlight: .teal)).

struct CustomShapeStyleView: View {
    var body: some View {
        VStack(spacing: 20) {
            Text("Hello to my App 👋")
                .foregroundStyle(.brand)
                .font(.largeTitle)

            Text("""
            Welcome to a whole new experience! Dive into the world of \(Text("seamless interactions").foregroundStyle(.highlight)) and intuitive design. We’re excited to have you on board and can't wait to show you what we've built.
            """)
                .foregroundStyle(.text)
        }
        .padding()
        .environment(\.theme, Theme(brand: .green, text: .gray, highlight: .teal))
    }
}
Custom View without @Environment declaration

With this, you don’t need to add @Environment(\.theme) var theme to every view or need to call your theme colors with static properties like .foregroundStyle(ThemeManager.currentTheme.brand)

💡 If you don’t want to recreate your semantic color names in a ShapeStyle extension you can also just use a wrapper function like this:

static func theme(_ keyPath: KeyPath) -> ThemeColor {
   ThemeColor(keyPath)
}

And use it in the View .foregroundStyle(.theme(\.brand))

Not only for Theming

It’s not only useful when you want to bring your own theming into your app.

Sometimes you need a different color depending on your user’s preferences regarding dark & light modes.With the same approach, you can easily access the current colorScheme from the Environment.

extension EnvironmentValues {
    @Entry var lightTheme: Theme = .mainTheme
    @Entry var darkTheme: Theme = Theme(brand: .white, text: .gray, highlight: .cyan)
}

struct ThemeColorScheme: ShapeStyle {
    private let keyPath: KeyPath

    init(_ keyPath: KeyPath) {
        self.keyPath = keyPath
    }

    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        if environment.colorScheme == .dark {
            return environment.darkTheme[keyPath: keyPath]
        } else {
            return environment.lightTheme[keyPath: keyPath]
        }
    }
}
Custom View for light and dark mode

Another solution would be to initialize the theme already with the UIColor(dynamicProvider:) initalizer, but this would force us to UIKit and we need to implement another solution for macOS apps.

Accessibility

Or lets talk about accessibility. Not every culture uses red as a warning, so you could create your own destructive style which returns different colors depending on the region of the user.Or add a different behavior depending on the platform the user is using your app.

struct DestructiveColor: ShapeStyle {
    func resolve(in environment: EnvironmentValues) -> some ShapeStyle {
        if environment.locale.region?.identifier == "CN" {
            Color.orange
        } else {
            Color.red
        }
    }
}

extension ShapeStyle where Self == DestructiveColor {
    static var destructive: DestructiveColor {
        DestructiveColor()
    }
}

Which can be used like this:

struct DestructiveColors: View {
    @Environment(\.locale) var locale
    var body: some View {
        VStack {
            Button {} label: {
                Label("Delete profile", systemImage: "person.crop.circle.badge.exclamationmark")
                    .padding()
                    .background(.destructive)
                    .clipShape(.capsule)
            }

            Button {} label: {
                Label("Delete profile", systemImage: "person.crop.circle.badge.exclamationmark")
                    .padding()
                    .background(.destructive)
                    .clipShape(.capsule)
            }
            .environment(\.locale, Locale.init(languageCode: .chinese, script: nil, languageRegion: .chinaMainland))
        }
        .foregroundStyle(.white)
    }
}
Two buttons with different destructive colors

Conclusion

By implementing custom ShapeStyle for your app, you can significantly streamline the theming process. This approach not only reduces the repetition of environment value declarations but also centralizes theme management, allowing for real-time adjustments based on user preferences, color schemes, and accessibility requirements. The versatility of custom ShapeStyle extends beyond theming, offering solutions for dynamic appearances in dark/light modes and culturally sensitive designs. Embrace this technique to enhance the user experience and maintain a clean, maintainable codebase. Happy coding!


Made with 💜 by Michael Freiwald

Created with Ignite Swift Logo

Impressum