Creating a macOS Screensaver in SwiftUI
A few months ago, I wrote an article titled Recreating The DVD Screensaver In SwiftUI. In this post, we'll take it one step further and we'll turn it into a macOS screensaver.
To get started, create a new Xcode project and select Screen Saver:
The new project comes with some starter code, but it's in Objective C:
Fortunately, we can just delete those classes and replace them with their Swift equivalent. No need to create a bridging header either.
Next, we'll need to go into our Project Settings and update the Principal class
to match the name of the Swift class we're replacing the starting Objective-C code with. We'll need to provide some namespace information here as well, so make sure to update this value to be in the format of PROJECT_NAME.SWIFT_CLASS_NAME
:
Now, creating a macOS screensaver is largely undocumented and riddled with bugs in macOS Sonoma, so implementing this custom screensaver was a bit trickier than expected.
We'll start by making a basic vanilla screensaver to get a feel for the main functions and steps involved and then we’ll replace it up with our SwiftUI version.
Building A Basic Screensaver
In order to create our custom screensaver, we'll start by subclassing ScreenSaverView
.
import Foundation
import ScreenSaver
class RotatingLogoScreensaver: ScreenSaverView {
}
Now, we have our first decision to make. We can implement our screensaver relying entirely on CoreAnimation
or we can implement it by overriding specific functions on ScreenSaverView
.
Core Animation vs. Manual Rendering
If you’re creating smooth, continuous animations—like rotating, scaling, or moving an object—Core Animation is ideal. It’s highly optimized for these types of animations, running efficiently on the GPU without requiring continuous manual updates. Core Animation takes care of frame timing and updates automatically, making it easier to implement animations that need consistent refresh rates (e.g., 60fps) without worrying about defining how the screensaver should work from frame to frame. So, if your building something like a rotating logo, a pulsing effect, or continuous fade-in/out—simple effects that don’t require frame-by-frame custom drawing, I'd recommend you use CoreAnimation
directly.
Instead, if you need custom drawing that changes each frame (such as an animation involving path drawing, text changes, or data visualizations), draw(_:)
and animateOneFrame()
give you full control over the exact contents of each frame. And for screensavers where you want to control the exact update frequency and timing independently of the display refresh rate, animateOneFrame()
offers more flexibility in setting custom frame rates.
If you want to proceed with the manual rendering approach, you'll need to override the following methods:
init?(frame: NSRect, isPreview: Bool)
Creates a newly allocated screen saver view with the specified frame rectangle. isPreview
tells the system whether it should use this screensaver as the preview in System Settings.
func draw(NSRect)
The draw(_:)
method is for static rendering, allowing you to draw shapes, images, or text without continuous updates—ideal for screensavers without animation.
func animateOneFrame()
This is called repeatedly at the screensaver’s frame rate, making it perfect for animated elements where you’re updating positions, colors, or other properties over time. Since animateOneFrame()
can manage both updating and rendering animated content, you generally don’t need to use draw(_:)
alongside it, as they’re almost mutually exclusive in practice.
Implementation
Since we're after a simple rotating image screensaver, we can rely on CoreAnimation
directly.
import Foundation
import ScreenSaver
class RotatingLogoScreensaver: ScreenSaverView {
override init?(frame: NSRect, isPreview: Bool) {
super.init(frame: frame, isPreview: isPreview)
setupLayers()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
setupLayers()
}
private func setupLayers() {
// 1
wantsLayer = true // Enable layer-backed view for animations
// 2
layer = CALayer()
layer?.backgroundColor = NSColor.black.cgColor
// 3
let logoLayer = CALayer()
logoLayer.contents = Bundle(for: Self.self).image(forResource: "Logo")
// Scales the logo to 35% of the view's dimensions
let defaultLogoSize: CGFloat = 150.0
var logoDimension: CGFloat = defaultLogoSize
if let currentWidth = layer?.frame.width {
logoDimension = currentWidth * 0.35
}
logoLayer.frame = CGRect(x: 0, y: 0, width: logoDimension, height: logoDimension)
// Center the logoLayer in the view
logoLayer.position = CGPoint(x: bounds.midX, y: bounds.midY)
// Apply the rotation animation
let rotation = CABasicAnimation(keyPath: "transform.rotation.z")
rotation.fromValue = 0
rotation.toValue = CGFloat.pi * 2
rotation.duration = 4.0 // Adjust for rotation speed
rotation.repeatCount = .infinity // Repeat indefinitely
// 5
logoLayer.add(rotation, forKey: "rotate")
// 6
layer?.addSublayer(logoLayer)
}
}
- Enable Layer-Backed View:
wantsLayer = true
tells the view to use a Core Animation layer as its backing store. This allows us to add sublayers and apply animations directly to them. - Set the Background Layer: By setting
layer = CALayer()
, we create a new custom root layer for this view. Then,layer?.backgroundColor = NSColor.black.cgColor
fills the background with black, giving the screensaver a clean slate to work with. - Create the Logo Layer: The next step is to create a
logoLayer
to display the image we want to animate.logoLayer.contents
loads the image resource from the app bundle and assigns it to the layer, which acts as the logo’s "canvas." - Add a Rotation Animation: To make the logo rotate continuously, we create a
CABasicAnimation
on thetransform.rotation.z
key path. - Adding this animation to
logoLayer
starts the rotation. - Add
logoLayer
to the Main Layer: Finally,layer?.addSublayer(logoLayer)
attaches the logo layer (with its rotation animation) to the main view’s root layer, making it visible and active in the screensaver.
Testing
With our testing complete, we can now focus on testing our new screensaver. We can't "Run" our screensaver directly from Xcode, so we'll need to grab the Build Product from Derived Data.
Inside Derived Data, in your project's Products
folder, you'll find the new .saver
file which you can now double-click to install.
Next, you'll be asked to approve the installation of the screen saver; you'll only be asked this the first time you install it.
You should now see a preview of your screensaver in System Preferences:
Now, creating and testing custom screensavers in macOS Sonoma is a bit of a mess.
I ran into an issue where the preview in System Settings was showing the correct behavior, but when I ran the screensaver, I just saw a black screen.
According to this article, the issue is that the previous screensaver process isn't actually terminating; instead, it's just being reused. To get around this, I found that opening Activity Monitor and force-quitting any processes related to "legacyScreensaver" forced the system to recognize the updated version of the .saver
.
My recommended testing workflow would be to:
- Quit System Preferences.
- In Activity Monitor, search for "legacy" and delete relevant entries.
- Build Xcode screensaver project.
- Open Derived Data and install new
.saver
file. - Rinse and repeat.
I also noticed that sometimes the Derived Data build product wouldn't update even if the code did, so you may need to delete Derived Data and build again if you don't see the "Last Modified" timestamp change.
Adding SwiftUI
You can turn any SwiftUI View
into a screensaver. Simply implement your SwiftUI screen in your typical way and wrap it in a NSHostingController
.
We'll be borrowing the implementation from my Recreating The DVD Screensaver In SwiftUI article.
Note: The original implementation was for iOS, so I had to make some minor tweaks to make the implementation work for macOS.
class DVDScreensaverScreensaverView: ScreenSaverView {
override init?(frame: NSRect, isPreview: Bool) {
super.init(frame: frame, isPreview: isPreview)
// Enable layer-backed view for better rendering compatibility with SwiftUI
wantsLayer = true
let timeView = ContentView()
let hostingController = NSHostingController(rootView: timeView)
// Set frame directly to bounds and enable autoresizing
hostingController.view.frame = bounds
hostingController.view.autoresizingMask = [.width, .height]
addSubview(hostingController.view)
}
required init?(coder decoder: NSCoder) {
super.init(coder: decoder)
fatalError("Not implemented.")
}
}
That's all we need to do to make any SwiftUI experience available as a screensaver!
The rest of the process is the same; simply build the project and install the new .saver
file:
The source code for this project is available here:
In case you missed it, here's a recording of my talk at SwiftCraft earlier this year:
If you're interested in more articles about iOS Development & Swift, check out my YouTube channel or follow me on Twitter.
And, if you're an indie iOS developer, make sure to check out my newsletter! Each issue features a new indie developer, so feel free to submit your iOS apps.