I recently released my first MacOS app and after a lot of trial and error, discovered that there are not enough Swift for MacOS tutorials! Consider this my first contribution to the cause š.
Shameless plug: This tutorial came about from my work on Snipbar, a MacOS app Iāve been working on for my shell command app Snipline. If you work with shell commands and servers, or SQL then Iād love it if you checked it out.
By the end of this tutorial you will have
- Set up an app with two windows: a Main window and Preferences window
- Created a button from the Main window that links to the Preferences window.
- Installed and configured HotKey via Carthage.
- Set up a simple UI for configuring a global keyboard shortcut that opens your Main window.
Hereās a preview of how the app works
This tutorial uses Xcode 10.2 and Swift 5.
Creating the app windows
First things first, letās create a new Mac project in Xcode, I called mine GlobalConfigKeybind
. We need to make sure āMacOSā and āCocoa Appā are selected. In the second panel, make sure āUse Storyboardsā is selected.
With the app created we need to create the Preferences window. We can do this by going to Main.storyboard
, clicking the Library button, searching for Window View Controller
and then drag a new Window Controller
next to our Main window.
Linking the main app window to the preferences window
Letās create a button on the Main view controller and set it up so that when itās pressed it shows the preferences window.
Press the library button, search for Push Button
and drag it into the Main view controller.
Select the button and go to the Attributes inspector
. Change the title to say Preferences
.
Now we have the button but we need to make it do something. Hold ctrl
while click and dragging from the button to the new Preferences window. The Preferences window will become highlighted. Release the mouse and select the Show
action segue.
Now when clicked the button will open the Preferences window. Before we test it though, letās make sure that when the button is pressed more than once it only opens one window. Click on the Window and in the Attributes inspector
change Presentation
to Single
.
At this point, if we run the app and press the Preferences button the new window will show. Hooray!
Installing HotKey
HotKey is a Swift package that wraps around the Carbon API for dealing with global hot keys.
Weāll use Carthage to install it but If you prefer SPM or CocoaPods feel free to use that instead.
First, make sure you have Carthage installed, following their installation instructions if needed. Then in Xcode, create a new Empty
file and call it Cartfile
. Make sure itās in the base of the project, if youāve accidentally saved it in the wrong place, make sure to drag it below the GlobalConfigKeybind
area with the blue page icon.
Inside that file add the following and save it.
github "soffes/HotKey"
We need to install HotKey from the Terminal. To do this go to the project directory and run carthage update && carthage build --platform MacOS
.
Back in Xcode link the new HotKey binary to our app.
Click the GlobalConfigKeybind
with the blue page icon, select the app Target and click the +
icon under Embedded Binaries
. Click Add Other
and navigate to the root directory of your project, then go to Carthage
> Build
> Mac
> highlight HotKey.framework
and click Open
.
When prompted select Copy items if needed
.
Creating the Keybind options interface.
In the Main.storyboard
drag a Text Field
and a Push Button
onto the Preferences window. Give the button a title of Clear
, set the State
to disabled. For the text field check Refuses First Responder
in the attributes inspector. This is because we donāt want the text field to be selected when the window is opened.
Adding the key bind configuration functionality
We need to create three new Cocoa class files. Make sure that XIB is not being created.
PreferencesViewController
which needs to be a subclass ofNSViewController
.PreferencesWindowController
which needs to be a subclass ofNSWindowController
.MainWindow
which needs to be a subclass ofNSWindow
.
Once made, set the class for each in the Main.storyboard
. One for the Preferences Window and one for the Preferences View Controller.
The MainWindow
needs to be set on the first app window. This will be used later on when we need to target the window to bring to the front. Notice in the below screenshot that Window
is highlighted, not Window Controller
.
Inside of PreferencesWindowController
add the following code:
//
// PreferencesWindowController.swift
// GlobalConfigKeybind
//
// Created by Mitch Stanley on 27/01/2019.
//
import Cocoa
class PreferencesWindowController: NSWindowController {
override func windowDidLoad() {
super.windowDidLoad()
}
override func keyDown(with event: NSEvent) {
super.keyDown(with: event)
if let vc = self.contentViewController as? PreferencesViewController {
if vc.listening {
vc.updateGlobalShortcut(event)
}
}
}
}
The keyDown:with
method triggers any time a key is pressed while this window is active. We only want to do this when the configuration button is pressed so we use an if statement that checks the listening
state in the Preferences View Controller (Weāll go into more detail on this shortly).
Inside of PreferencesViewController
add this code.
//
// PreferencesWindowController.swift
// GlobalConfigKeybind
//
// Created by Mitch Stanley on 27/01/2019.
//
import Cocoa
import HotKey
import Carbon
class PreferencesViewController: NSViewController {
@IBOutlet weak var clearButton: NSButton!
@IBOutlet weak var shortcutButton: NSButton!
// When this boolean is true we will allow the user to set a new keybind.
// We'll also trigger the button to highlight blue so the user sees feedback and knows the button is now active.
var listening = false {
didSet {
if listening {
DispatchQueue.main.async { [weak self] in
self?.shortcutButton.highlight(true)
}
} else {
DispatchQueue.main.async { [weak self] in
self?.shortcutButton.highlight(false)
}
}
}
}
override func viewDidLoad() {
super.viewDidLoad()
// Check to see if the keybind has been stored previously
// If it has then update the UI with the below methods.
if Storage.fileExists("globalKeybind.json", in: .documents) {
let globalKeybinds = Storage.retrieve("globalKeybind.json", from: .documents, as: GlobalKeybindPreferences.self)
updateKeybindButton(globalKeybinds)
updateClearButton(globalKeybinds)
}
}
// When a shortcut has been pressed by the user, turn off listening so the window stops listening for keybinds
// Put the shortcut into a JSON friendly struct and save it to storage
// Update the shortcut button to show the new keybind
// Make the clear button enabled to users can remove the shortcut
// Finally, tell AppDelegate to start listening for the new keybind
func updateGlobalShortcut(_ event : NSEvent) {
self.listening = false
if let characters = event.charactersIgnoringModifiers {
let newGlobalKeybind = GlobalKeybindPreferences.init(
function: event.modifierFlags.contains(.function),
control: event.modifierFlags.contains(.control),
command: event.modifierFlags.contains(.command),
shift: event.modifierFlags.contains(.shift),
option: event.modifierFlags.contains(.option),
capsLock: event.modifierFlags.contains(.capsLock),
carbonFlags: event.modifierFlags.carbonFlags,
characters: characters,
keyCode: UInt32(event.keyCode)
)
Storage.store(newGlobalKeybind, to: .documents, as: "globalKeybind.json")
updateKeybindButton(newGlobalKeybind)
clearButton.isEnabled = true
let appDelegate = NSApplication.shared.delegate as! AppDelegate
appDelegate.hotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: UInt32(event.keyCode), carbonModifiers: event.modifierFlags.carbonFlags))
}
}
// When the set shortcut button is pressed start listening for the new shortcut
@IBAction func register(_ sender: Any) {
unregister(nil)
listening = true
view.window?.makeFirstResponder(nil)
}
// If the shortcut is cleared, clear the UI and tell AppDelegate to stop listening to the previous keybind.
@IBAction func unregister(_ sender: Any?) {
let appDelegate = NSApplication.shared.delegate as! AppDelegate
appDelegate.hotKey = nil
shortcutButton.title = ""
Storage.remove("globalKeybind.json", from: .documents)
}
// If a keybind is set, allow users to clear it by enabling the clear button.
func updateClearButton(_ globalKeybindPreference : GlobalKeybindPreferences?) {
if globalKeybindPreference != nil {
clearButton.isEnabled = true
} else {
clearButton.isEnabled = false
}
}
// Set the shortcut button to show the keys to press
func updateKeybindButton(_ globalKeybindPreference : GlobalKeybindPreferences) {
shortcutButton.title = globalKeybindPreference.description
}
}
Two properties and two methods need to be hooked up here.
register:sender
needs to be connected with the shortcut button.unregister:sender
needs to be connected to the clear shortcut button.clearButton
property needs to be connected to the clear shortcut button.shortcutButton
needs to be connected to the shortcut button.
This is quite long a long file but each method is commented. As a general overview, itās letting one button listen and update the app shortcut and another button will clear that shortcut.
Create a new file, GlobalKeybindPreferences.swift
. This will be a struct that holds the shortcut state. This includes modifiers and keys that are pressed. It also has a handy computed property called description
which is used in PreferencesViewController
to set the shortcut button text to look like āāK
.
//
// GlobalKeybindPreferences.swift
// GlobalConfigKeybind
//
// Created by Mitch Stanley on 07/04/2019.
//
struct GlobalKeybindPreferences: Codable, CustomStringConvertible {
let function : Bool
let control : Bool
let command : Bool
let shift : Bool
let option : Bool
let capsLock : Bool
let carbonFlags : UInt32
let characters : String?
let keyCode : UInt32
var description: String {
var stringBuilder = ""
if self.function {
stringBuilder += "Fn"
}
if self.control {
stringBuilder += "ā"
}
if self.option {
stringBuilder += "ā„"
}
if self.command {
stringBuilder += "ā"
}
if self.shift {
stringBuilder += "ā§"
}
if self.capsLock {
stringBuilder += "āŖ"
}
if let characters = self.characters {
stringBuilder += characters.uppercased()
}
return "\(stringBuilder)"
}
}
In AppDelegate.swift
we need to listen for the shortcut if it exists and pull the MainWindow
to the front.
We can see in applicationDidFinishLaunching:aNotification
we check if the globalKeybind.json file exists, if it does, set the HotKey
to what we have stored.
The computed property hotKey
checks if hotKey is not nil, and then adds a keyDownHandler
. In this closure, we loop through all the windows we have open (Itās possible that the Preferences window is also open, otherwise we could use first
). When the MainWindow
is found we bring it to the front with makeKeyAndOrderFront
and makeKey
.
//
// AppDelegate.swift
// GlobalConfigKeybind
//
// Created by Mitch Stanley on 07/04/2019.
//
import Cocoa
import HotKey
import Carbon
@NSApplicationMain
class AppDelegate: NSObject, NSApplicationDelegate {
func applicationDidFinishLaunching(_ aNotification: Notification) {
// Insert code here to initialize your application
if Storage.fileExists("globalKeybind.json", in: .documents) {
let globalKeybinds = Storage.retrieve("globalKeybind.json", from: .documents, as: GlobalKeybindPreferences.self)
hotKey = HotKey(keyCombo: KeyCombo(carbonKeyCode: globalKeybinds.keyCode, carbonModifiers: globalKeybinds.carbonFlags))
}
}
func applicationWillTerminate(_ aNotification: Notification) {
// Insert code here to tear down your application
}
public var hotKey: HotKey? {
didSet {
guard let hotKey = hotKey else {
return
}
hotKey.keyDownHandler = { [weak self] in
NSApplication.shared.orderedWindows.forEach({ (window) in
if let mainWindow = window as? MainWindow {
print("woo")
NSApplication.shared.activate(ignoringOtherApps: true)
mainWindow.makeKeyAndOrderFront(self)
mainWindow.makeKey()
}
})
}
}
}
}
Finally, the last piece of the puzzle, create a new file called Storage.swift
. Weāll use this awesome class created by Saoud M. Rizwan which can be found in more detail here. This class makes working with local JSON storage very simple and I encourage you to read the blog post to understand how it works.
import Foundation
public class Storage {
fileprivate init() { }
enum Directory {
// Only documents and other data that is user-generated, or that cannot otherwise be recreated by your application, should be stored in the <Application_Home>/Documents directory and will be automatically backed up by iCloud.
case documents
// Data that can be downloaded again or regenerated should be stored in the <Application_Home>/Library/Caches directory. Examples of files you should put in the Caches directory include database cache files and downloadable content, such as that used by magazine, newspaper, and map applications.
case caches
}
/// Returns URL constructed from specified directory
static fileprivate func getURL(for directory: Directory) -> URL {
var searchPathDirectory: FileManager.SearchPathDirectory
switch directory {
case .documents:
searchPathDirectory = .documentDirectory
case .caches:
searchPathDirectory = .cachesDirectory
}
if let url = FileManager.default.urls(for: searchPathDirectory, in: .userDomainMask).first {
return url
} else {
fatalError("Could not create URL for specified directory!")
}
}
/// Store an encodable struct to the specified directory on disk
///
/// - Parameters:
/// - object: the encodable struct to store
/// - directory: where to store the struct
/// - fileName: what to name the file where the struct data will be stored
static func store<T: Encodable>(_ object: T, to directory: Directory, as fileName: String) {
let url = getURL(for: directory).appendingPathComponent(fileName, isDirectory: false)
let encoder = JSONEncoder()
do {
let data = try encoder.encode(object)
if FileManager.default.fileExists(atPath: url.path) {
try FileManager.default.removeItem(at: url)
}
FileManager.default.createFile(atPath: url.path, contents: data, attributes: nil)
} catch {
fatalError(error.localizedDescription)
}
}
/// Retrieve and convert a struct from a file on disk
///
/// - Parameters:
/// - fileName: name of the file where struct data is stored
/// - directory: directory where struct data is stored
/// - type: struct type (i.e. Message.self)
/// - Returns: decoded struct model(s) of data
static func retrieve<T: Decodable>(_ fileName: String, from directory: Directory, as type: T.Type) -> T {
let url = getURL(for: directory).appendingPathComponent(fileName, isDirectory: false)
if !FileManager.default.fileExists(atPath: url.path) {
fatalError("File at path \(url.path) does not exist!")
}
if let data = FileManager.default.contents(atPath: url.path) {
let decoder = JSONDecoder()
do {
let model = try decoder.decode(type, from: data)
return model
} catch {
fatalError(error.localizedDescription)
}
} else {
fatalError("No data at \(url.path)!")
}
}
/// Remove all files at specified directory
static func clear(_ directory: Directory) {
let url = getURL(for: directory)
do {
let contents = try FileManager.default.contentsOfDirectory(at: url, includingPropertiesForKeys: nil, options: [])
for fileUrl in contents {
try FileManager.default.removeItem(at: fileUrl)
}
} catch {
fatalError(error.localizedDescription)
}
}
/// Remove specified file from specified directory
static func remove(_ fileName: String, from directory: Directory) {
let url = getURL(for: directory).appendingPathComponent(fileName, isDirectory: false)
if FileManager.default.fileExists(atPath: url.path) {
do {
try FileManager.default.removeItem(at: url)
} catch {
fatalError(error.localizedDescription)
}
}
}
/// Returns BOOL indicating whether file exists at specified directory with specified file name
static func fileExists(_ fileName: String, in directory: Directory) -> Bool {
let url = getURL(for: directory).appendingPathComponent(fileName, isDirectory: false)
return FileManager.default.fileExists(atPath: url.path)
}
}
And thatās it. Try running the app, bringing up preferences, setting a shortcut, bring some other apps infront of it and press the shortcuts to test it out. Not only this, but if you close the Main window and then press the key bind the app should re-open.
There are a few things that could be improved, such as checking if the shortcut is valid or already used by the system but that's a quest for another day.