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)
    }
}
  1. 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.
  2. 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.
  3. 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."
  4. Add a Rotation Animation: To make the logo rotate continuously, we create a CABasicAnimation on the transform.rotation.z key path.
  5. Adding this animation to logoLayer starts the rotation.
  6. 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:

  1. Quit System Preferences.
  2. In Activity Monitor, search for "legacy" and delete relevant entries.
  3. Build Xcode screensaver project.
  4. Open Derived Data and install new .saver file.
  5. 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:

Actual frame rate is 60FPS. The .gif lies.

The source code for this project is available here:

GitHub - aryamansharda/RotatingLogoScreensaverDemo
Contribute to aryamansharda/RotatingLogoScreensaverDemo development by creating an account on GitHub.
GitHub - aryamansharda/DVDScreensaverScreensaver
Contribute to aryamansharda/DVDScreensaverScreensaver development by creating an account on GitHub.

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.

Ace The iOS Interview
The best investment for landing your dream iOS jobHey there! My name is Aryaman Sharda and I started making iOS apps way back in 2015. Since then, I’ve worked for a variety of companies like Porsche, Turo, and Scoop Technologies just to name a few. Over the years, I’ve mentored junior engineers, bui…
Indie Watch
Indie Watch is an exclusive weekly hand-curated newsletter showcasing the best iOS, macOS, watchOS, and tvOS apps from developers worldwide.
Universal Link & Apple App Site Association Testing Tool
Easily verify and test Universal GetUniversal.link is a free tool for verifying and testing Apple App Site Association (AASA) files. Ensure your Universal Links are configured correctly with easy link creation, real-time testing, and team collaboration features. Save the website as a bookmark for quick access on devices and simulators. Simplify your AASA file troubleshooting today!