Saturday, August 25, 2018

MacOS Screensavers Aren't That Hard

I recently had the chance to play around with a Macbook Pro and XCode 10. I'm not used to using these computers, but I do really like them. I've never been a big fan of the iPhone, but I've always loved Apple's beautiful, if underpowered computers. I wanted to try to make something relatively easy, so I figured I'd start with a screen saver. It wasn't as easy as I thought, but it certainly wasn't hard, either.

The goal I had in mind was to make a screen saver that used an Apple Map to orbit well-known buildings. Apple Maps has some pretty good looking flyover maps, so I figured I'd take advantage of this and make a Screen Saver out of this.

My first attempt was in Swift. Swift is the future of all Apple development, so I wanted to give it a shot. Unfortunately, the boilerplate code provided by XCode 10 for screen savers is written in Objective-C, so I converted it to Swift. The program compiled just fine but when I tried to run the screen saver, it said it was incompatible with my version of MacOS.

This could have been for two reasons. The first could have been the bundling issue described here. Basically, the Swift compiler will bundle libraries with the executable that should be loaded but instead the runtime tries to use other libraries. The fix for that is to just run the screen saver or reload the System Preferences window, but that didn't work. So that lead me to believe that I wasn't compiling my project correctly. I was not equipped to handle that kind of build issue, so I decided to go back to Objective-C. Yes, I know that was dumb, but it wasn't as bad as you think.

As evidenced by the commit log of this project's repo, I took steps to get to where I needed to go. First was to set up the project. That involved telling the builder to link the Screen Saver libraries and MapKit to the executable. Next, I got a default world map to display by adding an MKMapView to as a subview to the ScreenSaverView I was working with. By simply passing in the frame of its parent view (aka self), I was able to immediately take up the full screen. That was good.

Next I wanted to start orbiting a subject. I chose the Eiffel Tower in Paris for a good scale of elevation and pitch for the camera. Basically, every animation frame, I change the heading of the camera. Because the center point of the camera stays fixed on the coordinates given, I don't have to point it myself, it just does it.

After that, I wanted to make the screen saver fade in and out and change the map locations when it did. You can see in the code that there are several references to a fade state. This was controlling the opacity of whatever was responsible for the fading. It turns out that this was a more interesting problem than imagined.

NSViews have an alphaValue property on them. I figured since the default draw behavior of ScreenSaverView was to draw a black rectangle, I could get away with just tweening that value. It turns out that's not the case. When I change that value, it just rerenders over what was there before. In the case of the first fade in, you could see the desktop. It also didn't fade through black between landmarks like I wanted it to. So this was not the way to go.

I figured I could make a BlackView that only drew a black rectangle over its frame. I added both of these as subviews to the ScreenSaverView. However, the MKMapView was keeping every other view from rendering (probably because it's using OpenGL or Metal or something like that). Regardless of the order, the map was always on top.

The solution was interesting: you had to make the BlackView a subclass of the MKMapView for it to work. I'm unsure as to why this magically overrode the rendering behavior of the map, but it worked. I just faded that in and out whenever I wanted to make a transition as it behaved well with its alphaValue value. After that it was just a matter of adding more landmarks.

To give a brief overview of how this works: a timer is set for 15 seconds, after which the fade state is set to fade out. The BlackView is made opaque. A request is made to Core Location's geocoder to get the next location from a random string. Once it resolves to a location, the callback sets the fade state to fade in. The render loop updates the camera position automatically so you just need to set the class variable.

Like most things I do here, this was a proof of concept. There are two big features missing here. First: the landmarks are hardcoded into the code. I'd like to make a configuration panel that allows the user to add locations to the rotation. Again, this uses geocoding to find locations (Core Location's geocoding sucks, by the way) so you can give it strings and it will figure it out (most of the time).

The second feature this is missing is the ability to behave on multiple monitors. The way I understand it, to make it work I'd have to keep an array of everything (array of MapViews, MapCameras, BlackViews, etc.) because I think the each method is invoked for each screen. So you'd need to maintain and update multiple instances for multiple displays.

But that's about all there is to it. Clone and compile the repo and see how easy it was to make a dumb little screen saver. I appreciate how easy this was and how much I learned about Cocoa development and Objective-C. It's been a while and I was rusty to the point of not knowing anything.

No comments:

Post a Comment