A Swift Transition From iOS To macOS Development
Today started just like any other day. You sat down at your desk, took a sip of coffee and opened up Xcode to start a new project. But wait! The similarities stop there. Today, we will try to build for a different platform! Don’t be afraid. I know you are comfortable there on your iOS island, knocking out iOS applications, but today begins a brand new adventure. Today is the day we head on over to macOS development, a dark and scary place that you know nothing about. The good news is that developing for macOS using Swift has a lot more in common with iOS development than you realize. To prove this, I will walk you through building a simple screen-annotation application. Once we complete it, you will realize how easy it is to build applications for macOS.
The Concept
The idea comes from two unlikely sources. The first source is my boss, Doug Cook. He came over and asked if I knew how to make a circular floating app on macOS for prototyping purposes. Having never really done anything on macOS, I started to do some digging. After a little digging, I found Apple’s little gem of RoundTransparentWindow. Sure, it was in Objective-C, and it was pre-ARC to boot, but after reading through the code, I saw that figuring out how to do it in Swift wasn’t very difficult. The second source was pure laziness. I recently picked up a side project making tutorial videos on YouTube. I wanted to be able to describe what I was saying by drawing directly on the screen, without any post-production.
I decided to build a macOS app to draw on the computer screen:
OK, it doesn’t look like much — and, honestly, it shouldn’t because I haven’t drawn anything. If you look closely at the image above, you will see a little pencil icon in the upper-right bar. This area of macOS contains items called “menu extras.” By clicking the pencil icon, you will enable drawing on screen, and then you can draw something like this below!
I wanted drawing on the screen to be enabled at all times, but not to take over the screen when not in use. I preferred that it not live in the dock, nor change the contents of macOS’ menu bar. I knew that it was possible because I’d seen it in other apps. So, over lunch one day, I decided to take a crack at building this drawing tool, and here we are! This is what we will build in this tutorial.
The Soapbox
At this point, you might be saying to yourself, “Why build this? It’s just a simple drawing app!” But that isn’t the point. If you are anything like me, you are a little intimidated by the thought of making a macOS app. Don’t be. If you program for your iPhone on your MacBook, shouldn’t you also be able to program for your MacBook on your MacBook? What if Apple actually does merge iOS and macOS? Should you be left behind because you were intimidated by macOS development? ChromeOS already supports Android builds. How long before macOS supports iOS builds? There are differences between Cocoa and UIKit, which will become apparent, but this tutorial will get your feet wet and (hopefully) challenge you to build something bigger and better.
Caveats And Requirements
There are some caveats to this project that I want to get out of the way before we start. We will be making an app that draws over the entire screen. This will work on only one screen (for now) and will not work as is over full-screen apps. For our purpose, this is enough, and it leaves open room for plenty of enhancements in future iterations of the project. In fact, if you have any ideas for enhancements of your own, please leave them in the comments at the bottom!
For this tutorial, you’ll need a basic understanding of Swift development and familiarity with storyboards. We will also be doing this in Xcode 9 because that is the latest and greatest version at the time of writing.
1. Begin A macOS Project
Open Xcode and create a new macOS project. At this point, you are probably still in the “iOS” style project. We have to update this so that we are building for the correct system. Hit the “macOS” icon at the top, and then make sure that “Cocoa App” is selected. Hit “Next.”
Enter in the product name as you normally would. We will call this one ScreenAnnotation
, and then do the normal dance with “organization name” and “team” that you’d normally do. Make sure to select Swift as the language, and again hit “Next.” After saving it in the directory of your choosing, you will have your very own macOS app. Congratulations!
At first glance, you will see most everything you get in an iOS app. The only differences you might notice right now are the entitlements file, Cocoa in place of UIKit in each of the .swift
files, and (naturally) the contents of the storyboard.
2. Clean Up
Our next step is to go into the storyboard and delete anything we don’t need. First, let’s get rid of the menu; because our app will live as a menu extra, instead of as a dock app, it is unnecessary. Beneath where the menu existed is the window. We need to subclass this and use it to determine whether we are drawing within our app or toying with windows beneath it. Looking below the window, we can also see the familiar view controller. We already have a subclass of this, provided by Xcode. It is aptly named ViewController
because it is a subclass of NSViewController
, but we still need a NSWindow
subclass, so that we can decorate it as a clear window. Head over to the left pane, right-click, select “New file,” then select “Swift” file. When prompted, name this ClearWindow
. Replace the one line with the following:
import Cocoa
class ClearWindow : NSWindow {
override init(contentRect: NSRect, styleMask style: NSWindow.StyleMask, backing backingStoreType: NSWindow.BackingStoreType, defer flag: Bool) {
super.init(contentRect: contentRect, styleMask: StyleMask.borderless, backing: backingStoreType, defer: flag)
level = NSWindow.Level.statusBar
backgroundColor = NSColor.blue
}
override func mouseDown(with event: NSEvent) {
print("Mouse down: \(event.locationInWindow)")
}
override func mouseDragged(with event: NSEvent) {
print("Mouse dragged: \(event.locationInWindow)")
}
override func mouseUp(with event: NSEvent) {
print("Mouse up: \(event.locationInWindow)")
}
}
In this code snippet, we are importing Cocoa, which is to macOS as UIKit is to iOS development. This is the main API we will use to control our app. After importing Cocoa, we subclass NSWindow, and then we update our super-call in the init
method. In here, we keep the same contentRect
but will modify this later. We change the styleMask
to borderless
, which removes the standard application options: close, minimize and maximize. It also removes the top bar on the window. You can also do this in the storyboard file, but we are doing it here to show what it would look like to do it programmatically. Next, we pass the other variables right on through to the constructor. Now that we have that out of the way, we need to tell our window where to draw. We will set the window level to NSStatusWindowLevel
because it will draw above all other normal windows.
3. Our First Test
We are using NSResponder methods in the same way that we’d use the UIResponder
method to respond to touches on iOS. On macOS, we are interested in mouse events. Later on, we will be using these methods to draw in our ViewController
.
Finally, we’ll change the color of our view to blue, just to make sure things are running smoothly; if we went straight to a transparent view, we wouldn’t know where the window was drawn yet! Next, we need to set up the storyboard to use our new ClearWindow
class, even though it isn’t living up to its name yet. Go back to the storyboard, click the window, and edit its subclass under the “Custom Class” area in the right pane. Type in ClearWindow
here, and we can now run our app.
Lo and behold, we have a blue rectangle on our screen! Nothing impressive, yet. We can click and drag around, and we can spam the console. Let’s stop running the app at this point because it will only get in the way.
4. Let’s Start Drawing!
Next, we can update our implementation of ViewController
. The bulk of our work will now happen here and in Main.storyboard
. Right now, the important part is to piggyback on the methods that we created in ClearWindow
to capture mouse gestures. Replace the contents of ViewController
with the following code:
import Cocoa
class ViewController: NSViewController {
override func viewDidLoad() {
super.viewDidLoad()
view.frame = CGRect(origin: CGPoint(), size: NSScreen.main!.visibleFrame.size)
}
func startDrawing(at point: NSPoint) {
}
func continueDrawing(at point: NSPoint) {
}
func endDrawing(at point: NSPoint) {
}
}
This does not look like much yet, but it is the foundation of our drawing code. In our modified viewDidLoad()
method, we’ve resized the view to equal our main screen’s dimension. We do this because we can only draw within our NSViewController
. If our view covered everything, then we’d be able to draw over anything! Finally, we’ve created hooks that, for ClearWindow
, will call for the ViewController
to draw. More on that in a bit.
The next thing we need to do is define how we will draw onto the screen. Add the following code above our viewDidLoad
method.
let lineWeight: CGFloat = 10
let strokeColor: NSColor = .red
var currentPath: NSBezierPath?
var currentShape: CAShapeLayer?
These four variables define our drawing. Currently, our line thickness is 10 points, and we will be drawing in red — nothing revolutionary here, but that needs to be defined. Next, we have a NSBezierPath
and a CAShapeLayer
. These should look pretty familiar if you have ever played with UIBezierPath. Note that these two are optional (they will come up again later).
Now for the fun part: We can start implementing our drawing methods.
Start Drawing
Update startDrawing
with the following code:
func startDrawing(at point: NSPoint) {
currentPath = NSBezierPath()
currentShape = CAShapeLayer()
currentShape?.lineWidth = lineWeight
currentShape?.strokeColor = strokeColor.cgColor
currentShape?.fillColor = NSColor.clear.cgColor
currentShape?.lineJoin = kCALineJoinRound
currentShape?.lineCap = kCALineCapRound
currentPath?.move(to: point)
currentPath?.line(to: point)
currentShape?.path = currentPath?.cgPath
view.layer?.addSublayer(currentShape!)
}
This is the most complicated of the three drawing methods. The reason for this is that we need to set up a new NSBezierPath
and CAShapeLayer
each time we start drawing. This is important because we don’t want to have one continuous line all over our screen — that wouldn’t do at all. This way, we can have one layer per line, and we will be able to make any kind of drawing we want. Then, we set up the newly created CAShapeLayer
’s properties. We send in our lineWeight
and then the stroke color to our nice red color. We set the fill color to clear, which means we will only be drawing with lines, instead of solid shapes. Then, we set the lineJoin
and lineCap
to use rounded edges. I chose this because the rounded edges make the drawing look nicer in my opinion. Feel free to play with these properties to figure out what works best for you.
Then, we move the point where we will start drawing to the NSPoint
that will be sent to us. This will not draw anything, but it will give the UIBezierPath
a reference point for when we actually give it instructions to draw. Think of it as if you had a pen in your hand and you decided to draw something in the middle of a sheet of paper. You move the pen to the location you want to draw, but you’re not doing anything yet, just hovering over the paper, waiting to put the ink down. Without this, nothing can be drawn because the next line requires two points to work. The next line, aptly named line(to: point)
, draws a line from the current position to wherever you specify. Currently, we’re telling our UIBezierPath
to stay in the same position and touch down on our sheet of paper.
The last two lines pull the path data out of our UIBezierPath
in a usable format for CAShapeLayer
. Note that currentPath?.cgPath
will be marked as an error at the moment. Don’t fret: We will take care of that after we cover the next two methods. Just know that when it does work, this function will have our CAShapeLayer
draw its path, even if it is currently a dot. Then, we add this layer to our view’s sublayer. At this point, the user will be able to see that they are now drawing.
Continue Drawing
Update continueDrawing
with the following code:
func continueDrawing(at point: NSPoint) {
currentPath?.line(to: point)
if let shape = currentShape {
shape.path = currentPath?.cgPath
}
}
Not much going on here, but we are adding another line to our currentPath
. Because the CAShapeLayer
is already in our view’s sublayer, the update will show on screen. Again, note that these are optional values; we are guarding ourselves just in case they are nil.
End Drawing
Update endDrawing
with the following code:
func endDrawing(at point: NSPoint) {
currentPath?.line(to: point)
if let shape = currentShape {
shape.path = currentPath?.cgPath
}
currentPath = nil
currentShape = nil
}
We update the path again, just the same way as we did in continueDrawing
, but then we also nil out our currentPath
and currentShape
. We do this because we are done drawing and no longer need to talk to this shape and path. The next action we can take is startDrawing
again, and we start this process all over again. We nil out these values so that we cannot update them again; the view’s sublayer will still hold a reference to the line, and it will stay on screen until we remove it.
If we run the app with what we have, we’ll get errors! Almost forgot about that. One thing you will definitely notice when moving over to macOS development is that not every API between iOS and macOS are equivalent. One such issue here is that NSBezierPath
doesn’t have the handy cgPath
property that UIBezierPath
has. We use this property to easily convert the NSBezierPath
path to the CAShapeLayer
path. This way, CAShapeLayer
will do all the heavy lifting to display our line. We can sit back and reap the reward of a nice-looking line with none of the work! StackOverflow has a handy answer that I’ve used to handle this absence of cgPath
by updating it to Swift 4 (see below). This code lets us create an extension to NSBezierPath
and returns to us a handy CGPath
to play with. Create a file named NSBezierPath+CGPath.swift
and add the following code to it.
import Cocoa
extension NSBezierPath {
public var cgPath: CGPath {
let path = CGMutablePath()
var points = [CGPoint](repeating: .zero, count: 3)
for i in 0 ..< self.elementCount {
let type = self.element(at: i, associatedPoints: &points)
switch type {
case .moveToBezierPathElement:
path.move(to: points[0])
case .lineToBezierPathElement:
path.addLine(to: points[0])
case .curveToBezierPathElement:
path.addCurve(to: points[2], control1: points[0], control2: points[1])
case .closePathBezierPathElement:
path.closeSubpath()
}
}
return path
}
}
At this point, everything will run, but we aren’t drawing anything quite yet. We still need to attach the drawing functions to actual mouse actions. In order to do this, we go back into our ClearWindow
and update the NSResponder
mouse methods to the following:
override func mouseDown(with event: NSEvent) {
(contentViewController as? ViewController)?.startDrawing(at: event.locationInWindow)
}
override func mouseDragged(with event: NSEvent) {
(contentViewController as? ViewController)?.continueDrawing(at: event.locationInWindow)
}
override func mouseUp(with event: NSEvent) {
(contentViewController as? ViewController)?.endDrawing(at: event.locationInWindow)
}
This basically checks to see whether the current view controller is our instance of ViewController
, where we will handle the drawing logic. If you run the app now, you should see something like this:
This is not completely ideal, but we are currently able to draw on the blue portion of our screen. This means that our drawing logic is correct, but our layout is not. Before correcting our layout, let’s create a good way to quit, or disable, drawing on our app. If we make it full screen right now, we would either have to “force quit” or switch to another space to quit our app.
5. Create A Menu
Head back over to our Main.storyboard
file, where we can add a new menu to our ViewController
. In the right pane, drag “Menu” under the “View Controller Scene” in our storyboard’s hierarchy.
Edit these menu items to say “Clear,” “Toggle” and “Quit.” For extra panache, we can add a line separator above our “Exit” item, to deter accidental clicks:
Next, open up the “Assistant Editor” (the Venn diagram-looking button near the top right of Xcode), so that we can start hooking up our menu items. For both “Clear” and “Toggle,” we want to create a “Referencing Outlet” so that we can modify them. After this, we want to hook up “Sent Action” so that we can get a callback when the menu item is selected.
For “Exit,” we will drag our “Sent Action” to the first responder, and select “Terminate.” “Terminate” is a canned action that will quit the application. Finally, we need a reference to the menu itself; so, right-click on the menu under “View Controller Scene,” and create a reference named optionsMenu
. The newly added code in ViewController
should look like this:
@IBOutlet weak var clearButton: NSMenuItem!
@IBOutlet weak var toggleButton: NSMenuItem!
@IBOutlet var optionsMenu: NSMenu!
@IBAction func clearButtonClicked(_ sender: Any) {
}
@IBAction func toggleButtonClicked(_ sender: Any) {
}
We have the building blocks for the menu extras for our app; now we need to finish the process. Close out of “Assistant Editor” mode and head over to ViewController
so that we can make use of these menu buttons. First, we will add the following strings to drive the text of the toggle button. Add the following two lines near the top of the file.
private let offText = "Disable Drawing"
private let onText = "Enable Drawing"
We need to update what happens when the clear and toggle buttons are clicked. Add the following line to clear the drawing in clearButtonClicked(_ sender)
:
view.window!.ignoresMouseEvents = !view.window!.ignoresMouseEvents
toggleButton.title = view.window!.ignoresMouseEvents ? onText : offText
This toggles the flag on our window to ignore mouse events, so that we will click “through” our window, in order to use our computer as intended. We’ll also update the toggleButton
’s text to let the user know that drawing is either enabled or disabled.
Now we need to finally put our menu to use. Let’s start by adding an icon to our project. You can find that in the repository. Then, we can override the awakeFromNib()
method because, at this point, our view will be inflated from the storyboard. Add the following code to ViewController
.
let statusItem = NSStatusBar.system.statusItem(withLength: NSStatusItem.variableLength)
override func awakeFromNib() {
statusItem.menu = optionsMenu
let icon = NSImage(named: NSImage.Name(rawValue: "pencil"))
icon?.isTemplate = true // best for dark mode
statusItem.image = icon
toggleButton.title = offText
}
Make sure to put the statusItem
near the top, next to the rest of the variables. statusItem
grabs a spot for our app to use menu extras in the top toolbar. Then, in awakeFromNib
, we set the menu as our optionsMenu
, and, finally, we give it an icon, so that it is easily identifiable and clickable. If we run our app now, the “menu extra” icon will appear at the top! We aren’t quite finished yet. We need to ensure that the drawing space is placed correctly on screen; otherwise, it will be only partially useful.
6. Positioning The Drawing App
To get our view to draw where we want it, we must venture back into Main.storyboard
. Click on the window itself, and then select the attributes inspector, the icon fourth from the left in the right-hand pane. Uncheck everything under the “Appearance,” “Controls” and “Behavior” headings, like so:
We do this to remove all extra behaviors and appearances. We want a clear screen, with no bells and whistles. If we run the app again, we will be greeted by a familiar blue screen, only larger. To make things useful, we need to change the color from blue to transparent. Head back over to ClearWindow
and update the blue color to this:
backgroundColor = NSColor(calibratedRed: 1, green: 1, blue: 1, alpha: 0.001)
This is pretty much the same as NSColor.clear
. The reason why we are not using NSColor.clear
is that if our entire app is transparent, then macOS won’t think our app is visible, and no clicks will be captured in the app. We’ll go with a mostly transparent app — something that, at least to me, is not noticeable, yet our clicks will be recorded correctly.
The final thing to do is remove it from our dock. To do this, head over to Info.plist
and add a new row, named “Application is agent (UIElement)” and set it to “Yes.” Once this is done, rerun the app, and it will no longer appear in the dock!
Conclusion
This app is pretty short and sweet, but we did learn a few things. First, we figured out some of the similarities and differences between UIKit and Cocoa. We also took a tour around what Cocoa has in store for us iOS developers. We also (hopefully) know that creating a Cocoa-based app is not difficult, nor should it be intimidating. We now have a small app that is presented in an atypical fashion, and it lives up in the menu bar, next to the menu extras. Finally, we can use all of this stuff to build bigger and better Cocoa apps! You started this article as an iOS developer and grew beyond that, becoming an Apple developer. Congratulations!
I will be updating the public repository for this example to add extra options. Keep an eye on the repository, and feel free to add code, issues and comments.
Good luck and happy programming!
Further Reading
- Picture Perfect: Meet Pixo, A Photo Editor For Your End Users
- Accessible Target Sizes Cheatsheet
- Using CSCS Scripting Language For Cross-Platform Development
- A Better iOS Architecture: A Deep Look At The Model-View-Controller Pattern