Memoji of Jacob giving a thumbs up

Handling Dark Mode Elegantly in SwiftUI

In SwiftUI, handling dark mode was made to be easy. The guys over at Apple thought things through and included the user's preferred color scheme as an environment value:

@Environment(\.colorScheme)

It's used like so:


struct DarkModeView: View {
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    var body: some View {
        Text("Hi")
            .foregroundColor(colorScheme == .dark ? .white : .black)
    }
}

In the example above, we simply apply the ternary operator where we assign the color value. If it's .dark we return .white, if not we return .black. Usually, we need to handle more than one color in a view, so let's do it a few more times.


struct DarkModeView: View {
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    var body: some View {
        VStack {
            Text("Hi dude")
                .foregroundColor(colorScheme == .dark ? .white : .black)

            Button("Dude, this is neat") { print("neat") }
                .foregroundColor(colorScheme == .dark ? .white : .black)

            Text("Totally neat.")
                .foregroundColor(colorScheme == .dark ? .white : .black)

        }.background(colorScheme == .dark ? Color.black : Color.white)
    }
}

That can get old pretty fast. Not only is it a lot to write, but the duplication leaves room for human error. If we chose to change the colors we'd need to change code in many locations. If done properly, we could change just one line of code. Instead of duplicating the color computation all over the place, let's use a computed property to handle that logic in one spot.


struct DarkModeView: View {
    @Environment(\.colorScheme) var colorScheme: ColorScheme

    var fontColor: Color {
        colorScheme == .dark ? .white : .black
    }

    var body: some View {
        VStack {
            Text("Hi")
                .foregroundColor(fontColor)

            Button("Dude, this is neat") { print("neat") }
                .foregroundColor(fontColor)

            Text("Totally neat.")
                .foregroundColor(fontColor)
        }
    }
}

Nice! That works pretty well. Unfortunately, that font color declaration is only included in this view. To solve that and share dark mode friendly colors, we'll make a protocol that handles that.


protocol Themeable {
    var colorScheme: ColorScheme { get }
}

This defines an interface of a Themeable object, but it does nothing to share the colors. In Swift, we are unable to define anything but the interface in a protocol's declaration, but we can extend protocols to contain functions, static variables, and computed properties.


extension Themeable {
    var fontColor: Color {
        colorScheme == .dark ? .white : .black
    }
}

With that in place, we can modify the DarkModeView that we created earlier. We can also use the new protocol to share fontColor in other views! Check it out:


struct DarkModeView: View, Themeable {
    @Environment(\.colorScheme) var colorScheme: ColorScheme
    
    var body: some View {
        VStack {
            Text("Hi")
                .foregroundColor(fontColor)
            
            Button("Dude, this is neat") { print("neat") }
                .foregroundColor(fontColor)
            
            Text("Totally neat.")
                .foregroundColor(fontColor)
        }
    }
}

struct AnotherDarkModeView: View, Themeable {
    @Environment(\.colorScheme) var colorScheme: ColorScheme
    
    var body: some View {
        Text("Pretty Cool!! Right?!")
            .foregroundColor(fontColor)
    }
}


With protocols, extensions, and computed properties, we created a dark mode friendly way to share colors. This method isn't limited to colors and the user's selected color scheme. What other user preferences could you account for in this way? Think about it and try it out on your own!


Tags: