ThemeKit is a lightweight theming library completely written in Swift that provides theming capabilities to both Swift and Objective-C macOS applications.
ThemeKit is brought to you with ❤️ by Nuno Grilo and the Paw team.
- Download the ThemeKit Demo binary and give it a try!
- Read the Make your macOS app themable article (simple tutorial).
- Check the ThemeKit Docs.
- Written in Swift 4.2
- Optional configuration, none required
- Neglected performance impact
- Automatically theme windows (configurable)
- Themes:
LightTheme
(default macOS appearance)DarkTheme
SystemTheme
(default theme). Dynamically resolves toThemeManager.lightTheme
orThemeManager.darkTheme
, depending on the "System Preferences > General > Appearance".- Support for custom themes (
Theme
) - Support for user-defined themes (
UserTheme
)
- Theme-aware assets:
ThemeColor
: colors that dynamically change with the themeThemeGradient
: gradients that dynamically change with the themeThemeImage
: images that dynamically change with the theme- Optional override of
NSColor
named colors (e.g.,labelColor
) to dynamically change with the theme
ThemeKit Version | Swift Version |
---|---|
1.0.0 | 3.0 |
1.1.0 | 4.0 |
1.2.0 | 4.1 |
1.2.3 | 4.2 |
There are multiple options to include ThemeKit on your project:
-
Add to your
Podfile
:use_frameworks! target '[YOUR APP TARGET]' do pod 'macOSThemeKit', '~> 1.2.0' end
When using CocoaPods, the ThemeKit module is named
macOSThemeKit
:import macOSThemeKit
-
github "luckymarmot/ThemeKit"
Then import ThemeKit module with:
import ThemeKit
-
Manually
- Either add
ThemeKit.framework
on your project, or, manually add source files from theThemeKit\
folder to your project - If importing into a Objective-C project, you will need to include all the Swift related frameworks as well (as reported here)
Then import ThemeKit module with:
import ThemeKit
- Either add
At its simpler usage, applications can be themed with a single line command:
func applicationWillFinishLaunching(_ notification: Notification) {
/// Apply the dark theme
ThemeManager.darkTheme.apply()
/// or, the light theme
//ThemeManager.lightTheme.apply()
/// or, the 'system' theme, which dynamically changes to light or dark,
/// respecting *System Preferences > General > Appearance* setting.
//ThemeManager.systemTheme.apply()
}
- (void)applicationWillFinishLaunching:(NSNotification *)aNotification {
// Apply the dark theme
TKDarkTheme *darkTheme = TKThemeManager.darkTheme;
[[TKThemeManager sharedManager] setTheme:darkTheme];
}
The following code will define which windows should be automatically themed (WindowThemePolicy
) and add support for user themes (UserTheme
):
func applicationWillFinishLaunching(_ notification: Notification) {
/// Define default theme.
/// Used on first run. Default: `SystemTheme`.
/// Note: `SystemTheme` is a special theme that resolves to `ThemeManager.lightTheme` or `ThemeManager.darkTheme`,
/// depending on the macOS preference at 'System Preferences > General > Appearance'.
ThemeManager.defaultTheme = ThemeManager.lightTheme
/// Define window theme policy.
ThemeManager.shared.windowThemePolicy = .themeAllWindows
//ThemeManager.shared.windowThemePolicy = .themeSomeWindows(windowClasses: [MyWindow.self])
//ThemeManager.shared.windowThemePolicy = .doNotThemeSomeWindows(windowClasses: [NSPanel.self])
//ThemeManager.shared.windowThemePolicy = .doNotThemeWindows
/// Enable & configure user themes.
/// Will use folder `(...)/Application Support/{your_app_bundle_id}/Themes`.
let applicationSupportURLs = NSSearchPathForDirectoriesInDomains(.applicationSupportDirectory, .userDomainMask, true)
let thisAppSupportURL = URL.init(fileURLWithPath: applicationSupportURLs.first!).appendingPathComponent(Bundle.main.bundleIdentifier!)
let userThemesFolderURL = thisAppSupportURL.appendingPathComponent("Themes")
ThemeManager.shared.userThemesFolderURL = userThemesFolderURL
/// Change the default light and dark theme, used when `SystemTheme` is selected.
//ThemeManager.lightTheme = ThemeManager.shared.theme(withIdentifier: PaperTheme.identifier)!
//ThemeManager.darkTheme = ThemeManager.shared.theme(withIdentifier: "com.luckymarmot.ThemeKit.PurpleGreen")!
/// Apply last applied theme (or the default theme, if no previous one)
ThemeManager.shared.applyLastOrDefaultTheme()
}
- (void)applicationWillFinishLaunching:(NSNotification *)aNotification {
/// Define default theme.
/// Used on first run. Default: `SystemTheme`.
/// Note: `SystemTheme` is a special theme that resolves to `ThemeManager.lightTheme` or `ThemeManager.darkTheme`,
/// depending on the macOS preference at 'System Preferences > General > Appearance'.
[TKThemeManager setDefaultTheme:TKThemeManager.lightTheme];
/// Define window theme policy.
[TKThemeManager sharedManager].windowThemePolicy = TKThemeManagerWindowThemePolicyThemeAllWindows;
//[TKThemeManager sharedManager].windowThemePolicy = TKThemeManagerWindowThemePolicyThemeSomeWindows;
//[TKThemeManager sharedManager].themableWindowClasses = @[[MyWindow class]];
//[TKThemeManager sharedManager].windowThemePolicy = TKThemeManagerWindowThemePolicyDoNotThemeSomeWindows;
//[TKThemeManager sharedManager].notThemableWindowClasses = @[[NSPanel class]];
//[TKThemeManager sharedManager].windowThemePolicy = TKThemeManagerWindowThemePolicyDoNotThemeWindows;
/// Enable & configure user themes.
/// Will use folder `(...)/Application Support/{your_app_bundle_id}/Themes`.
NSArray<NSString*>* applicationSupportURLs = NSSearchPathForDirectoriesInDomains(NSApplicationSupportDirectory, NSUserDomainMask, YES);
NSURL* thisAppSupportURL = [[NSURL fileURLWithPath:applicationSupportURLs.firstObject] URLByAppendingPathComponent:[NSBundle mainBundle].bundleIdentifier];
NSURL* userThemesFolderURL = [thisAppSupportURL URLByAppendingPathComponent:@"Themes"];
[TKThemeManager sharedManager].userThemesFolderURL = userThemesFolderURL;
/// Change the default light and dark theme, used when `SystemTheme` is selected.
//TKThemeManager.lightTheme = [[TKThemeManager sharedManager] themeWithIdentifier:PaperTheme.identifier];
//TKThemeManager.darkTheme = [[TKThemeManager sharedManager] themeWithIdentifier:@"com.luckymarmot.ThemeKit.PurpleGreen"];
/// Apply last applied theme (or the default theme, if no previous one)
[[TKThemeManager sharedManager] applyLastOrDefaultTheme];
}
Please check the Demo application source code for a more complete usage example of ThemeKit.
ThemeKit provides the following notifications:
Notification.Name.willChangeTheme
is sent when current theme is about to changeNotification.Name.didChangeTheme
is sent when current theme did changeNotification.Name.didChangeSystemTheme
is sent when system theme did change (System Preference > General)
Example:
// Register to be notified of theme changes
NotificationCenter.default.addObserver(self, selector: #selector(changedTheme(_:)), name: .didChangeTheme, object: nil)
@objc private func changedTheme(_ notification: Notification) {
// ...
}
Additionally, the following properties are KVO compliant:
ThemeManager.shared.theme
ThemeManager.shared.effectiveTheme
ThemeManager.shared.themes
ThemeManager.shared.userThemes
Example:
// Register for KVO changes on ThemeManager.shared.effectiveTheme
ThemeManager.shared.addObserver(self, forKeyPath: "effectiveTheme", options: NSKeyValueObservingOptions.init(rawValue: 0), context: nil)
public override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
if keyPath == "effectiveTheme" {
// ...
}
}
In case (WindowThemePolicy
) was NOT set to .themeAllWindows
, you may need to manually theme a window. You can use our NSWindow
extension for that:
-
Theme window if appearance needs update. Doesn't check for policy compliance.
-
NSWindow.themeIfCompliantWithWindowThemePolicy()
Theme window if compliant to
ThemeManager.shared.windowThemePolicy
(and if appearance needs update). -
Theme all windows compliant to
ThemeManager.shared.windowThemePolicy
(and if appearance needs update). -
Any window specific theme. This is, usually,
nil
, which means the current global theme will be used. Please note that when using window specific themes, only the associatedNSAppearance
will be automatically set. All theme aware assets (ThemeColor
,ThemeGradient
andThemeImage
) should call methods that returns a resolved color instead (which means they don't change with the theme change, you need to observe theme changes manually, and set colors afterwards):ThemeColor.color(for view:, selector:)
ThemeGradient.gradient(for view:, selector:)
ThemeImage.image(for view:, selector:)
Additionally, please note that system overridden colors (
NSColor.*
) will always use the global theme. -
Returns the current effective theme (read-only).
-
NSWindow.windowEffectiveThemeAppearance
Returns the current effective appearance (read-only).
ThemeColor
, ThemeGradient
and ThemeImage
provides colors, gradients and images, respectively, that dynamically change with the current theme.
Additionally, named colors from the NSColor
class defined on the ThemeColor
subclass extension will override the system ones, providing theme-aware colors.
For example, a project defines a ThemeColor.brandColor
color. This will resolve to different colors at runtime, depending on the selected theme:
ThemeColor.brandColor
will resolve toNSColor.blue
if the light theme is selectedThemeColor.brandColor
will resolve toNSColor.white
if the dark theme is selectedThemeColor.brandColor
will resolve torgba(100, 50, 0, 0.5)
for some user-defined theme (UserTheme
)
Similarly, defining a ThemeColor.labelColor
will override NSColor.labelColor
(ThemeColor
is a subclass of NSColor
), and ThemeKit will allow labelColor
to be customized on a per-theme basis as well.
The NSColor Extension may be useful when overriding colors in ThemeColor extensions.
ThemeKit provides a simple fallback mechanism when looking up assets in the current theme. It will search for assets, in order:
- the asset name, defined in theme (e.g.,
myBackgroundColor
) fallbackForegroundColor
,fallbackBackgroundColor
,fallbackGradient
orfallbackImage
defined in theme, depending if asset is a foreground/background color, gradient or image, respectivelydefaultFallbackForegroundColor
,defaultFallbackBackgroundColor
,fallbackGradient
ordefaultFallbackImage
defined internally, depending if asset is a foreground/background color, gradient or image, respectively
However, for overridden system named colors, the fallback mechanism is different and simpler:
- the asset name, defined in theme (e.g.,
labelColor
) - original asset defined in the system (e.g.,
NSColor.labelColor
)
Please refer to ThemeColor
, ThemeGradient
and ThemeImage
for more information.
For creating additional themes, you only need to create a class that conforms to the Theme
protocol and extends NSObject
.
Sample theme:
import Cocoa
import ThemeKit
class MyOwnTheme: NSObject, Theme {
/// Light theme identifier (static).
public static var identifier: String = "com.luckymarmot.ThemeKit.MyOwnTheme"
/// Unique theme identifier.
public var identifier: String = MyOwnTheme.identifier
/// Theme display name.
public var displayName: String = "My Own Theme"
/// Theme short display name.
public var shortDisplayName: String = "My Own"
/// Is this a dark theme?
public var isDarkTheme: Bool = false
/// Description (optional).
public override var description : String {
return "<\(MyOwnTheme.self): \(themeDescription(self))>"
}
// MARK: -
// MARK: Theme Assets
// Here you can define the instance methods for the class methods defined
// on `ThemeColor`, `ThemeGradient` and `ThemeImage`, if any. Check
// documentation of these classes for more details.
}
ThemeKit also supports definition of additional themes with simple text files (.theme
files). Example of a very basic .theme
file:
// ************************* Theme Info ************************* //
displayName = My Theme 1
identifier = com.luckymarmot.ThemeKit.MyTheme1
darkTheme = true
// ********************* Colors & Gradients ********************* //
# define color for `ThemeColor.brandColor`
brandColor = $blue
# define a new color for `NSColor.labelColor` (overriding)
labelColor = rgb(11, 220, 111)
# define gradient for `ThemeGradient.brandGradient`
brandGradient = linear-gradient($orange.sky, rgba(200, 140, 60, 1.0))
// ********************* Images & Patterns ********************** //
# define pattern image from named image "paper" for color `ThemeColor.contentBackgroundColor`
contentBackgroundColor = pattern(named:paper)
# define pattern image from filesystem (relative to user themes folder) for color `ThemeColor.bottomBackgroundColor`
bottomBackgroundColor = pattern(file:../some/path/some-file.png)
# define image using named image "apple"
namedImage = image(named:apple)
# define image using from filesystem (relative to user themes folder)
fileImage = image(file:../some/path/some-file.jpg)
// *********************** Common Colors ************************ //
blue = rgb(0, 170, 255)
orange.sky = rgb(160, 90, 45, .5)
// ********************** Fallback Assets *********************** //
fallbackForegroundColor = rgb(255, 10, 90, 1.0)
fallbackBackgroundColor = rgb(255, 200, 190)
fallbackGradient = linear-gradient($blue, rgba(200, 140, 60, 1.0))
To enable support for user themes, just need to set the location for them:
// Setup ThemeKit user themes folder
ThemeManager.shared.userThemesFolderURL = //...
Please refer to UserTheme
for more information.
Documentation can be found here. You can also install it on Dash.
Yes - please check one way to do it on the Demo project. Basically, a TitleBarOverlayView view is added below the window title bar, as shown on the WindowController controller.
Other than the colors set by the inherited appearance - light (dark text on light background) or dark (light text on dark background) - natively, it is not possible to specify different colors for the text and/or background fills of controls (buttons, popups, etc).
For simple cases, overriding NSColor
can be sufficient: for example, NSColor.labelColor
is a named color used for text labels; overriding it will allow to have all labels themed accordingly. You can get a list of all overridable named colors (class method names) with NSColor.colorMethodNames()
.
For more complex cases, like views/controls with custom drawing, please refer to next question.
Yes, you can! Implement your own custom controls drawing using Theme-aware Assets (ThemeColor
and ThemeGradient
) so that your controls drawing will always adapt to your current theme... automatically!
In case needed (for example, if drawing is being cached), you can observe when theme changes to refresh the UI or to perform any theme related operation. Check "Observing theme changes" on Usage section above.
Please check this issue. This theming issue only affects view-based NSTableView
s, when placed on sheets, on macOS < 10.14. Please check this comment for a brief explanation on how to fix it, and a small project demonstrating the issue and the fix.
If the user opts for always showing the scrollbars on System Preferences, scrollbars may render all white on dark themes. To bypass this, we need to observe for theme changes and change its background color directly. E.g.,
scrollView?.backgroundColor = ThemeColor.myBackgroundColor
scrollView?.wantsLayer = true
NotificationCenter.default.addObserver(forName: .didChangeTheme, object: nil, queue: nil) { (note) in
scrollView?.verticalScroller?.layer?.backgroundColor = ThemeColor.myBackgroundColor.cgColor
}
You may run into font smoothing issues when you use text without a background color set. Bottom line is, always specify/draw a background when using/drawing text.
-
For controls like
NSTextField
,NSTextView
, etc:Specify a background color on the control. E.g.,
control.backgroundColor = NSColor.black
-
For custom text rendering:
First draw a background fill, then enable font smoothing and render your text. E.g.,
let context = NSGraphicsContext.current()?.cgContext NSColor.black.set() context?.fill(frame) context?.saveGState() context?.setShouldSmoothFonts(true) // draw text... context?.restoreGState()
As a last solution - if you really can't draw a background color - you can disable font smoothing which can slightly improve text rendering:
let context = NSGraphicsContext.current()?.cgContext context?.saveGState() context?.setShouldSmoothFonts(false) // draw text... context?.restoreGState()
-
For custom
NSButton
's:This is more tricky, as you will need to override private methods. If you are distributing your app on the Mac App Store, you must first check if this is allowed.
a) override the private method
_backgroundColorForFontSmoothing
to return your button background color.b) if (a) isn't sufficient, you will also need to override
_textAttributes
and change the dictionary returned from thesuper
call to provide your background color for the keyNSBackgroundColorAttributeName
.
ThemeKit is available under the MIT license. See the LICENSE file for more info.