| name | themekit |
| description | Public API guide for the ThemeKit Swift package (github.com/demolaf/ThemeKit) โ Theme, ThemeExtension, ThemeVariant, ThemeStorage, and the SwiftUI/UIKit ThemeAppliers. Use when adding ThemeKit as a dependency, defining an app's theme model, wiring up light/dark variants, or debugging theme persistence, color storage, or Swift 6 concurrency issues involving Theme, ThemeExtension, ThemeVariant, or ThemeApplier.
|
ThemeKit
A small, dependency-free theming package for UIKit and SwiftUI apps. One
Theme store holds app-defined ThemeExtension values (colors, fonts,
anything Codable), persists them, and pushes fine-grained @Observable
updates to SwiftUI and UIKit.
Install
.package(url: "https://github.com/demolaf/ThemeKit.git", from: "1.0.0")
Pick the product(s) you need:
ThemeKit โ core (Theme, ThemeExtension, ThemeVariant, ThemeStorage). No UI dependency.
ThemeKitSwiftUI โ adds the applyTheme(_:default:available:) view modifier and Color: Codable.
ThemeKitUIKit โ adds the UIKit ThemeApplier class and @CodableColor.
Core concepts
Theme โ @Observable @MainActor final class. Central store. Create one instance per app (or per isolated section) and pass it down; no singleton.
ThemeExtension โ a protocol your own struct conforms to (Codable & Equatable & Sendable). Holds the actual theme data (colors, fonts, spacing โ anything). Requires static var fallback: Self and var colorScheme: SystemColorScheme.
ThemeVariant โ a named light/dark pair of one ThemeExtension type. Requires id, light, dark.
ThemeStorage โ protocol abstracting persistence. UserDefaults conforms out of the box; implement it yourself for Keychain, CloudKit, or an in-memory test double.
ThemeApplier โ separate SwiftUI (ViewModifier) and UIKit (class) implementations that apply a ThemeVariant on first launch, follow system appearance, or force an override, and keep the window/view hierarchy in sync.
Define a theme
SwiftUI โ store Color directly:
import ThemeKit
import ThemeKitSwiftUI
struct AppColors: ThemeExtension {
var tint: Color
var background: Color
var colorScheme: SystemColorScheme
static let fallback = AppColors(tint: .blue, background: .white, colorScheme: .light)
}
UIKit โ wrap UIColor in @CodableColor:
import ThemeKit
struct AppColors: ThemeExtension {
@CodableColor var tint: UIColor
@CodableColor var background: UIColor
var colorScheme: SystemColorScheme
static let fallback = AppColors(tint: .systemBlue, background: .white, colorScheme: .light)
}
Both encode to the same hex-int format, so storage is interchangeable between targets. SwiftUI.Color gets retroactive Codable conformance from ThemeKitSwiftUI (hex int via cgColor?.components); UIKit properties use the @CodableColor property wrapper โ UIColor itself cannot be made Codable directly (see Pitfalls).
Give Theme a named accessor:
extension Theme {
var colors: AppColors { value(AppColors.self) }
}
Define a light/dark pair:
struct AppColorsVariant: ThemeVariant {
let id: String
let light: AppColors
let dark: AppColors
static let `default` = AppColorsVariant(id: "default", light: .fallback, dark: .darkFallback)
static let all: [AppColorsVariant] = [.default]
}
Wire it up
let theme = Theme()
let theme = Theme(suiteName: "com.x.y")
let theme = Theme(storage: myStorage)
SwiftUI โ apply once near the root of the view hierarchy:
ContentView()
.applyTheme(theme, default: AppColorsVariant.default, available: AppColorsVariant.all)
UIKit โ create one ThemeApplier per scene/window and drive its lifecycle hooks:
let applier = ThemeApplier(theme: theme, default: .default, available: AppColorsVariant.all, window: window)
applier.onAppear()
applier.onChangeOfThemeState()
applier.onChangeOfSystemUserInterfaceStyle()
Read and write
theme.colors.tint
theme.apply(AppColors(tint: .red, background: theme.colors.background, colorScheme: theme.colors.colorScheme))
theme.apply(variant: AppColorsVariant.default, for: .dark)
theme.followsSystem = true
For partial user customization (e.g. a color well that overrides just the accent, leaving the rest of the preset intact), conform to ThemeOverridable and declare which fields are user-editable:
import ThemeKit
import ThemeKitSwiftUI
struct AppColors: ThemeExtension, ThemeOverridable {
var tint: Color
var background: Color
var colorScheme: SystemColorScheme
static let fallback = AppColors(tint: .blue, background: .white, colorScheme: .light)
var props: [Prop<Self>] { [.init(\.tint)] }
}
theme.merge(AppColors(tint: newColor, background: theme.colors.background, colorScheme: theme.colors.colorScheme))
theme.colors.compare(to: preset)
Pitfalls
- Two
Theme() instances sharing UserDefaults.standard corrupt each other's metadata. Both write to the same "themeKit.metadata" key (followsSystem / activeVariantID). Always pass a unique suiteName: for a second Theme in the same app.
UIColor cannot conform to Codable via extension โ it's a non-final class and required init(from:) can't be added retroactively. Use @CodableColor on the property instead of trying to make UIColor itself Codable.
- Swift 6 static stored properties are always
nonisolated, even under module-wide @MainActor isolation. ThemeExtension.fallback and any static variant presets must not call @MainActor initializers.
Color(hex:) must build via Color(red:green:blue:), not Color(UIColor(hex:)) โ Color.init(_ uiColor:) is @MainActor, which fails to compile in a nonisolated static property under Swift 6 strict concurrency.
theme.apply(_:) replaces the whole value; theme.merge(_:) overlays only props fields. Reach for merge when only some fields should be user-editable โ using apply there silently discards untouched fields on the next read if the caller only populated a subset.
- Reads register observation dependencies per extension type, not globally โ
theme.colors and theme.christmas (say) are tracked independently, so an observer reading only theme.colors won't re-run when an unrelated extension changes.