Let a million screensavers bloom
Writing screensavers in Swift for fun and … profit?

Tags: tech

This is nominally a “travel blog”, or something, but I also write code, and sometimes screensavers, so I thought I’d try putting this out there to see how it goes. Let me know what you think! mail@tolar.town or on twitter @tolar.

Writing screensavers for macs is a little weird. But once you get past a few oddities, it can be lots of fun!

For purposes of this post, I assume you have some knowledge of and experience with Xcode and Swift. We’ll build a simple screensaver that spins a green square.

If you want to go ahead and download the complete source, you can find it on GitHub.

Setup your project

Let’s start with your Xcode project setup before we dive in to code: File -> New project. In the project template selection screen, select macOS -> Screen Saver.

Project selection Project selection

Continue with setup until you have a project. Xcode will generate an Objective-c subclass of ScreenSaverView for you, but that’s no fun. Swift is the new hot thing, right? Go ahead and delete those files (***View.h and ***View.m). Create a new Swift file. Here we call it GreenSquareScreenSaverView, but you can name it as you please. (Note: if you change the name of the class, be sure to also update it in your Info.plist “Principal class” key.) You can use this stub code:

import Foundation
import ScreenSaver

class GreenSquareScreenSaverView: ScreenSaverView {
    override init?(frame: CGRect, isPreview: Bool) {
        super.init(frame: frame, isPreview: isPreview)
        self.animationTimeInterval = 1 / 30.0
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func startAnimation() {
        super.startAnimation()
    }
    
    override func stopAnimation() {
        super.stopAnimation()
    }
    
    override func draw(_ rect: NSRect) {
        super.draw(rect)
    }
    
    override func animateOneFrame() {
    }    
}

Congrats, you have a functional screensaver! It might not do much apart from showing a blank screen, but it’ll build.

Install your screensaver

One major difference between screensavers and apps is that you can’t simply “Run” from Xcode. To install the screensaver:

  1. Build from Xcode.
  2. Find the screensaver file under “Products”.
  3. Right-click on it & select “Open with External Editor.”
  4. This will open System Preferences and install the screensaver. (The first time, you’ll have to select the screensaver here).

Install the screensaver Install the screensaver

Add a mac app target to make testing easier

Troubleshooting screensavers is a pain in the butt. I highly recommend doing the bulk of your development using a mac app target. Then make sure to test it in screensaver mode, as this is treated a bit differently by macOS.

First, add a new target:

  1. Select your project on the left.
  2. Under “Targets”, at the bottom select the “+” button.
  3. Under “macOS”, select “App”.
  4. Continue with the prompts. I suggest naming it something like “Preview”.

Setup the Mac target

Hook up your mac app to the screensaver code you have. We need to expose the screensaver view class (and any other code you have written or will write):

  1. Select the file(s) on the left.
  2. On the right, select the File Inspector icon
  3. Under “Target Membership”, make sure your screensaver & mac app are selected.

Fix target membership Fix target membership

You’ll need to fake the animation in the mac app. Here is some stub code for a view controller (this should only have the mac app as a target):

import AppKit

class PreviewViewController: NSViewController {
    var screensaverView: GreenSquareScreenSaverView? = nil
    
    var timer: Timer? = nil
    
    var isAnimating: Bool = false {
        didSet {
            toggleAnimationTimer()
        }
    }
    
    override func loadView() {
        screensaverView = GreenSquareScreenSaverView(frame: CGRect.zero, isPreview: true)
        self.view = screensaverView ?? NSView()
    }
    
    override func viewDidAppear() {
        super.viewDidAppear()
        
        isAnimating = true
    }
    
    override func viewWillDisappear() {
        super.viewWillDisappear()
        
        isAnimating = false
    }
    
    private func toggleAnimationTimer() {
        if isAnimating {
            if timer == nil {
                timer = Timer.scheduledTimer(withTimeInterval: 1/30.0, repeats: true) { [weak self] (_) in
                    self?.animate()
                }
            }
        } else {
            if let timer = timer {
                timer.invalidate()
                self.timer = nil
            }
        }
    }
    
    func animate() {
        if isAnimating, let screensaverView = screensaverView {
            screensaverView.animateOneFrame()
        }
    }
}

Then in your AppDelegate, you can instantiate your PreviewViewController and pass it to the window:

import Cocoa

@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {

    var window: NSWindow!
    var previewViewController: PreviewViewController?

    func applicationDidFinishLaunching(_ aNotification: Notification) {
        let previewViewController = PreviewViewController()
        self.previewViewController = previewViewController

        // Create the window and set the content view. 
        window = NSWindow(
            contentRect: NSRect(x: 0, y: 0, width: 800, height: 600),
            styleMask: [.titled, .closable, .miniaturizable, .resizable, .fullSizeContentView],
            backing: .buffered, defer: false)
        window.center()
        window.setFrameAutosaveName("Main Window")
        window.contentView = previewViewController.view
        window.makeKeyAndOrderFront(nil)
    }
}

Now if you build & run the Preview mac app, you’ll have your screensaver running inside the app, and tied to Xcode for debugging, stepping through code, troubleshooting, etc.

You should see a single window with a black background color.

Write code!

If you want to eventually port your code to iOS or tvOS, you may want to stick with something like layers (CALayer) that are easy to use cross-platform. But if you’re focused on screensavers, AppKit is the mac corollary of UIKit, which you might be familiar with if you’ve built iOS apps. NSView and NSViewController are your friends!

For now, let’s add a little green square and rotate it, so you don’t have to stare at a blank screensaver. Create a new Swift file GreenSquareView:

class GreenSquareView: NSView {
    init() {
        super.init(frame: .zero)
        wantsLayer = true
        layer?.backgroundColor = NSColor.green.cgColor
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }    
}

Let’s update a few things in your ScreenSaverView subclass. Create a property:

let greenSquareView = GreenSquareView()

Then, in the init(), add:

addSubview(greenSquareView)

We’ll resize the frame in draw():

    override func draw(_ rect: NSRect) {
        super.draw(rect)
        
        var squareFrame = NSRect.zero
        squareFrame.size = NSSize(width: 150, height: 150)
        squareFrame.origin.x = (rect.width - squareFrame.width) / 2.0
        squareFrame.origin.y = (rect.height - squareFrame.height) / 2.0
        greenSquareView.frame = squareFrame
    }

And we can animate it in the stub animateOneFrame():

    override func animateOneFrame() {
        greenSquareView.rotate(byDegrees: 1)
    }

So if we put that together, our GreenSquareScreenSaverView now looks like:

import Foundation
import ScreenSaver

class GreenSquareScreenSaverView: ScreenSaverView {
    let greenSquareView = GreenSquareView()
    
    override init?(frame: CGRect, isPreview: Bool) {
        super.init(frame: frame, isPreview: isPreview)
        self.animationTimeInterval = 1 / 30.0
        addSubview(greenSquareView)
    }
    
    required init?(coder: NSCoder) {
        fatalError("init(coder:) has not been implemented")
    }
    
    override func startAnimation() {
        super.startAnimation()
    }
    
    override func stopAnimation() {
        super.stopAnimation()
    }
    
    override func draw(_ rect: NSRect) {
        super.draw(rect)
        
        var squareFrame = NSRect.zero
        squareFrame.size = NSSize(width: 150, height: 150)
        squareFrame.origin.x = (rect.width - squareFrame.width) / 2.0
        squareFrame.origin.y = (rect.height - squareFrame.height) / 2.0
        greenSquareView.frame = squareFrame
    }
    
    override func animateOneFrame() {
        greenSquareView.rotate(byDegrees: 1)
    }
}

Animation

The ScreenSaverView class offers you a straightforward way to animate, if what you want to do involves regular updates, like a rotating green square. But that should not restrict you from doing your own CoreAnimation or NSAnimation.

Swift classes: beware, beware, be aware

There’s a fun little bug where sometimes when you create a Swift class, macOS will try to instantiate it as a ScreenSaverView. Your screensaver will quietly crash, but you’ll see a black screen. You can dive into the Console.app, where you might find that it’s trying to call: -[GreenSquare.GreenSquareView initWithFrame:isPreview:]: unrecognized selector sent to instance 0x7fd13af16950

Fortunately, there’s an easy fix!

Back in Xcode, select your project on the left, then your screensaver target, then “Build Phases”. Under “Compile Sources”, ensure that your ScreenSaverView subclass is the first Swift file.

Fix Build Phases Fix Build Phases

Code!

The code mentioned here is over on github.com/rhaining/screensaver-demo.

App preview App preview

What else?

What else do you want to learn? Hit me up: mail@tolar.town or on twitter @tolar.

Other screensavers

I’ve published a couple of screensavers this year, if you want to check them out.

Check them out and let me know what you think!