7 Swift Windows
Claire edited this page 2 years ago

Windows with Swift

Sections

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.