theme-management
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseTheme Management — Expert Decisions
主题管理——专业决策指南
Expert decision frameworks for theming choices in SwiftUI. Claude knows Color and colorScheme — this skill provides judgment calls for when custom theming adds value and architecture trade-offs.
SwiftUI中主题选择的专业决策框架。本指南涵盖Color与colorScheme的相关知识,为自定义主题的适用场景及架构取舍提供判断依据。
Decision Trees
决策树
Do You Need Custom Theming?
是否需要自定义主题?
What's your color requirement?
├─ Standard light/dark mode only
│ └─ System colors are sufficient
│ Color(.systemBackground), Color(.label)
│
├─ Brand colors that differ from system
│ └─ Asset catalog colors (Named Colors)
│ Define light/dark variants in xcassets
│
├─ User-selectable themes (beyond light/dark)
│ └─ Full ThemeManager with custom palettes
│ User can choose "Ocean", "Forest", etc.
│
└─ White-label app (different branding per client)
└─ Remote theme configuration
Fetch brand colors from serverThe trap: Building a ThemeManager for an app that only needs light/dark mode. Asset catalog colors already handle this automatically.
What's your color requirement?
├─ Standard light/dark mode only
│ └─ System colors are sufficient
│ Color(.systemBackground), Color(.label)
│
├─ Brand colors that differ from system
│ └─ Asset catalog colors (Named Colors)
│ Define light/dark variants in xcassets
│
├─ User-selectable themes (beyond light/dark)
│ └─ Full ThemeManager with custom palettes
│ User can choose "Ocean", "Forest", etc.
│
└─ White-label app (different branding per client)
└─ Remote theme configuration
Fetch brand colors from server误区:只为支持明暗模式的应用构建ThemeManager。资源目录颜色(Asset catalog colors)已能自动处理该需求。
Color Token Architecture
颜色令牌架构
How should you organize colors?
├─ Small app (< 20 screens)
│ └─ Direct Color("name") usage
│ Don't over-engineer
│
├─ Medium app, single brand
│ └─ Color extension with static properties
│ Color.appPrimary, Color.appBackground
│
├─ Large app, design system
│ └─ Semantic tokens + primitive tokens
│ Primitives: blue500, gray100
│ Semantic: textPrimary → gray900/gray100
│
└─ Multi-brand/white-label
└─ Protocol-based themes
protocol Theme { var primary: Color }How should you organize colors?
├─ Small app (< 20 screens)
│ └─ Direct Color("name") usage
│ Don't over-engineer
│
├─ Medium app, single brand
│ └─ Color extension with static properties
│ Color.appPrimary, Color.appBackground
│
├─ Large app, design system
│ └─ Semantic tokens + primitive tokens
│ Primitives: blue500, gray100
│ Semantic: textPrimary → gray900/gray100
│
└─ Multi-brand/white-label
└─ Protocol-based themes
protocol Theme { var primary: Color }Theme Switching Strategy
主题切换策略
When should theme changes apply?
├─ Immediate (all screens at once)
│ └─ Environment-based (@Environment(\.colorScheme))
│ System handles propagation
│
├─ Per-screen animation
│ └─ withAnimation on theme property change
│ Smooth transition within visible content
│
├─ Custom transition (like morphing)
│ └─ Snapshot + crossfade technique
│ Complex, usually not worth it
│
└─ App restart required
└─ For deep UIKit integration
Sometimes unavoidable with third-party SDKsWhen should theme changes apply?
├─ Immediate (all screens at once)
│ └─ Environment-based (@Environment(\.colorScheme))
│ System handles propagation
│
├─ Per-screen animation
│ └─ withAnimation on theme property change
│ Smooth transition within visible content
│
├─ Custom transition (like morphing)
│ └─ Snapshot + crossfade technique
│ Complex, usually not worth it
│
└─ App restart required
└─ For deep UIKit integration
Sometimes unavoidable with third-party SDKsDark Mode Compliance Level
深色模式合规等级
What's your dark mode strategy?
├─ System automatic (no custom colors)
│ └─ Free dark mode support
│ UIColor semantic colors work automatically
│
├─ Custom colors with asset catalog
│ └─ Define both appearances per color
│ Xcode handles switching
│
├─ Programmatic dark variants
│ └─ Use Color.dynamic(light:dark:)
│ More flexible, harder to maintain
│
└─ Ignoring dark mode
└─ DON'T — accessibility issue
Users expect dark mode supportWhat's your dark mode strategy?
├─ System automatic (no custom colors)
│ └─ Free dark mode support
│ UIColor semantic colors work automatically
│
├─ Custom colors with asset catalog
│ └─ Define both appearances per color
│ Xcode handles switching
│
├─ Programmatic dark variants
│ └─ Use Color.dynamic(light:dark:)
│ More flexible, harder to maintain
│
└─ Ignoring dark mode
└─ DON'T — accessibility issue
Users expect dark mode supportNEVER Do
绝对不要做的事
Color Definition
颜色定义
NEVER hardcode colors in views:
swift
// ❌ Doesn't adapt to dark mode
Text("Hello")
.foregroundColor(Color(red: 0, green: 0, blue: 0))
.background(Color.white)
// ✅ Adapts automatically
Text("Hello")
.foregroundColor(Color(.label))
.background(Color(.systemBackground))
// Or use named colors from asset catalog
Text("Hello")
.foregroundColor(Color("TextPrimary"))NEVER use opposite colors for dark mode:
swift
// ❌ Pure black/white has harsh contrast
let background = colorScheme == .dark ? Color.black : Color.white
let text = colorScheme == .dark ? Color.white : Color.black
// ✅ Use elevated surfaces and softer contrasts
let background = Color(.systemBackground) // Slightly elevated in dark mode
let text = Color(.label) // Not pure white in dark modeNEVER check colorScheme when system colors suffice:
swift
// ❌ Unnecessary — system colors do this
@Environment(\.colorScheme) var colorScheme
var textColor: Color {
colorScheme == .dark ? .white : .black // Reimplementing Color(.label)!
}
// ✅ Just use the semantic color
.foregroundColor(Color(.label))绝对不要在视图中硬编码颜色:
swift
// ❌ Doesn't adapt to dark mode
Text("Hello")
.foregroundColor(Color(red: 0, green: 0, blue: 0))
.background(Color.white)
// ✅ Adapts automatically
Text("Hello")
.foregroundColor(Color(.label))
.background(Color(.systemBackground))
// Or use named colors from asset catalog
Text("Hello")
.foregroundColor(Color("TextPrimary"))绝对不要为深色模式使用完全相反的颜色:
swift
// ❌ Pure black/white has harsh contrast
let background = colorScheme == .dark ? Color.black : Color.white
let text = colorScheme == .dark ? Color.white : Color.black
// ✅ Use elevated surfaces and softer contrasts
let background = Color(.systemBackground) // Slightly elevated in dark mode
let text = Color(.label) // Not pure white in dark mode绝对不要在系统颜色已能满足需求时检查colorScheme:
swift
// ❌ Unnecessary — system colors do this
@Environment(\.colorScheme) var colorScheme
var textColor: Color {
colorScheme == .dark ? .white : .black // Reimplementing Color(.label)!
}
// ✅ Just use the semantic color
.foregroundColor(Color(.label))Theme Architecture
主题架构
NEVER store theme in multiple places:
swift
// ❌ State scattered — which is source of truth?
class ThemeManager {
@Published var isDark = false
}
struct SettingsView: View {
@AppStorage("isDark") var isDark = false // Different storage!
}
// ✅ Single source of truth
class ThemeManager: ObservableObject {
@AppStorage("theme") var theme: Theme = .system // One place
}NEVER force override system preference without user consent:
swift
// ❌ Ignores user's system-wide preference
init() {
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .dark
}
// ✅ Only override if user explicitly chose in-app
func applyUserPreference(_ theme: Theme) {
switch theme {
case .system:
window?.overrideUserInterfaceStyle = .unspecified // Respect system
case .light:
window?.overrideUserInterfaceStyle = .light
case .dark:
window?.overrideUserInterfaceStyle = .dark
}
}NEVER forget accessibility contrast requirements:
swift
// ❌ Low contrast — fails WCAG AA
let textColor = Color(white: 0.6) // On white background = 2.5:1 ratio
let backgroundColor = Color.white
// ✅ Meets WCAG AA (4.5:1 for normal text)
let textColor = Color(.secondaryLabel) // System ensures compliance
let backgroundColor = Color(.systemBackground)绝对不要在多个位置存储主题状态:
swift
// ❌ State scattered — which is source of truth?
class ThemeManager {
@Published var isDark = false
}
struct SettingsView: View {
@AppStorage("isDark") var isDark = false // Different storage!
}
// ✅ Single source of truth
class ThemeManager: ObservableObject {
@AppStorage("theme") var theme: Theme = .system // One place
}绝对不要未经用户同意强制覆盖系统偏好设置:
swift
// ❌ Ignores user's system-wide preference
init() {
UIApplication.shared.windows.first?.overrideUserInterfaceStyle = .dark
}
// ✅ Only override if user explicitly chose in-app
func applyUserPreference(_ theme: Theme) {
switch theme {
case .system:
window?.overrideUserInterfaceStyle = .unspecified // Respect system
case .light:
window?.overrideUserInterfaceStyle = .light
case .dark:
window?.overrideUserInterfaceStyle = .dark
}
}绝对不要忘记无障碍对比度要求:
swift
// ❌ Low contrast — fails WCAG AA
let textColor = Color(white: 0.6) // On white background = 2.5:1 ratio
let backgroundColor = Color.white
// ✅ Meets WCAG AA (4.5:1 for normal text)
let textColor = Color(.secondaryLabel) // System ensures compliance
let backgroundColor = Color(.systemBackground)Performance
性能优化
NEVER recompute colors on every view update:
swift
// ❌ Creates new color object every render
var body: some View {
Text("Hello")
.foregroundColor(Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
})) // New UIColor closure every time!
}
// ✅ Define once, reuse
extension Color {
static let adaptiveText = Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
})
}
var body: some View {
Text("Hello")
.foregroundColor(.adaptiveText)
}绝对不要在每次视图更新时重新计算颜色:
swift
// ❌ Creates new color object every render
var body: some View {
Text("Hello")
.foregroundColor(Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
})) // New UIColor closure every time!
}
// ✅ Define once, reuse
extension Color {
static let adaptiveText = Color(UIColor { traits in
traits.userInterfaceStyle == .dark ? .white : .black
})
}
var body: some View {
Text("Hello")
.foregroundColor(.adaptiveText)
}Essential Patterns
核心模式
Semantic Color System
语义化颜色系统
swift
// Primitive tokens (raw values)
extension Color {
enum Primitive {
static let blue500 = Color(hex: "#007AFF")
static let blue600 = Color(hex: "#0056B3")
static let gray900 = Color(hex: "#1A1A1A")
static let gray100 = Color(hex: "#F5F5F5")
}
}
// Semantic tokens (usage-based)
extension Color {
static let textPrimary = Color("TextPrimary") // gray900 light, gray100 dark
static let textSecondary = Color("TextSecondary")
static let surfacePrimary = Color("SurfacePrimary")
static let surfaceSecondary = Color("SurfaceSecondary")
static let interactive = Color("Interactive") // blue500
static let interactivePressed = Color("InteractivePressed") // blue600
// Feedback colors (always same hue, adjusted for mode)
static let success = Color("Success")
static let warning = Color("Warning")
static let error = Color("Error")
}swift
// Primitive tokens (raw values)
extension Color {
enum Primitive {
static let blue500 = Color(hex: "#007AFF")
static let blue600 = Color(hex: "#0056B3")
static let gray900 = Color(hex: "#1A1A1A")
static let gray100 = Color(hex: "#F5F5F5")
}
}
// Semantic tokens (usage-based)
extension Color {
static let textPrimary = Color("TextPrimary") // gray900 light, gray100 dark
static let textSecondary = Color("TextSecondary")
static let surfacePrimary = Color("SurfacePrimary")
static let surfaceSecondary = Color("SurfaceSecondary")
static let interactive = Color("Interactive") // blue500
static let interactivePressed = Color("InteractivePressed") // blue600
// Feedback colors (always same hue, adjusted for mode)
static let success = Color("Success")
static let warning = Color("Warning")
static let error = Color("Error")
}ThemeManager with Persistence
带持久化的ThemeManager
swift
@MainActor
final class ThemeManager: ObservableObject {
static let shared = ThemeManager()
@AppStorage("selectedTheme") private var storedTheme: String = Theme.system.rawValue
@Published private(set) var currentTheme: Theme = .system
enum Theme: String, CaseIterable {
case system, light, dark
var overrideStyle: UIUserInterfaceStyle {
switch self {
case .system: return .unspecified
case .light: return .light
case .dark: return .dark
}
}
}
private init() {
currentTheme = Theme(rawValue: storedTheme) ?? .system
applyTheme(currentTheme)
}
func setTheme(_ theme: Theme) {
currentTheme = theme
storedTheme = theme.rawValue
applyTheme(theme)
}
private func applyTheme(_ theme: Theme) {
// Apply to all windows (handles multiple scenes)
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.overrideUserInterfaceStyle = theme.overrideStyle
}
}
}
}swift
@MainActor
final class ThemeManager: ObservableObject {
static let shared = ThemeManager()
@AppStorage("selectedTheme") private var storedTheme: String = Theme.system.rawValue
@Published private(set) var currentTheme: Theme = .system
enum Theme: String, CaseIterable {
case system, light, dark
var overrideStyle: UIUserInterfaceStyle {
switch self {
case .system: return .unspecified
case .light: return .light
case .dark: return .dark
}
}
}
private init() {
currentTheme = Theme(rawValue: storedTheme) ?? .system
applyTheme(currentTheme)
}
func setTheme(_ theme: Theme) {
currentTheme = theme
storedTheme = theme.rawValue
applyTheme(theme)
}
private func applyTheme(_ theme: Theme) {
// Apply to all windows (handles multiple scenes)
for scene in UIApplication.shared.connectedScenes {
guard let windowScene = scene as? UIWindowScene else { continue }
for window in windowScene.windows {
window.overrideUserInterfaceStyle = theme.overrideStyle
}
}
}
}Contrast Validation
对比度验证
swift
extension Color {
func contrastRatio(against background: Color) -> Double {
let fgLuminance = relativeLuminance
let bgLuminance = background.relativeLuminance
let lighter = max(fgLuminance, bgLuminance)
let darker = min(fgLuminance, bgLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
var meetsWCAGAA: Bool {
// Check against both light and dark backgrounds
let lightBg = Color.white
let darkBg = Color.black
return contrastRatio(against: lightBg) >= 4.5 ||
contrastRatio(against: darkBg) >= 4.5
}
private var relativeLuminance: Double {
guard let components = UIColor(self).cgColor.components,
components.count >= 3 else { return 0 }
func linearize(_ value: CGFloat) -> Double {
let v = Double(value)
return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
}
return 0.2126 * linearize(components[0]) +
0.7152 * linearize(components[1]) +
0.0722 * linearize(components[2])
}
}swift
extension Color {
func contrastRatio(against background: Color) -> Double {
let fgLuminance = relativeLuminance
let bgLuminance = background.relativeLuminance
let lighter = max(fgLuminance, bgLuminance)
let darker = min(fgLuminance, bgLuminance)
return (lighter + 0.05) / (darker + 0.05)
}
var meetsWCAGAA: Bool {
// Check against both light and dark backgrounds
let lightBg = Color.white
let darkBg = Color.black
return contrastRatio(against: lightBg) >= 4.5 ||
contrastRatio(against: darkBg) >= 4.5
}
private var relativeLuminance: Double {
guard let components = UIColor(self).cgColor.components,
components.count >= 3 else { return 0 }
func linearize(_ value: CGFloat) -> Double {
let v = Double(value)
return v <= 0.03928 ? v / 12.92 : pow((v + 0.055) / 1.055, 2.4)
}
return 0.2126 * linearize(components[0]) +
0.7152 * linearize(components[1]) +
0.0722 * linearize(components[2])
}
}Quick Reference
快速参考
When to Use Custom Theming
自定义主题的适用场景
| Scenario | Solution |
|---|---|
| Standard light/dark | System colors only |
| Brand colors | Asset catalog named colors |
| User-selectable themes | ThemeManager + color palettes |
| White-label | Remote config + theme protocol |
| 场景 | 解决方案 |
|---|---|
| 标准明暗模式 | 仅使用系统颜色 |
| 品牌专属颜色 | 资源目录命名颜色 |
| 用户可选主题 | ThemeManager + 颜色调色板 |
| 白标应用 | 远程配置 + 主题协议 |
System vs Custom Colors
系统颜色 vs 自定义颜色
| Need | System | Custom |
|---|---|---|
| Text colors | Color(.label), Color(.secondaryLabel) | Only if brand requires |
| Backgrounds | Color(.systemBackground) | Only if brand requires |
| Dividers | Color(.separator) | Rarely |
| Tint/accent | Color.accentColor | Usually brand color |
| 需求 | 系统颜色 | 自定义颜色 |
|---|---|---|
| 文本颜色 | Color(.label), Color(.secondaryLabel) | 仅品牌有特殊要求时使用 |
| 背景色 | Color(.systemBackground) | 仅品牌有特殊要求时使用 |
| 分割线 | Color(.separator) | 极少使用 |
| 强调色 | Color.accentColor | 通常为品牌颜色 |
Contrast Ratios (WCAG)
对比度比值(WCAG标准)
| Level | Normal Text | Large Text | Use Case |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | Minimum acceptable |
| AAA | 7:1 | 4.5:1 | Enhanced readability |
| 等级 | 常规文本 | 大文本 | 使用场景 |
|---|---|---|---|
| AA | 4.5:1 | 3:1 | 最低可接受标准 |
| AAA | 7:1 | 4.5:1 | 增强可读性标准 |
Red Flags
风险信号
| Smell | Problem | Fix |
|---|---|---|
| Color(red:green:blue:) in view | No dark mode | Use semantic colors |
| @Environment colorScheme everywhere | Over-engineering | System colors adapt automatically |
| Pure black/white | Harsh contrast | Elevated surfaces |
| Theme in multiple @AppStorage | Split source of truth | Single ThemeManager |
| window.overrideUserInterfaceStyle in init | Ignores user pref | Only on explicit user action |
| 问题迹象 | 潜在问题 | 修复方案 |
|---|---|---|
| 视图中使用Color(red:green:blue:) | 不支持深色模式 | 使用语义化颜色 |
| 随处使用@Environment colorScheme | 过度设计 | 系统颜色可自动适配 |
| 使用纯黑/纯白 | 对比度过于刺眼 | 使用层级化界面 |
| 主题存储在多个@AppStorage中 | 数据源不一致 | 使用单一ThemeManager |
| 在init中设置window.overrideUserInterfaceStyle | 忽略用户偏好 | 仅在用户明确操作时修改 |