Supporting Dark Mode in SwiftUI Apps
Dark Mode has been a staple of iOS since iOS 13, offering users a visually distinct experience that can reduce eye strain in low-light environments and save battery life on OLED displays. For developers, supporting Dark Mode isn't just a "nice-to-have" feature; it's an expectation for any modern iOS application.
Fortunately, SwiftUI makes adopting Dark Mode remarkably straightforward, often requiring minimal effort for standard components. However, when you introduce custom colors, images, or specific UI requirements, you'll need to understand the tools SwiftUI provides to ensure your app looks great in both light and dark appearances.
In this article, we'll dive deep into supporting Dark Mode in your SwiftUI applications. We'll cover everything from leveraging system-provided semantic colors to handling custom assets and even overriding the color scheme for specific parts of your UI.
Understanding colorScheme and colorSchemeContrast
At the heart of Dark Mode support in SwiftUI are the colorScheme and colorSchemeContrast environment values. These values automatically adapt as the user changes their system appearance settings.
colorScheme (an instance of ColorScheme) tells you whether the current appearance is .light or .dark.
colorSchemeContrast (an instance of ColorSchemeContrast) indicates whether the user prefers .standard contrast or .increased contrast, which is an accessibility setting.
You can read these values using the @Environment property wrapper:
struct ContentView: View {
@Environment(\.colorScheme) var colorScheme
@Environment(\.colorSchemeContrast) var colorSchemeContrast
var body: some View {
VStack {
Text("Current color scheme: \(colorScheme == .dark ? "Dark" : "Light")")
.font(.title)
.padding()
Text("Current contrast: \(colorSchemeContrast == .increased ? "Increased" : "Standard")")
.font(.headline)
.padding()
// A simple button that adapts automatically
Button("Hello, Dark Mode!") {
// Action
}
.padding()
.background(Color.accentColor)
.foregroundColor(.white)
.cornerRadius(10)
}
}
}
Notice how in the example above, the Button's background color (Color.accentColor) and text color (.white) automatically adjust for contrast against the system background. This is a key benefit of using SwiftUI's built-in Color types.
Leveraging Semantic Colors for System Adaptation
The most effective way to support Dark Mode is to lean into SwiftUI's semantic colors. These are colors that describe their purpose rather than their absolute RGB values. The system then automatically provides the appropriate color for the current appearance.
Some common semantic colors you should use:
Color.primary: The dominant foreground color for your content. It's black in light mode and white in dark mode.Color.secondary: A less prominent foreground color, often used for supplementary information.Color.tertiary: Even less prominent, for subtle details.Color.quaternary: The least prominent foreground color.Color.accentColor: Your app's brand color, which should ideally look good in both light and dark.Color.systemBackground: The default background color for views.Color.secondarySystemBackground: A slightly darker/lighter background, useful for grouping content.Color.tertiarySystemBackground: Even darker/lighter, for further visual separation.Color.label: The color for primary text. Similar toColor.primary.Color.secondaryLabel,Color.tertiaryLabel,Color.quaternaryLabel: For less important text.Color.separator: The color for dividers and separators.
Consider this example where we use semantic colors for a simple card view:
struct ProductCardView: View {
let productName: String
let price: String
let description: String
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text(productName)
.font(.headline)
.foregroundColor(.primary) // Adapts automatically
Text(price)
.font(.subheadline)
.foregroundColor(.accentColor) // Your app's accent color
Divider() // Uses Color.separator automatically
Text(description)
.font(.caption)
.foregroundColor(.secondary) // Adapts automatically
}
.padding()
.background(Color.secondarySystemBackground) // Adapts automatically
.cornerRadius(12)
.shadow(color: Color.primary.opacity(0.1), radius: 5, x: 0, y: 2)
.padding(.horizontal)
}
}
Defining Custom Semantic Colors
What if you have custom brand colors that aren't covered by the system's semantic colors? You can define your own color assets in your Assets.xcassets catalog.
- Open
Assets.xcassets. - Right-click in the left pane -> "New Color Set".
- Name your color (e.g.,
BrandGreen). - In the Attributes Inspector (right pane), set "Appearances" to "Any, Dark".
- Set the color for "Any Appearance" (Light Mode) and "Dark Appearance" separately.
Now you can use Color("BrandGreen") in your SwiftUI views, and it will automatically pick the correct variant based on the current colorScheme.
┌─────────────────────────────────┐
│ Assets.xcassets │
│ ┌───────────────────────────┐ │
│ │ BrandGreen (Color Set) │ │
│ └───────────────────────────┘ │
│ │
│ Attributes Inspector │
│ ┌───────────────────────────┐ │
│ │ Appearances: Any, Dark │ │
│ │ ───────────────────────── │ │
│ │ Any Appearance: #2A8367 │ │
│ │ Dark Appearance: #4CAF50 │ │
│ └───────────────────────────┘ │
└─────────────────────────────────┘
Handling Custom Assets (Images)
Just like colors, images often need to change based on the current appearance. A light-themed logo might not be visible or might clash in Dark Mode. Asset Catalogs come to the rescue again.
- Open
Assets.xcassets. - Select an existing image or create a new "Image Set".
- In the Attributes Inspector, set "Appearances" to "Any, Dark".
- Drag your light mode image into the "Any Appearance" slot and your dark mode image into the "Dark Appearance" slot.
When you use Image("myCustomLogo") in SwiftUI, the system will automatically load the appropriate image variant.
struct LogoView: View {
var body: some View {
Image("AppLogo") // Automatically picks light or dark version
.resizable()
.scaledToFit()
.frame(width: 150, height: 150)
}
}
For SF Symbols, which are vector-based icons provided by Apple, Dark Mode adaptation is built-in. They automatically invert or adjust their appearance. This is another strong reason to prefer SF Symbols where possible.
Overriding Color Scheme for Specific Views
While automatic adaptation is great, there are times you might want a specific part of your UI to always be in light mode or dark mode, regardless of the system setting. This is common for things like splash screens, onboarding flows, or perhaps a modal sheet that needs a consistent look.
You can achieve this using the preferredColorScheme(_:) view modifier.
struct AlwaysLightView: View {
var body: some View {
NavigationView {
VStack {
Text("This view is always light!")
.font(.title)
.padding()
.background(Color.white)
.foregroundColor(.black)
Image(systemName: "sun.max.fill")
.font(.largeTitle)
.foregroundColor(.yellow)
}
.navigationTitle("Light Mode Only")
.preferredColorScheme(.light) // Forces light mode for this view hierarchy
}
}
}
struct AlwaysDarkView: View {
var body: some View {
NavigationView {
VStack {
Text("This view is always dark!")
.font(.title)
.padding()
.background(Color.black)
.foregroundColor(.white)
Image(systemName: "moon.fill")
.font(.largeTitle)
.foregroundColor(.blue)
}
.navigationTitle("Dark Mode Only")
.preferredColorScheme(.dark) // Forces dark mode for this view hierarchy
}
}
}
The preferredColorScheme(_:) modifier applies to the view and its entire hierarchy. It's a powerful tool for fine-grained control, but use it judiciously. Generally, it's best to respect the user's system preference unless there's a strong design or functional reason not to.
Best Practices and Testing
Testing Dark Mode
Testing your app in both light and dark appearances is crucial. Simulator/Device: Go to Settings -> Display & Brightness to toggle between Light and Dark. Xcode Previews: You can explicitly set the colorScheme for your previews using the .preferredColorScheme(_:) modifier or by using colorScheme(.dark) on the preview itself.
struct ProductCardView_Previews: PreviewProvider {
static var previews: some View {
Group {
ProductCardView(productName: "SwiftUI Book", price: "$49.99", description: "Learn SwiftUI from scratch.")
.previewDisplayName("Light Mode")
ProductCardView(productName: "SwiftUI Book", price: "$49.99", description: "Learn SwiftUI from scratch.")
.preferredColorScheme(.dark) // Force dark for preview
.previewDisplayName("Dark Mode")
}
}
}
Accessibility Considerations
Dark Mode isn't just about aesthetics; it also plays a role in accessibility. Ensure that your chosen colors maintain sufficient contrast in both light and dark modes, especially if you're using custom colors. Tools like Xcode's Accessibility Inspector can help you verify contrast ratios. The colorSchemeContrast environment value can also be useful here to provide even higher contrast options if the user has that setting enabled.
When to use ColorScheme.dark or ColorScheme.light explicitly
While using semantic colors is preferred, sometimes you might need to perform conditional logic based on the current scheme.
struct MyConditionalView: View {
@Environment(\.colorScheme) var colorScheme
var body: some View {
VStack {
if colorScheme == .dark {
Text("You are in Dark Mode!")
.foregroundColor(.white)
.background(Color.red.opacity(0.8)) // A specific dark mode only color
} else {
Text("You are in Light Mode!")
.foregroundColor(.black)
.background(Color.green.opacity(0.8)) // A specific light mode only color
}
}
.padding()
.cornerRadius(8)
}
}
This approach should be used sparingly. Rely on semantic colors and asset catalogs first. Explicit if colorScheme == .dark checks are usually reserved for scenarios where you need to change logic or layout based on the scheme, rather than just colors or images.
Summary
Supporting Dark Mode in SwiftUI is largely about embracing the framework's design philosophy: use semantic colors, leverage asset catalogs for custom assets, and let the system do the heavy lifting. For those specific scenarios where you need more control, preferredColorScheme(_:) and environment value checks offer the flexibility to tailor the experience. By following these guidelines, you can ensure your app provides a polished and delightful experience for all users, regardless of their preferred appearance.
Happy Swifting!