Windows with Swift
Sections
- The main application
- Windows with Swift (this page)
- Setting (and retrieving) user preferences
I. Introduction
I'm just going to preface this by saying: holy hell, making windows work in Xcode and Swift is no small feat.
This project features two windows: one that shows some basic information about the application, and another that shows the application's changelog, which will be loaded from a text file packaged with the application.
II. The "About" window
A. Building the window
Hit ⌘+N or right-click in the project navigator to add a new file to your project. Select Window in the User Interface section. Click Next, give your new file a name, and click Create. My file is named InfoBox.xib
.
Now we have an empty window we need to customize. You can resize the window using the handles on the corners and sides.
We need to make a few customizations to the window's title bar.
Make sure you've selected the Window (not the View under the window), and find the Attribute Inspector icon in the righthand pane of your Xcode window. You can also find this pane in the application menu under View > Inspectors > Show Attribute Inspector.
Under Title, enter a display title (mine is "About LunaMac"), and under Controls, uncheck the boxes next to Minimize and Resize. Finally, make sure Visible at launch is checked. This refers the launch of the window, not the application.
B. Adding the controls
To add content to the window, we need to bring up the Objects Library - you can find this under View > Libraries > Show Library from the application menu, hit Shift+⌘+L, or click the Libraries icon in the toolbar of your Xcode window (it looks like a square inside a circle). Search for the word label, and drag a Label to the window. Change the Title attribute to your application name. Drag a Multi-line Label to the window, and position it below the label we just created. Change the Title attribute to a short summary (or other information) about your application.
The application icon in this window also serves as a button to open the changelog window. Open the Objects Library again, and search for and add an Image Button. Change the Image attribute of this button to the name of your application icon (in my case, LunaMac
). Resize and position this button to your liking.
C. Activating the window
We're going to go back to our main AppDelegate.swift
file. In your buildMenu()
function, add a new menu item above the Quit application menu item. This will activate a new showAbout()
function when clicked.
@objc func buildMenu(key: String = "default") {
...
statusBarMenu.addItem(
withTitle: "About LunaMac...",
action: #selector(AppDelegate.showAbout),
keyEquivalent: ""
)
...
}
showAbout()
needs to load this window. To accomplish this, we need to use an instance of NSWindowController
using InfoBox.xib
.
@objc func showAbout() {
// create infobox window object
let aboutWin = NSWindowController(windowNibName: "InfoBox")
aboutWin.loadWindow()
}
To make sure your window shows up on top of any other open windows, add a call to NSApp.activate
.
@objc func showAbout() {
...
NSApp.activate(ignoringOtherApps: true)
}
Launch your application, show the "About" window from your icon's menu, and bask in your success. But wait...there's more. Hit that menu item again, and note that you now have a duplicate window. We need to find a way to make sure we're only loading one instance of this window, and resurfacing the same window, rather than duplicating it.
This took awhile to figure out, so maybe I can save you the headache of blindly searching for answers and details.
D. Preventing window duplication
Swift follows the MVC model, in which models, views, and controls are separate components of your application. Because of this, we need to use an instance NSViewController
, which will give us access to window triggers that fire when the window is opened, closed, changed, etc.
We'll start with adding a Bool
scoped to our AppDelegate
class named infoOpen
. This is going to keep track of whether or not the "About" window is already open.
class AppDelegate: NSObject, NSApplicationDelegate {
...
// is the info box open?
var infoOpen = false
...
}
Now we need to add an NSViewController
to InfoBox.xib
. Open the Object Library, and add a View Controller to your xib.
To make this controller useful, we're going to create a class that inherits from NSViewController
.
Add a new Swift file to your project named ViewController.swift
. Remove the import Foundation
line at the top, and create your ViewController class. We also need a reference to our AppDelegate
class, so we can access our infoOpen
Bool
.
import Cocoa
class ViewController: NSViewController, NSWindowDelegate {
let appDelegate = NSApplication.shared.delegate as! AppDelegate
}
To handle setting infoOpen
, we need to override two methods in NSViewController
: viewDidLoad()
and viewWillDisappear()
.
In our viewDidLoad()
override, we need to call super.viewDidLoad()
, which ensures the inherited method will still run as expected.
Then, we're going to call a custom function that will load the window, or show it if it's already loaded.
override func viewDidLoad() {
super.viewDidLoad()
toggleWindow(boolShow: true)
}
Our toggleWindow()
function looks at the title of the window that triggered it, and sets the value of infoOpen
based on the Bool
passed by the original function call. This allows us to use the same function to either show or hide the window.
func toggleWindow(boolShow: Bool = false) {
// get the window title to set the right bool
// window title is in self.view.window.title
let winTitle = self.view.window?.title
if (winTitle?.contains("About") ?? false) {
appDelegate.infoOpen = boolShow
}
}
We need something similar when the window closes, which triggers viewWillDisappear()
.
// triggers when window is closed
override func viewWillDisappear() {
toggleWindow(boolShow: false)
}
Now that we have our custom ViewController
class, we need to assign it to the View Controller we created in InfoBox.xib
.
In the xib
file, select the View Controller, and open the Identity Inspector in the righthand pane of your Xcode window. In the Custom Class section, set the Class value to ViewController (the name of our class).
This is where things get more than slightly confusing. Xcode relies on a mouse-driven graphical interface for connecting code to windows and controls in windows. To associate the View Controller with our window, hold down Ctrl
while click-and-dragging from View Controller in the tree to the View in the window - the whole window area excluding the title bar. A little menu will appear; choose View under Outlets.
If you look at the source code (XML) view for InfoBox.xib
, there's now a <viewController>
section that references the automatically-generated id
of the <view>
. You can manually set these element id
values, and set up the connection between view and controller directly in the XML.
<window title="About LunaMac" ... id="lunaWindow">
...
<view key="contentView" wantsLayer="YES" id="lunaView">
...
</view>
...
</window>
<viewController id="lunaViewController" customClass="ViewController" customModule="LunaMac" customModuleProvider="target">
<connections>
<outlet property="view" destination="lunaView" id="lunaViewOutlet"/>
</connections>
</viewController>
Launch your app, and verify that everything works. Now, when you select "About..." from the status bar icon's menu, if the window's already open, it will display, rather than creating a new instance of the window.
E. Adding an action to the button
Recall when we created InfoBox
, we included an Image Button using the application's icon. Now we're going to associate a function with this button. First, we'll create a special kind of function using @IBAction
in our AppDelegate
class. Note: this documentation relates to UIKit, which we aren't using in this application. The @IBAction
concept is the same.
When the button is clicked, it calls clickClick()
, which in turn calls showLog()
.
@IBAction func clickClick(_ sender: NSButtonCell) {
// show changelog
showLog()
}
showLog()
is a simple function that behaves just like showAbout()
. Add the function to your AppDelegate
class definition.
@objc func showLog() {
if (!logOpen) {
// create changelog window object
// this creates duplicate windows
let logWin = NSWindowController(windowNibName: "Changelog")
logWin.loadWindow()
}
NSApp.activate(ignoringOtherApps: true)
}
To connect this action to your button, you can either edit the XML, or use the mouse-driven Interface Builder. The XML is simple:
<window ... >
...
<button ... >
...
<connections>
<action selector="clickClick:" target="-1" id="clickAction"/>
</connections>
</button>
...
</window>
Alternatively, you can Ctrl
-and-drag from the button's Button Cell to the First Responder you'll find under Placeholders, and select your function clickClick:
from the list that appears.
D. Displaying the build version number
In LunaMac version 1.4.3, I updated the About window to dynamically display the current build number, which is set in Info.plist
. To make this slightly easier, and because Xcode offers no way to assign unique, meaningful names to window elements, we're first going to set the Tag
property of the "Using LunaMac" text label to 100
.
I've seen advice against using tags to identify form elements, but it's consistent and predictable, so I'm sticking with it. Note that the Tag
property must be an integer, so you'll have to keep track of your tag numbers if you decide to use this method in your own projects.
To programmatically access this label and set its value, we're going to update our viewDidAppear()
function in ViewController.swift
.
We can retrieve the build version from the global Bundle
object, using Bundle.main.appBuild
.
override func viewDidAppear() {
...
if (title.contains("Changelog")) {
...
}
else if (title.contains("About")) {
// set label with version number
if (label.tag == 100) {
label.stringValue = ("Using LunaMac " + Bundle.main.appBuild)
}
}
}
Launch your application and open the About window, and you'll see the current version number displayed.
Now we need to make a changelog dialog, and the code to load it and its contents.
III. The Changelog window
Add a new Window document to your project named Changelog.xib
. Next, add an Empty file - this file type can be found in the Other section under MacOS templates. Name this file changelog.txt
- we'll load the contents of this file into the changelog window, making it easy to maintain the changelog separate from the application.
Change the Window attributes to remove the minimize and maximize buttons, and set the title to Changelog
. Add a Scrollable Text View, and resize it to fill out the window. Navigate through the window tree to the Text View that sits under Scroll View, and uncheck the box next to Editable in the Attributes Inspector.
To interact with this window and its contents, we need a View Controller. Add one from the Object Library. As we did with InfoBox.xib
, change this View Controller to use our custom class named ViewController
. Connect this View Controller to your changelog window's View, either through the Ctrl
-and-drag, or by editing the XML directly in Changelog.xib
.
Since we're also going to be changing the contents of our Scrollable Text View, we need to add an outlet for it, as well.
<window title="Changelog" ... id="lunaWindow">
...
<view key="contentView" wantsLayer="YES" id="lunaView">
...
</view>
...
</window>
<viewController id="lunaViewController" customClass="ViewController" customModule="LunaMac" customModuleProvider="target">
<connections>
<outlet property="view" destination="lunaView" id="lunaViewOutlet"/>
<outlet property="textView" destination="lunaText" id="lunaTextOutlet"/>
</connections>
</viewController>
A. Tracking the window status
Displaying the Changelog window also requires a Bool
value to track whether it's already open, so add one to your AppDelegate
class definition in AppDelegate.swift
.
class AppDelegate: NSObject, NSApplicationDelegate {
...
// is the info box open?
var logOpen = false
...
}
B. Handling the view controller
Then, add the code to toggleWindow()
in ViewController.swift
to handle showing the changelog window, similar to how we handled the info window.
func toggleWindow(boolShow: Bool = false) {
...
if (winTitle?.contains("About") ?? false) {
appDelegate.infoOpen = boolShow
}
else if (winTitle?.contains("Changelog") ?? false) {
appDelegate.logOpen = boolShow
}
}
Now you can launch your application and access both the info and changelog windows. We're in the home stretch!
C. Loading the changelog file
In our ViewController
class, we're going to override NSViewController.viewDidAppear()
to load and display the contents of changelog.txt
in our changelog window.
Since changelog.txt
will be packaged with our application, we can access it as a resource in NSBundle.main
. We'll catch any failures with ?? ""
.
override func viewDidAppear() {
// use this to load the changelog
if (self.view.window?.title.contains("Changelog") ?? false) {
// get log file path
let logPath = Bundle.main.path(forResource: "changelog.txt", ofType: nil) ?? ""
...
}
}
We'll use Swift's String.contentsOfFile()
method to read the contents of changelog.txt
into a variable. If the file can't be read for any reason, the window will show a simple error message.
Note the encoding:
argument ensures the file is read with the right encoding. The full list of supported formats can be found in Apple's documentation for String.Encoding
.
override func viewDidAppear() {
// use this to load the changelog
if (self.view.window?.title.contains("Changelog") ?? false) {
...
// get contents of log file
let logText = (try? String(contentsOfFile: logPath, encoding: String.Encoding.utf8)) ?? "Changelog couldn't be read."
...
}
}
To actually change the contents of our scrolling text control, we need to create an NSTextStorage
object that contains both the string and some formatting. Since the log is formatted as a plain text file, we'll use Apple's standard fixed width font, Monaco.
override func viewDidAppear() {
// use this to load the changelog
if (self.view.window?.title.contains("Changelog") ?? false) {
...
// create an object to hold the text and its formatting
let textStorage = NSTextStorage(string: logText)
textStorage.font = NSFont(name: "Monaco", size: 11)
textStorage.foregroundColor = NSColor.textColor
...
}
}
We made it to the finish line! To refer to our text control, we'll use that special syntax again, this time using @IBOutlet
class ViewController: NSViewController, NSWindowDelegate {
@IBOutlet var textView: NSTextView!
}
We'll assign our textStorage
object to our textView
, and we're done!
override func viewDidAppear() {
...
// replace textView's textStorage contents
self.textView?.layoutManager?.replaceTextStorage(textStorage)
}
Congratulations. You can now make simple Swift applications for MacOS with the concepts illustrated in this project. For the complete project, just clone this repo.